bareloop 0.42.0 → 0.43.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 (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bareloop.context.md +53 -30
  3. package/package.json +1 -1
  4. package/src/authorjob.js +6 -0
  5. package/src/declaredclose.js +2 -2
  6. package/src/interviewrun.js +0 -1
  7. package/src/job.js +38 -11
  8. package/src/panel/authorroutes.js +107 -33
  9. package/src/panel/authorsession.js +59 -27
  10. package/src/panel/index.html +813 -484
  11. package/src/panel/runroutes.js +163 -5
  12. package/src/panel/scheduler.js +156 -0
  13. package/src/panel/schedules.js +667 -0
  14. package/src/panel/schedulestart.js +243 -0
  15. package/src/panel/server.js +451 -48
  16. package/src/planrun.js +39 -7
  17. package/src/providerrows.js +3 -3
  18. package/src/reviewdoor.js +37 -0
  19. package/src/runlist.js +35 -0
  20. package/src/schedulewords.js +296 -0
  21. package/src/trigger.js +48 -0
  22. package/src/triggerclock.js +127 -0
  23. package/src/triggerfiles.js +215 -0
  24. package/src/triggerledger.js +79 -0
  25. package/src/userrun.js +6 -7
  26. package/types/declaredclose.d.ts +1 -1
  27. package/types/job.d.ts +8 -1
  28. package/types/panel/authorroutes.d.ts +34 -1
  29. package/types/panel/authorsession.d.ts +37 -12
  30. package/types/panel/runroutes.d.ts +32 -1
  31. package/types/panel/scheduler.d.ts +17 -0
  32. package/types/panel/schedules.d.ts +412 -0
  33. package/types/panel/schedulestart.d.ts +106 -0
  34. package/types/panel/server.d.ts +143 -14
  35. package/types/planrun.d.ts +2 -0
  36. package/types/providerrows.d.ts +2 -2
  37. package/types/reviewdoor.d.ts +27 -0
  38. package/types/runlist.d.ts +20 -0
  39. package/types/schedulewords.d.ts +135 -0
  40. package/types/trigger.d.ts +37 -0
  41. package/types/triggerclock.d.ts +52 -0
  42. package/types/triggerfiles.d.ts +93 -0
  43. package/types/triggerledger.d.ts +35 -0
@@ -23,10 +23,15 @@ import { openSync } 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
28
  import { proveInputFiles, looksLikeRubricInput } 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.";
30
35
  import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
31
36
  import { ConfigError } from '../config.js';
32
37
  import { keysForDoor, keysHome } from '../keysfile.js';
@@ -95,6 +100,9 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
95
100
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
96
101
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
97
102
  * jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
103
+ * schedules?: ReturnType<typeof import('./schedules.js').createScheduleDoor>,
104
+ * sessionDeps?: Record<string, any>,
105
+ * runJobNames?: () => string[],
98
106
  * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
99
107
  */
100
108
  export function createAuthorRoutes(opts) {
@@ -110,8 +118,37 @@ export function createAuthorRoutes(opts) {
110
118
  /** @type {Map<string, ReturnType<typeof createSession>>} */
111
119
  const sessions = new Map();
112
120
 
121
+ /** Headless (scheduled) sessions live apart from the person's: neither blocks nor is blocked by the other (T I.5). Not addressable over HTTP. */
122
+ /** @type {Map<string, ReturnType<typeof createSession>>} */
123
+ const headless = new Map();
124
+
113
125
  const hasLiveSession = () => [...sessions.values()].some((s) => !TERMINAL_PHASES.has(s.state.phase));
114
126
 
127
+ /**
128
+ * 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
129
+ * HTTP route and the schedule starter share ONE function. A scheduled fire is exactly a Reuse the person did not click.
130
+ * `headless` puts the session in its own map and skips the one-live-session rule.
131
+ * @param {any} card the form (fields only)
132
+ * @param {{spec: any, workflowKey: string}|null} reuse
133
+ * @param {import('../jobblock.js').JobInput[]} inputs the parsed Inputs
134
+ * @param {{headless?: boolean, filesJob?: boolean}} [o]
135
+ * @returns {Promise<{ok: true, session: ReturnType<typeof createSession>}|{ok: false, status: number, error: string}>}
136
+ */
137
+ function startSession(card, reuse, inputs, o = {}) {
138
+ return (o.filesJob ? Promise.resolve(/** @type {{ok: true}} */ ({ ok: true })) : proveInputFiles(inputs)).then((proof) => {
139
+ if (!proof.ok) return { ok: /** @type {false} */ (false), status: 400, error: proof.error };
140
+ if (!o.headless && hasLiveSession()) return { ok: /** @type {false} */ (false), status: 409, error: 'an authoring session is already live — one at a time' };
141
+ // the SAME Destination check the session runs, at $0, BEFORE the session (and its folder) exists
142
+ const destProblem = startDestinationProblem(card);
143
+ if (destProblem !== null) return { ok: /** @type {false} */ (false), status: 400, error: destProblem };
144
+ const session = createSession(cardFields(card), {
145
+ env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}), ...(opts.sessionDeps ?? {}),
146
+ });
147
+ (o.headless ? headless : sessions).set(session.id, session);
148
+ return { ok: /** @type {true} */ (true), session };
149
+ }).catch((e) => ({ ok: /** @type {false} */ (false), status: 500, error: `could not check the inputs: ${/** @type {Error} */ (e)?.message ?? e}` }));
150
+ }
151
+
115
152
  /**
116
153
  * @param {import('node:http').IncomingMessage} req
117
154
  * @param {import('node:http').ServerResponse} res
@@ -217,10 +254,20 @@ export function createAuthorRoutes(opts) {
217
254
  const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
218
255
  if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
219
256
  if (!pre.ok) { send(409, pre); return true; }
257
+ // 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)
258
+ const sched = sp.get('mode') === 'schedule' && opts.schedules ? opts.schedules.readSchedule(pre.origin.job) : null;
259
+ const card = typeof sched?.destination === 'string' ? { ...pre.card, destination: sched.destination } : pre.card;
220
260
  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,
261
+ ok: true, origin: pre.origin, card, from: pre.from, note: pre.note,
262
+ // ONE door (v1.101): `green` = the origin earned a real green (an imported job counts), so an unchanged job reuses the signed plan;
263
+ // `nextName` = the Job name box once a job box changes (the box shows the origin's name until then). Nothing is locked.
264
+ green: pre.origin.importId !== undefined || pre.outcome === 'green' || pre.outcome === 'already-green',
265
+ nextName: nextFreeJobName(pre.origin.job, opts.runJobNames ? opts.runJobNames() : [], { jobsDir: opts.jobsDir }),
266
+ specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
223
267
  line: pre.line, specNamesModel: pre.specNamesModel, specWorker: pre.specWorker,
268
+ // T Part 3: `mode=schedule` also returns the job's current schedule (data only) for the When field's prefill; Part 7 adds the
269
+ // card's own words (`scheduleCard`: now / refusal / the button table). A reuse never touches a schedule (v1.100).
270
+ ...(sp.get('mode') === 'schedule' && opts.schedules ? { schedule: opts.schedules.read(pre.origin.job), scheduleCard: opts.schedules.cardFor(pre.origin.job) } : {}),
224
271
  });
225
272
  return true;
226
273
  }
@@ -236,6 +283,17 @@ export function createAuthorRoutes(opts) {
236
283
  return true;
237
284
  }
238
285
 
286
+ // ── T Part 3: the schedule door, two clicks, both $0 (no drafter, no run, no provider call). Click 1 signs and
287
+ // writes nothing; click 2 carries the hash it was shown. `when: null` cancels (never refused). Placed with the
288
+ // other exact matches, before the session regex below.
289
+ if (pathname === '/api/author/schedule-prepare' || pathname === '/api/author/schedule') {
290
+ if (!opts.schedules) { send(404, { ok: false, error: 'not found' }); return true; }
291
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
292
+ const r = pathname.endsWith('-prepare') ? opts.schedules.prepare(body ?? {}) : opts.schedules.set(body ?? {});
293
+ send(r.status, r.body);
294
+ return true;
295
+ }
296
+
239
297
  // A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
240
298
  // or `null`. The server's session map is the only truth; nothing is stored page-side.
241
299
  if (pathname === '/api/author/live') {
@@ -250,11 +308,15 @@ export function createAuthorRoutes(opts) {
250
308
  if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
251
309
  const card = body ?? {};
252
310
  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.
311
+ // Reuse workflow, ONE DOOR (v1.101): every box is open; the CARD's job boxes (job text, check type, thinking) are compared with
312
+ // the origin run's. A GREEN origin (or an imported job) with no job box changed signs the origin's plan verbatim, under the
313
+ // origin's name, with no drafting. Any other start drafts: a changed job is a NEW workflow under the Job name box; an unchanged
314
+ // 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
315
+ // here, before drafting, so the hash the person signs already covers it. The server never trusts the page.
256
316
  /** @type {{spec: any, workflowKey: string}|null} */
257
317
  let reuse = null;
318
+ /** @type {string|undefined} */
319
+ let allowName;
258
320
  const from = card.startFrom;
259
321
  const fromRunid = typeof from === 'string' ? from : (typeof from?.runid === 'string' ? from.runid : null);
260
322
  const fromImport = typeof from?.importId === 'string' ? from.importId : null;
@@ -262,24 +324,33 @@ export function createAuthorRoutes(opts) {
262
324
  const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
263
325
  if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
264
326
  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;
327
+ const changed = jobBoxesChanged(card, pre.card);
328
+ if (changed) {
329
+ // 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)
330
+ if (typeof card.jobName === 'string' && (card.jobName === pre.origin.job || (opts.runJobNames && opts.runJobNames().includes(card.jobName)))) {
331
+ send(400, { ok: false, error: `A job named "${card.jobName}" already exists and is a different job. Pick another name.` });
332
+ return true;
333
+ }
334
+ } else {
335
+ // a scheduled run's green waits for the person's Accept (quarantine): its plan is neither reused nor re-drafted until then
336
+ 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; }
337
+ card.jobName = pre.origin.job; // an unchanged job keeps its name; a typed one is ignored
338
+ if (fromImport !== null || pre.outcome === 'green' || pre.outcome === 'already-green') {
339
+ const rv = validateReuseCard(card, { rows });
340
+ if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
341
+ const spec = buildReuseSpec(pre.spec, card, rows);
342
+ // validateJob can THROW (not only red) on a saved job the guard-battery lookup no longer admits: same plain words, never a raw 500
343
+ /** @type {ReturnType<typeof validateJob>|null} */
344
+ let jv = null;
345
+ try { jv = validateJob(spec, { shellCapUsd: spec.budgetUsd }); } catch { jv = null; }
346
+ // 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
347
+ 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; }
348
+ reuse = { spec, workflowKey: pre.workflowKey };
349
+ } else allowName = pre.origin.job;
271
350
  }
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
351
  }
281
352
  if (reuse === null) {
282
- const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows });
353
+ const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows, ...(allowName !== undefined ? { allowName } : {}) });
283
354
  if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
284
355
  }
285
356
  // P7: the Inputs are proven at $0 before a session (so before any token): line 1 the repo, lines 2+ tracked files inside it.
@@ -289,15 +360,10 @@ export function createAuthorRoutes(opts) {
289
360
  // Part 1a: a rubric job whose first input is a local FILE has no repo to prove inputs against; the session proves every
290
361
  // line itself (`proveRubricInputs`), at $0, before any model call
291
362
  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}` }); });
363
+ startSession(card, reuse, ip.inputs, { filesJob }).then((r) => {
364
+ if (!r.ok) { send(r.status, { ok: false, error: r.error }); return; }
365
+ send(200, { ok: true, sessionId: r.session.id, reuse: reuse !== null, state: r.session.state });
366
+ });
301
367
  return true;
302
368
  }
303
369
 
@@ -336,7 +402,7 @@ export function createAuthorRoutes(opts) {
336
402
  }
337
403
  if (sub === 'sign') {
338
404
  const claimedHash = String(body?.specHash ?? '');
339
- const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
405
+ const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home, ...(opts.schedules ? { schedules: opts.schedules } : {}) });
340
406
  send(r.ok ? 200 : 400, r);
341
407
  return true;
342
408
  }
@@ -344,7 +410,11 @@ export function createAuthorRoutes(opts) {
344
410
  return true;
345
411
  }
346
412
 
347
- return { handle, sessions };
413
+ return {
414
+ handle, sessions, headless, startSession, envNow, spawnFn, bareloopBin,
415
+ /** a headless session the starter has removed from disk leaves its map too @param {string} id */
416
+ forgetHeadless: (id) => { headless.delete(id); },
417
+ };
348
418
  }
349
419
 
350
420
  /**
@@ -378,7 +448,7 @@ export function panelMoney2(n) {
378
448
  * it to sign"), and this is where it is checked, not trusted.
379
449
  * @param {ReturnType<typeof createSession>} session
380
450
  * @param {string} claimedHash
381
- * @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string}} o
451
+ * @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
452
  * @returns {{ok: boolean, error?: string, job?: string}}
383
453
  */
384
454
  export function signRun(session, claimedHash, o) {
@@ -430,6 +500,8 @@ export function signRun(session, claimedHash, o) {
430
500
  'systemd-inhibit', '--why=bareloop panel run',
431
501
  process.execPath, o.bareloopBin,
432
502
  'run-u', '--spec', session.state.resolvedSpecPath, '--approve', session.state.specHash,
503
+ // T (F.1): a scheduled run opens the review door even when deterministic, so its green waits for the person's Accept
504
+ ...(o.reviewDoor === true ? ['--review-door'] : []),
433
505
  ...(draftSpentUsd !== null ? ['--draft-spent-usd', String(draftSpentUsd), ...(draftIncomplete ? ['--draft-spend-incomplete'] : [])] : []),
434
506
  ];
435
507
  /** @type {number|null} */
@@ -439,6 +511,8 @@ export function signRun(session, claimedHash, o) {
439
511
  detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: o.env,
440
512
  });
441
513
  if (typeof child?.unref === 'function') child.unref();
514
+ if (o.onSpawnError && typeof child?.once === 'function') child.once('error', o.onSpawnError);
515
+ if (o.onExit && typeof child?.once === 'function') child.once('exit', o.onExit);
442
516
  session.state.phase = 'signed';
443
517
  // hamr 2026-10-05: the sign shows as the last step of the one progress list, never as a thread bubble
444
518
  // hamr 2026-10-05 (option A): the drafting-spend line is that step's detail — nothing when there was no drafting spend (a Reuse)
@@ -198,6 +198,25 @@ export function jobNameTaken(name, opts = {}) {
198
198
  } catch { return false; }
199
199
  }
200
200
 
201
+ /**
202
+ * 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.
203
+ * 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`
204
+ * suggests `x-3`, never `x-2-2`); else the origin's own name. Free = not a run-list job and not {@link jobNameTaken}.
205
+ * @param {string} origin
206
+ * @param {Iterable<string>} runJobs job names in the panel's run list
207
+ * @param {{jobsDir?: string}} [opts] test seam for {@link jobNameTaken}
208
+ * @returns {string}
209
+ */
210
+ export function nextFreeJobName(origin, runJobs, opts = {}) {
211
+ const used = new Set(runJobs);
212
+ const m = /^(.+)-\d+$/.exec(origin);
213
+ const base = m && (used.has(m[1]) || jobNameTaken(m[1], opts)) ? m[1] : origin;
214
+ for (let n = 2; ; n += 1) {
215
+ const name = `${base}-${n}`;
216
+ if (!used.has(name) && !jobNameTaken(name, opts)) return name;
217
+ }
218
+ }
219
+
201
220
  /** the form's fields, in card order — what `card.json` stores, verbatim (P5 item 3) */
202
221
  export const CARD_FIELDS = Object.freeze(['jobName', 'checkType', 'provider', 'model', 'thinking', 'jobText', 'inputs', 'destination', 'capUsd', 'maxWallMs']);
203
222
 
@@ -225,30 +244,25 @@ export function cardFields(card) {
225
244
  }
226
245
 
227
246
  /**
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.
247
+ * 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
248
+ * 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,
249
+ * `~` rules, `~ PASS`/`~ FAIL`), the check type and the thinking tick. The RUN boxes (Inputs, Destination, Model, $ cap, Time cap)
250
+ * never decide it. A green origin with no job box changed reuses the signed plan verbatim (no drafting); anything else drafts.
232
251
  */
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
- }));
252
+ export const JOB_BOXES = Object.freeze(['jobText', 'checkType', 'thinking']);
239
253
 
240
254
  /**
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.
255
+ * Did any JOB box on `card` change from what the origin run prefilled (`origin`)? Whitespace is not compared (a one-line input drops
256
+ * a text's newlines); an absent thinking tick reads as off.
243
257
  * @param {Record<string, any>} card the form as it was submitted
244
258
  * @param {Record<string, any>} origin the form as the server prefilled it
245
- * @returns {string|null} the offending field's label
259
+ * @returns {boolean}
246
260
  */
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);
261
+ export function jobBoxesChanged(card, origin) {
262
+ const text = (/** @type {any} */ v) => (typeof v === 'string' ? v.replace(/\s+/g, '') : '');
263
+ return text(card?.jobText) !== text(origin?.jobText)
264
+ || (card?.checkType === 'rubric') !== (origin?.checkType === 'rubric')
265
+ || (card?.thinking === true) !== (origin?.thinking === true);
252
266
  }
253
267
 
254
268
  /**
@@ -267,7 +281,9 @@ export function lockedFieldChanged(card, origin) {
267
281
  */
268
282
  export function buildReuseSpec(originSpec, card, rows) {
269
283
  const spec = JSON.parse(JSON.stringify(originSpec));
270
- spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
284
+ // 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
285
+ // 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).
286
+ if (!isAbsoluteDestination(String(card.destination ?? '').trim())) spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
271
287
  const ip = parseInputs(card.inputs);
272
288
  if (ip.ok) spec.inputs = ip.inputs; else delete spec.inputs;
273
289
  spec.budgetUsd = card.capUsd;
@@ -310,6 +326,27 @@ const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(desti
310
326
 
311
327
  export { nonRepoSourceMessage };
312
328
 
329
+ /** the refusal for a plain-files rubric job whose Destination is not an absolute folder (ends with the words the session start shows) */
330
+ 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.';
331
+
332
+ /**
333
+ * THE ONE Destination check of a session start, at $0, before any session, copy or model call. The session's own pipeline, the HTTP
334
+ * start (`startSession`), the schedule's two clicks and a scheduled fire's `wouldAccept` all call THIS (never a copy of it), so a start
335
+ * that would be refused for its Destination is refused before anything is made.
336
+ * @param {{inputs?: unknown, destination?: unknown, checkType?: unknown}} card the Inputs text (line 1 = the source), the Destination and the check type
337
+ * @returns {string|null} the plain refusal, or null when the Destination is fine
338
+ */
339
+ export function startDestinationProblem(card) {
340
+ const ip = parseInputs(card?.inputs);
341
+ const source = ip.ok ? (ip.inputs[0]?.value ?? '') : '';
342
+ const destination = typeof card?.destination === 'string' ? card.destination : '';
343
+ const isRepoLike = looksLikeRepoSource(source);
344
+ const repo = repoDestinationProblem(destination, isRepoLike);
345
+ if (repo !== null) return repo;
346
+ if (card?.checkType === 'rubric' && !isRepoLike && !isAbsoluteDestination(destination)) return FILES_DESTINATION_WORDS;
347
+ return null;
348
+ }
349
+
313
350
  /**
314
351
  * The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
315
352
  * 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 +364,14 @@ export function repoDestinationProblem(destination, isRepoLike) {
327
364
  * creates a session or spends anything — never inside the async pipeline,
328
365
  * so a bad card refuses synchronously, in the same HTTP response.
329
366
  * @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
367
+ * @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
368
  * @returns {{ok: true}|{ok: false, error: string}}
332
369
  */
333
370
  export function validateJobCard(card, opts = {}) {
334
371
  if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
335
372
  const { jobName, checkType, model, destination, capUsd } = card;
336
373
  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 })) {
374
+ if (jobName !== opts.allowName && jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
338
375
  return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
339
376
  }
340
377
  if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
@@ -685,7 +722,7 @@ export function createSession(card, deps = {}) {
685
722
  // line is one file, frozen by `proveRubricInputs`; there is no repo, no language and no scout (docs/product/ITEM33-BUILD.md)
686
723
  const RUBRIC_FILES = card.checkType === 'rubric' && !isRepoLike;
687
724
  // 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);
725
+ const destProblem = startDestinationProblem(card);
689
726
  if (destProblem !== null) { refuse(destProblem); return; }
690
727
 
691
728
  state.phase = 'preparing-source';
@@ -707,10 +744,6 @@ export function createSession(card, deps = {}) {
707
744
  // fence read as a directory and refused as "not absolute" — hiding the real problem with the Source. A scope-shaped
708
745
  // (relative) Destination is therefore handed to the door only when the source is repo-like.
709
746
  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
747
  /** @type {any} */
715
748
  let prep;
716
749
  if (RUBRIC_FILES) {
@@ -945,7 +978,6 @@ export function createSession(card, deps = {}) {
945
978
  model: modelChoice.name,
946
979
  // the "Thinking on" tick is signed ONLY when ticked (unticked writes no field: the hash stays what it would have been)
947
980
  ...(card.thinking === true && draftThinkingPlan?.support !== false ? { thinking: true } : {}),
948
- cadence: { unit: 'day', every: 1 },
949
981
  budgetUsd: card.capUsd,
950
982
  ...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
951
983
  writeScope,