bareloop 0.41.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 (75) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +97 -34
  4. package/package.json +4 -3
  5. package/src/authorflow.js +337 -29
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +84 -16
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +14 -5
  10. package/src/authorscout.js +108 -0
  11. package/src/declaredclose.js +25 -14
  12. package/src/facts.js +317 -0
  13. package/src/factwords.js +129 -0
  14. package/src/inputlook.js +301 -0
  15. package/src/interviewrun.js +0 -1
  16. package/src/job.js +40 -12
  17. package/src/jobblock.js +7 -2
  18. package/src/kinds.js +84 -3
  19. package/src/labelscan.js +170 -0
  20. package/src/pagefetch.js +125 -0
  21. package/src/panel/authorroutes.js +121 -34
  22. package/src/panel/authorsession.js +154 -45
  23. package/src/panel/index.html +845 -490
  24. package/src/panel/jobplan.js +34 -11
  25. package/src/panel/runroutes.js +163 -5
  26. package/src/panel/scheduler.js +156 -0
  27. package/src/panel/schedules.js +667 -0
  28. package/src/panel/schedulestart.js +243 -0
  29. package/src/panel/server.js +467 -48
  30. package/src/planrun.js +42 -9
  31. package/src/providerrows.js +3 -3
  32. package/src/reviewdoor.js +37 -0
  33. package/src/rubricstep.js +804 -0
  34. package/src/runlist.js +35 -0
  35. package/src/schedulewords.js +296 -0
  36. package/src/source.js +166 -1
  37. package/src/trigger.js +48 -0
  38. package/src/triggerclock.js +127 -0
  39. package/src/triggerfiles.js +215 -0
  40. package/src/triggerledger.js +79 -0
  41. package/src/u-readout.js +1 -1
  42. package/src/userrun.js +20 -9
  43. package/types/authorflow.d.ts +267 -13
  44. package/types/authoring.d.ts +29 -1
  45. package/types/authorjob.d.ts +7 -5
  46. package/types/authorreadout.d.ts +27 -2
  47. package/types/authorscout.d.ts +70 -0
  48. package/types/declaredclose.d.ts +5 -3
  49. package/types/facts.d.ts +71 -0
  50. package/types/factwords.d.ts +42 -0
  51. package/types/inputlook.d.ts +238 -0
  52. package/types/job.d.ts +8 -1
  53. package/types/jobblock.d.ts +8 -1
  54. package/types/labelscan.d.ts +38 -0
  55. package/types/pagefetch.d.ts +71 -0
  56. package/types/panel/authorroutes.d.ts +34 -1
  57. package/types/panel/authorsession.d.ts +55 -14
  58. package/types/panel/jobplan.d.ts +12 -4
  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 +145 -14
  64. package/types/planrun.d.ts +2 -0
  65. package/types/providerrows.d.ts +2 -2
  66. package/types/reviewdoor.d.ts +27 -0
  67. package/types/rubricstep.d.ts +286 -0
  68. package/types/runlist.d.ts +20 -0
  69. package/types/schedulewords.d.ts +135 -0
  70. package/types/source.d.ts +94 -0
  71. package/types/trigger.d.ts +37 -0
  72. package/types/triggerclock.d.ts +52 -0
  73. package/types/triggerfiles.d.ts +93 -0
  74. package/types/triggerledger.d.ts +35 -0
  75. package/types/userrun.d.ts +11 -0
@@ -22,10 +22,16 @@ import { spawn as realSpawn } from 'node:child_process';
22
22
  import { openSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { readFileSync } from 'node:fs';
25
- import { createSession, validateJobCard, validateReuseCard, lockedFieldChanged, buildReuseSpec, cardFields, STEP_LABELS } from './authorsession.js';
25
+ import { planMatchesClose } from '../rubricstep.js';
26
+ import { createSession, validateJobCard, validateReuseCard, jobBoxesChanged, buildReuseSpec, cardFields, nextFreeJobName, startDestinationProblem, STEP_LABELS } from './authorsession.js';
26
27
  import { parseInputs } from '../jobblock.js';
27
- import { proveInputFiles } from '../source.js';
28
+ import { proveInputFiles, looksLikeRubricInput } from '../source.js';
28
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.";
29
35
  import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
30
36
  import { ConfigError } from '../config.js';
31
37
  import { keysForDoor, keysHome } from '../keysfile.js';
@@ -94,6 +100,9 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
94
100
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
95
101
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
96
102
  * jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
103
+ * schedules?: ReturnType<typeof import('./schedules.js').createScheduleDoor>,
104
+ * sessionDeps?: Record<string, any>,
105
+ * runJobNames?: () => string[],
97
106
  * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
98
107
  */
99
108
  export function createAuthorRoutes(opts) {
@@ -109,8 +118,37 @@ export function createAuthorRoutes(opts) {
109
118
  /** @type {Map<string, ReturnType<typeof createSession>>} */
110
119
  const sessions = new Map();
111
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
+
112
125
  const hasLiveSession = () => [...sessions.values()].some((s) => !TERMINAL_PHASES.has(s.state.phase));
113
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
+
114
152
  /**
115
153
  * @param {import('node:http').IncomingMessage} req
116
154
  * @param {import('node:http').ServerResponse} res
@@ -216,10 +254,20 @@ export function createAuthorRoutes(opts) {
216
254
  const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
217
255
  if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
218
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;
219
260
  send(200, {
220
- ok: true, origin: pre.origin, card: pre.card, from: pre.from, note: pre.note,
221
- 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,
222
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) } : {}),
223
271
  });
224
272
  return true;
225
273
  }
@@ -235,6 +283,17 @@ export function createAuthorRoutes(opts) {
235
283
  return true;
236
284
  }
237
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
+
238
297
  // A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
239
298
  // or `null`. The server's session map is the only truth; nothing is stored page-side.
240
299
  if (pathname === '/api/author/live') {
@@ -249,11 +308,15 @@ export function createAuthorRoutes(opts) {
249
308
  if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
250
309
  const card = body ?? {};
251
310
  const rows = rowsForHome(keysHome(opts.home), envNow());
252
- // Reuse workflow — started from a green run or an imported job: only Source, Destination and the two caps may
253
- // differ from the signed workflow. The SERVER refuses any other change (the page greys those boxes, but is
254
- // 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.
255
316
  /** @type {{spec: any, workflowKey: string}|null} */
256
317
  let reuse = null;
318
+ /** @type {string|undefined} */
319
+ let allowName;
257
320
  const from = card.startFrom;
258
321
  const fromRunid = typeof from === 'string' ? from : (typeof from?.runid === 'string' ? from.runid : null);
259
322
  const fromImport = typeof from?.importId === 'string' ? from.importId : null;
@@ -261,39 +324,46 @@ export function createAuthorRoutes(opts) {
261
324
  const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
262
325
  if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
263
326
  if (!pre.ok) { send(409, pre); return true; }
264
- // only a run that earned a real green is a reuse source (hamr 2026-10-08) — the pickers already filter, but a
265
- // hand-made POST must not sign a reuse of an already-green, red or still-running run. Imports are green by ruling;
266
- // Edit in chat and Resume only READ start-from (GET) and never post a startFrom, so this gate never touches them.
267
- if (fromImport === null && pre.outcome !== 'green') {
268
- send(409, { ok: false, error: 'only a run that finished green can be reused — this one did not' });
269
- 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;
270
350
  }
271
- const locked = lockedFieldChanged(card, pre.card);
272
- if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
273
- const rv = validateReuseCard(card, { rows });
274
- if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
275
- const spec = buildReuseSpec(pre.spec, card, rows);
276
- const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
277
- 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; }
278
- reuse = { spec, workflowKey: pre.workflowKey };
279
351
  }
280
352
  if (reuse === null) {
281
- const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows });
353
+ const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows, ...(allowName !== undefined ? { allowName } : {}) });
282
354
  if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
283
355
  }
284
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.
285
357
  // A miss is a plain 400 naming the line, shown under the card exactly like every other refused start.
286
358
  const ip = parseInputs(card.inputs);
287
359
  if (!ip.ok) { send(400, { ok: false, error: ip.error }); return true; }
288
- proveInputFiles(ip.inputs).then((proof) => {
289
- if (!proof.ok) { send(400, { ok: false, error: proof.error }); return; }
290
- if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return; }
291
- const session = createSession(cardFields(card), {
292
- env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}),
293
- });
294
- sessions.set(session.id, session);
295
- send(200, { ok: true, sessionId: session.id, reuse: reuse !== null, state: session.state });
296
- }).catch((e) => { send(500, { ok: false, error: `could not check the inputs: ${/** @type {Error} */ (e)?.message ?? e}` }); });
360
+ // Part 1a: a rubric job whose first input is a local FILE has no repo to prove inputs against; the session proves every
361
+ // line itself (`proveRubricInputs`), at $0, before any model call
362
+ const filesJob = reuse === null && card?.checkType === 'rubric' && looksLikeRubricInput(ip.inputs[0].value);
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
+ });
297
367
  return true;
298
368
  }
299
369
 
@@ -332,7 +402,7 @@ export function createAuthorRoutes(opts) {
332
402
  }
333
403
  if (sub === 'sign') {
334
404
  const claimedHash = String(body?.specHash ?? '');
335
- 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 } : {}) });
336
406
  send(r.ok ? 200 : 400, r);
337
407
  return true;
338
408
  }
@@ -340,7 +410,11 @@ export function createAuthorRoutes(opts) {
340
410
  return true;
341
411
  }
342
412
 
343
- 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
+ };
344
418
  }
345
419
 
346
420
  /**
@@ -374,7 +448,7 @@ export function panelMoney2(n) {
374
448
  * it to sign"), and this is where it is checked, not trusted.
375
449
  * @param {ReturnType<typeof createSession>} session
376
450
  * @param {string} claimedHash
377
- * @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
378
452
  * @returns {{ok: boolean, error?: string, job?: string}}
379
453
  */
380
454
  export function signRun(session, claimedHash, o) {
@@ -393,6 +467,15 @@ export function signRun(session, claimedHash, o) {
393
467
  if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
394
468
  return { ok: false, error: 'could not read the signed spec to check the monthly limit — refusing to sign' };
395
469
  }
470
+ // PART 1a, the sign-vs-run invariant: the checks on the plan card the person saw are exactly the checks of the close about to be
471
+ // signed, per line. A mismatch is refused in plain words and nothing is spawned.
472
+ if (session.state.finalPlan) {
473
+ try {
474
+ const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
475
+ const m = planMatchesClose(session.state.finalPlan.checkItems, spec.closeDecl);
476
+ if (!m.ok) return { ok: false, error: m.error };
477
+ } catch { return { ok: false, error: 'could not read the signed spec to compare it with the plan you were shown — refusing to sign' }; }
478
+ }
396
479
  const logFile = join(session.state.outDir, 'run.log');
397
480
  // array argv, never a shell string — `--approve <hash>` is a literal
398
481
  // element, checkable byte-for-byte by a test stubbing `spawnFn`, and there
@@ -417,6 +500,8 @@ export function signRun(session, claimedHash, o) {
417
500
  'systemd-inhibit', '--why=bareloop panel run',
418
501
  process.execPath, o.bareloopBin,
419
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'] : []),
420
505
  ...(draftSpentUsd !== null ? ['--draft-spent-usd', String(draftSpentUsd), ...(draftIncomplete ? ['--draft-spend-incomplete'] : [])] : []),
421
506
  ];
422
507
  /** @type {number|null} */
@@ -426,6 +511,8 @@ export function signRun(session, claimedHash, o) {
426
511
  detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: o.env,
427
512
  });
428
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);
429
516
  session.state.phase = 'signed';
430
517
  // hamr 2026-10-05: the sign shows as the last step of the one progress list, never as a thread bubble
431
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)