bareloop 0.43.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 (57) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bareloop.context.md +52 -13
  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 +27 -15
  7. package/src/authorreadout.js +7 -6
  8. package/src/declaredclose.js +4 -1
  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/job.js +1 -1
  14. package/src/kinds.js +5 -0
  15. package/src/pagedropwords.js +64 -0
  16. package/src/pagemap.js +264 -0
  17. package/src/pagerules.js +319 -0
  18. package/src/panel/authorroutes.js +34 -30
  19. package/src/panel/authorsession.js +29 -5
  20. package/src/panel/index.html +465 -65
  21. package/src/panel/jobplan.js +4 -2
  22. package/src/panel/runroutes.js +46 -22
  23. package/src/panel/server.js +791 -28
  24. package/src/panel/status.js +33 -0
  25. package/src/panelmoney.js +29 -0
  26. package/src/planrun.js +53 -4
  27. package/src/promptregisters.js +4 -0
  28. package/src/replay.js +118 -7
  29. package/src/rubricstep.js +123 -11
  30. package/src/runlist.js +2 -1
  31. package/src/schedulewords.js +6 -3
  32. package/src/source.js +18 -0
  33. package/src/thinking.js +63 -2
  34. package/src/userrun.js +28 -14
  35. package/types/authorflow.d.ts +42 -6
  36. package/types/authorjob.d.ts +10 -2
  37. package/types/declaredclose.d.ts +2 -0
  38. package/types/draftspend.d.ts +27 -1
  39. package/types/facts.d.ts +15 -0
  40. package/types/factwords.d.ts +26 -3
  41. package/types/inputlook.d.ts +15 -0
  42. package/types/kinds.d.ts +5 -0
  43. package/types/pagedropwords.d.ts +26 -0
  44. package/types/pagemap.d.ts +154 -0
  45. package/types/pagerules.d.ts +86 -0
  46. package/types/panel/authorroutes.d.ts +17 -14
  47. package/types/panel/runroutes.d.ts +2 -2
  48. package/types/panel/server.d.ts +155 -2
  49. package/types/panel/status.d.ts +51 -0
  50. package/types/panelmoney.d.ts +5 -0
  51. package/types/replay.d.ts +21 -1
  52. package/types/rubricstep.d.ts +57 -1
  53. package/types/runlist.d.ts +4 -1
  54. package/types/schedulewords.d.ts +4 -2
  55. package/types/source.d.ts +11 -0
  56. package/types/thinking.d.ts +39 -0
  57. package/types/userrun.d.ts +4 -0
@@ -19,24 +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
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
30
 
31
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
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
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
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.";
35
37
  import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
36
38
  import { ConfigError } from '../config.js';
37
39
  import { keysForDoor, keysHome } from '../keysfile.js';
38
40
  import { endpointOf, judgePick, keyValueOf, rowsForHome, tickedLines } from '../providerrows.js';
39
41
  import { thinkingForLine } from '../thinking.js';
42
+ import { panelMoney2 } from '../panelmoney.js';
40
43
  import { apiKeyProblem, checkProviderReachable } from '../providers.js';
41
44
 
42
45
  /** @returns {string} a fresh per-process token — never persisted, never logged */
@@ -90,6 +93,18 @@ export function checkHumanGuard(req, { token, port }) {
90
93
  * one; a terminal session is kept (so its chat/hash stay readable) but no
91
94
  * longer counts against the one-at-a-time rule.
92
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
+
93
108
  const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'signing-failed']);
94
109
 
95
110
  /**
@@ -131,11 +146,17 @@ export function createAuthorRoutes(opts) {
131
146
  * @param {any} card the form (fields only)
132
147
  * @param {{spec: any, workflowKey: string}|null} reuse
133
148
  * @param {import('../jobblock.js').JobInput[]} inputs the parsed Inputs
134
- * @param {{headless?: boolean, filesJob?: boolean}} [o]
149
+ * @param {{headless?: boolean}} [o]
135
150
  * @returns {Promise<{ok: true, session: ReturnType<typeof createSession>}|{ok: false, status: number, error: string}>}
136
151
  */
137
152
  function startSession(card, reuse, inputs, o = {}) {
138
- return (o.filesJob ? Promise.resolve(/** @type {{ok: true}} */ ({ ok: true })) : proveInputFiles(inputs)).then((proof) => {
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) => {
139
160
  if (!proof.ok) return { ok: /** @type {false} */ (false), status: 400, error: proof.error };
140
161
  if (!o.headless && hasLiveSession()) return { ok: /** @type {false} */ (false), status: 409, error: 'an authoring session is already live — one at a time' };
141
162
  // the SAME Destination check the session runs, at $0, BEFORE the session (and its folder) exists
@@ -299,6 +320,7 @@ export function createAuthorRoutes(opts) {
299
320
  if (pathname === '/api/author/live') {
300
321
  if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
301
322
  const live = [...sessions.values()].find((s) => !TERMINAL_PHASES.has(s.state.phase));
323
+ if (live) settleFetchingStep(live.state);
302
324
  send(200, { ok: true, sessionId: live ? live.state.id : null, state: live ? live.state : null });
303
325
  return true;
304
326
  }
@@ -357,10 +379,7 @@ export function createAuthorRoutes(opts) {
357
379
  // A miss is a plain 400 naming the line, shown under the card exactly like every other refused start.
358
380
  const ip = parseInputs(card.inputs);
359
381
  if (!ip.ok) { send(400, { ok: false, error: ip.error }); return true; }
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) => {
382
+ startSession(card, reuse, ip.inputs).then((r) => {
364
383
  if (!r.ok) { send(r.status, { ok: false, error: r.error }); return; }
365
384
  send(200, { ok: true, sessionId: r.session.id, reuse: reuse !== null, state: r.session.state });
366
385
  });
@@ -375,6 +394,7 @@ export function createAuthorRoutes(opts) {
375
394
 
376
395
  if (sub === null) {
377
396
  if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
397
+ settleFetchingStep(session.state);
378
398
  send(200, { ok: true, state: session.state });
379
399
  return true;
380
400
  }
@@ -417,28 +437,7 @@ export function createAuthorRoutes(opts) {
417
437
  };
418
438
  }
419
439
 
420
- /**
421
- * The panel's OWN 2-decimal money render for a chat line — hamr's ruling
422
- * 2026-09-28 (2nd addendum, panel money 2-decimals): a positive amount below
423
- * a cent reads `<$0.01`, never `$0.00` (a real cost must never read as
424
- * free). This is the server-side (Node, chat-text) sibling of
425
- * `src/panel/index.html`'s own inline `panelMoney` — the two can't share
426
- * code (one runs in the browser as inlined script, the other in Node), the
427
- * same duplication `src/replay.js`'s `money()`/`index.html`'s `money()`
428
- * already carry, so both apply the identical rule rather than drift.
429
- * @param {number} n a non-negative dollar amount
430
- * @returns {string}
431
- */
432
- export function panelMoney2(n) {
433
- if (n > 0 && n < 0.01) return '<$0.01';
434
- // two-step rounding (matching index.html's own panelMoney): clean to
435
- // 6-decimal precision first (the same precision src/text.js's tallyCalls
436
- // sums to) before rounding to cents, so a summed float landing just under
437
- // an exact cent boundary is never mis-rounded.
438
- const clean = Math.round(n * 1e6) / 1e6;
439
- const cents = Math.round(clean * 100 + 1e-6);
440
- return `$${(cents / 100).toFixed(2)}`;
441
- }
440
+ export { panelMoney2 };
442
441
 
443
442
  /**
444
443
  * THE ONLY function in this file (or anywhere in the panel) that spawns a
@@ -520,5 +519,10 @@ export function signRun(session, claimedHash, o) {
520
519
  ? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
521
520
  : '';
522
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
+ }
523
527
  return { ok: true, job: session.state.resolvedSpecPath };
524
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
  /**
@@ -539,7 +541,20 @@ export function createSession(card, deps = {}) {
539
541
  * @param {{kind: string, [k: string]: any}} step
540
542
  * @returns {Promise<string|null>}
541
543
  */
542
- 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
+ };
543
558
  state.pendingAsk = step;
544
559
  resolvePending = resolveFn;
545
560
  if (step.kind === 'language') say('bot', `${step.field?.prompt ?? 'Which language is this job about?'} (${(step.candidates ?? []).join(', ')})`);
@@ -639,6 +654,8 @@ export function createSession(card, deps = {}) {
639
654
  kind: 'call', no: callNo, step: state.steps[callNo]?.id ?? null, label: call.label, model: draftIdentity?.model ?? null,
640
655
  thinking: draftThinkingPlan === null ? null : draftThinkingPlan.mode, thinkingSupport: draftThinkingPlan === null ? null : draftThinkingPlan.support,
641
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 } : {}),
642
659
  costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0, at: nowIso,
643
660
  });
644
661
  try {
@@ -1021,27 +1038,34 @@ export function createSession(card, deps = {}) {
1021
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
1022
1039
  // added WORD FOR WORD as a `~` rule on the job line it names — never rewritten. The lines are mutated in place, so the
1023
1040
  // signed `jobLines`, the answers text and the plan card all read the same words.
1024
- 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) => {
1025
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() });
1026
1043
  const total = o.questions.length;
1044
+ /** @type {{line: number, rule: number}[]} */
1045
+ const left = [];
1027
1046
  for (let k = 0; k < total; k += 1) {
1028
1047
  const q = o.questions[k];
1029
1048
  let text = '';
1030
1049
  for (;;) {
1031
- 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 });
1032
1051
  if (a === null) return null;
1033
1052
  // one `~` line is one line: the person's own words, newlines folded to spaces, nothing else touched
1034
1053
  text = String(a).replace(/\s*\n\s*/g, ' ').trim();
1035
1054
  if (text) break;
1036
1055
  say('bot', 'An answer is needed here — there is no skip. Type it in the box below and press Send.');
1037
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; }
1038
1062
  const line = jobLines.find((l) => l.n === q.line);
1039
1063
  if (line) { line.rule = line.rule ? `${line.rule}; ${text}` : text; line.rules.push(text); }
1040
1064
  }
1041
1065
  draft.jobLines = signedJobLines(jobLines);
1042
1066
  answers[1] = renderJobLines(jobLines);
1043
- state.messages.push({ ...planMessage(o.confirmed ?? {}, jobLines), text: 'Your answers are in as ~ rules on those job lines. Drafting again.' });
1044
- 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 };
1045
1069
  };
1046
1070
 
1047
1071
  state.phase = 'drafting';