bareloop 0.43.0 → 0.45.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 (64) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bareloop.context.md +53 -13
  3. package/package.json +2 -2
  4. package/src/authorflow.js +27 -12
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +52 -16
  7. package/src/authorreadout.js +12 -6
  8. package/src/authorrun.js +8 -2
  9. package/src/declaredclose.js +4 -1
  10. package/src/draftspend.js +20 -2
  11. package/src/facts.js +54 -9
  12. package/src/factwords.js +58 -6
  13. package/src/fitcheck.js +166 -0
  14. package/src/inputlook.js +38 -3
  15. package/src/job.js +1 -1
  16. package/src/kinds.js +5 -0
  17. package/src/pagedropwords.js +110 -0
  18. package/src/pagedry.js +125 -0
  19. package/src/pagemap.js +269 -0
  20. package/src/pagerules.js +318 -0
  21. package/src/panel/authorroutes.js +34 -30
  22. package/src/panel/authorsession.js +66 -11
  23. package/src/panel/index.html +469 -65
  24. package/src/panel/jobplan.js +14 -7
  25. package/src/panel/runroutes.js +46 -22
  26. package/src/panel/server.js +792 -28
  27. package/src/panel/status.js +33 -0
  28. package/src/panelmoney.js +29 -0
  29. package/src/planrun.js +53 -4
  30. package/src/promptregisters.js +8 -0
  31. package/src/replay.js +118 -7
  32. package/src/rubricstep.js +157 -15
  33. package/src/runlist.js +2 -1
  34. package/src/schedulewords.js +6 -3
  35. package/src/source.js +18 -0
  36. package/src/thinking.js +63 -2
  37. package/src/userrun.js +28 -14
  38. package/types/authorflow.d.ts +42 -6
  39. package/types/authorjob.d.ts +20 -4
  40. package/types/declaredclose.d.ts +2 -0
  41. package/types/draftspend.d.ts +27 -1
  42. package/types/facts.d.ts +15 -0
  43. package/types/factwords.d.ts +26 -3
  44. package/types/fitcheck.d.ts +113 -0
  45. package/types/inputlook.d.ts +15 -0
  46. package/types/kinds.d.ts +5 -0
  47. package/types/pagedropwords.d.ts +39 -0
  48. package/types/pagedry.d.ts +37 -0
  49. package/types/pagemap.d.ts +154 -0
  50. package/types/pagerules.d.ts +86 -0
  51. package/types/panel/authorroutes.d.ts +17 -14
  52. package/types/panel/authorsession.d.ts +4 -2
  53. package/types/panel/jobplan.d.ts +5 -2
  54. package/types/panel/runroutes.d.ts +2 -2
  55. package/types/panel/server.d.ts +156 -2
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/panelmoney.d.ts +5 -0
  58. package/types/replay.d.ts +21 -1
  59. package/types/rubricstep.d.ts +62 -1
  60. package/types/runlist.d.ts +4 -1
  61. package/types/schedulewords.d.ts +4 -2
  62. package/types/source.d.ts +11 -0
  63. package/types/thinking.d.ts +39 -0
  64. package/types/userrun.d.ts +4 -0
package/src/rubricstep.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, lstat } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { createRubric, checkStep, renderGaps, numbersInQuote } from 'bareguard';
5
+ import { dropRows, dropLine } from './pagedropwords.js';
5
6
  import { factPartOf, factShapeReds, factDecides, factClaims, factRows, plainFactItem, factBrief } from './facts.js';
6
7
 
7
8
  // PART 1a (docs/product/ITEM33-BUILD.md, "Addendum 2026-10-12"): the rubric job on LOCAL FILES — what one job line leaves
@@ -100,15 +101,35 @@ export function rubricGoal({ jobLines, files, confirmedGoal = null, closeDecl =
100
101
  const inputs = files.map((f) => ` - ${f.label}: ${f.rel}`).join('\n');
101
102
  const lines = jobLines.map((l) => `${l.n}. ${l.text}${l.rule ? `\n rule: ${l.rule}` : ''}`).join('\n');
102
103
  return [
103
- 'Do the job below from the input files, then write one JSON file per numbered line.',
104
+ RUBRIC_GOAL_HEAD,
104
105
  `The input files (label: file):\n${inputs}`,
105
106
  stepFileContract(),
106
107
  `The job, line by line:\n${lines}`,
107
108
  ...(closeDecl ? lineBriefs(closeDecl).map((b) => b.text) : []),
108
- ...(confirmedGoal ? [`The plan the person confirmed: ${confirmedGoal}`] : []),
109
+ ...(confirmedGoal ? [`${CONFIRMED_GOAL_PREFIX}${confirmedGoal}`] : []),
109
110
  ].join('\n\n');
110
111
  }
111
112
 
113
+ /**
114
+ * What a rubric job's goal text is: the worker brief `rubricGoal` builds. The Job tab's "AI's summary" is only the one
115
+ * sentence the person confirmed, never this brief, so the first line and the confirmed-sentence prefix have ONE spelling.
116
+ */
117
+ export const RUBRIC_GOAL_HEAD = 'Do the job below from the input files, then write one JSON file per numbered line.';
118
+ const CONFIRMED_GOAL_PREFIX = 'The plan the person confirmed: ';
119
+
120
+ /**
121
+ * The sentence the person confirmed, out of a goal text: for a rubric brief the rest of the `CONFIRMED_GOAL_PREFIX` line,
122
+ * or null when the brief has none; any other goal (a code job's) comes back unchanged.
123
+ * @param {string} goal
124
+ * @returns {string|null}
125
+ */
126
+ export function confirmedGoalOf(goal) {
127
+ if (!goal.startsWith(RUBRIC_GOAL_HEAD)) return goal;
128
+ const line = goal.split('\n').find((l) => l.startsWith(CONFIRMED_GOAL_PREFIX));
129
+ const sentence = line ? line.slice(CONFIRMED_GOAL_PREFIX.length).trim() : '';
130
+ return sentence || null;
131
+ }
132
+
112
133
  // ── the `rubric-step` stage: its parameters, and the rubric they build ───────────────────────────────────────────────
113
134
  //
114
135
  // A stage serves exactly ONE job line N and reads exactly one file, output/steps/N.json. Its parameters are
@@ -258,6 +279,46 @@ export function buildStepSpec(params, inputShas) {
258
279
  /** the sha256 of text, hex @param {string|Buffer} b @returns {string} */
259
280
  export const sha256Of = (b) => createHash('sha256').update(b).digest('hex');
260
281
 
282
+ /**
283
+ * The files one named check READS, tree-relative: a `rubric-step` stage's own step file (`output/steps/N.json`). Any other kind reads
284
+ * none that a try's output can be told by, so `[]`. From the signed declaration, never guessed.
285
+ * @param {any} closeDecl @param {string|null|undefined} checkName
286
+ * @returns {string[]}
287
+ */
288
+ export function judgedFilesOf(closeDecl, checkName) {
289
+ const stages = Array.isArray(closeDecl?.stages) ? closeDecl.stages : [];
290
+ const st = stages.find((/** @type {any} */ x) => x && x.name === checkName && x.kind === 'rubric-step');
291
+ return st && Number.isInteger(st.params?.step) ? [stepFileRel(st.params.step)] : [];
292
+ }
293
+
294
+ /**
295
+ * PRD v1.106 Part 5: what each file a try's `check-passes` checks read looked like WHEN it was judged: `{path, bytes, sha256}`. The step
296
+ * file is overwritten by the next try, so the spine's `output-judged` record is the only trace; the content itself is never written (hard
297
+ * line). A missing, non-regular or over-size file is skipped — nothing was judged there.
298
+ * @param {any} closeDecl @param {string} workdir @param {Array<{type?: string, name?: string}>} exits a step's declared exits
299
+ * @returns {Promise<Array<{path: string, bytes: number, sha256: string}>>}
300
+ */
301
+ export async function outputJudgedRecords(closeDecl, workdir, exits) {
302
+ /** @type {Array<{path: string, bytes: number, sha256: string}>} */
303
+ const out = [];
304
+ const seen = new Set();
305
+ for (const e of Array.isArray(exits) ? exits : []) {
306
+ if (!e || e.type !== 'check-passes') continue;
307
+ for (const rel of judgedFilesOf(closeDecl, e.name)) {
308
+ if (seen.has(rel)) continue;
309
+ seen.add(rel);
310
+ try {
311
+ const abs = join(workdir, rel);
312
+ const ls = await lstat(abs);
313
+ if (!ls.isFile() || ls.size > STEP_MAX_BYTES) continue;
314
+ const buf = await readFile(abs);
315
+ out.push({ path: rel, bytes: buf.length, sha256: sha256Of(buf) });
316
+ } catch { /* not there: nothing was judged */ }
317
+ }
318
+ }
319
+ return out;
320
+ }
321
+
261
322
  /**
262
323
  * The step file's bytes as the close reads them: the parsed JSON object, or WHY there is none. A file cut off mid-write
263
324
  * parses to nothing (reason `not-json`); text cut off INSIDE an otherwise valid JSON object cannot be told from a short
@@ -358,7 +419,9 @@ export function plainStepGap(step, why, g, params = null) {
358
419
  * `inputs` maps each cited label to its frozen TEXT; its sha256 is taken here from that text, and bareguard compares it to
359
420
  * the manifest's recorded sha via `inputShas`.
360
421
  * @param {Record<string, string>} inputShas the manifest's recorded sha256 per cited label
361
- * @returns {Promise<{verdict: 'green'|'red'|'stopped', gapLines: string[], fault: string|null, detail: Record<string, any>}>}
422
+ * @returns {Promise<{verdict: 'green'|'red'|'stopped', gapLines: string[], fault: string|null, detail: Record<string, any>,
423
+ * evidence?: any[], evidenceTotal?: number}>} `evidence`/`evidenceTotal` are bareguard's `full.evidence` (what each `cited` check read), carried out
424
+ * for the report-only `rubric-evidence` spine record; no verdict, gap or count above is derived from them
362
425
  */
363
426
  export async function gradeStep({ params, bytes, outputBytes, inputs, factQuotes = {}, factMissing = [] }, inputShas) {
364
427
  let rubric;
@@ -396,8 +459,12 @@ export async function gradeStep({ params, bytes, outputBytes, inputs, factQuotes
396
459
  const redCount = extraCount + r.full.gaps.reduce((/** @type {number} */ n, /** @type {any} */ g) => n + (g.check === 'cited' && g.kind === 'unsupported' && typeof g.measured === 'number' ? g.measured : 1), 0);
397
460
  const detail = { step: params.step, rubricSha: r.rubricSha, outputSha: r.outputSha, why: parsed.why, gaps: r.full.gaps.length, ...(r.verdict === 'red' || extra.length > 0 ? { redCount } : {}) };
398
461
  if (r.verdict === 'stopped') return { verdict: 'stopped', gapLines: [], fault: r.fault ? `${r.fault.kind}: ${r.fault.detail}` : 'the rubric stopped', detail };
399
- if (r.verdict === 'green' && extra.length === 0) return { verdict: 'green', gapLines: [], fault: null, detail };
400
- return { verdict: 'red', gapLines: [...extra, ...r.gaps.flatMap((g) => plainStepGap(params.step, parsed.why ?? 'empty', g, params).split('\n'))], fault: null, detail };
462
+ const seen = {
463
+ ...(Array.isArray(r.full.evidence) && r.full.evidence.length > 0 ? { evidence: r.full.evidence } : {}),
464
+ ...(typeof r.full.evidenceTotal === 'number' ? { evidenceTotal: r.full.evidenceTotal } : {}),
465
+ };
466
+ if (r.verdict === 'green' && extra.length === 0) return { verdict: 'green', gapLines: [], fault: null, detail, ...seen };
467
+ return { verdict: 'red', gapLines: [...extra, ...r.gaps.flatMap((g) => plainStepGap(params.step, parsed.why ?? 'empty', g, params).split('\n'))], fault: null, detail, ...seen };
401
468
  }
402
469
 
403
470
  // ── R11: the sorting rules, applied at DRAFTING ──────────────────────────────────────────────────────────────────────
@@ -546,12 +613,41 @@ export function lineRules(l) {
546
613
  return isRec(l) && typeof l.rule === 'string' && l.rule ? l.rule.split('; ').filter(Boolean) : [];
547
614
  }
548
615
 
549
- /** @param {unknown} b @returns {string[]} the spellings a rule bound can have in the person's own words */
550
- const boundSpellings = (b) => {
551
- const s = String(b);
552
- return [s, s.replace(/^0+(?=\d)/, ''), s.replace(/\.0+$/, '')];
616
+ /** @param {string} t @returns {string} a time token as compared: no spaces, lower case, no leading zero on the hour */
617
+ const timeKey = (t) => t.replace(/\s+/g, '').toLowerCase().replace(/^0(?=\d:)/, '');
618
+
619
+ /**
620
+ * The person's words read by KIND, so a digit inside a time, a date or an amount belongs to that token only (F242).
621
+ * @param {string} text @returns {{bare: Set<number>, amount: Set<number>, time: Set<string>, date: Set<string>}}
622
+ */
623
+ const tokensSaid = (text) => {
624
+ const out = { bare: new Set(), amount: new Set(), time: new Set(), date: new Set() };
625
+ const re = /(\d{1,2}:\d{2}(?:\s?[AaPp][Mm])?)|(\d{4}-\d{1,2}-\d{1,2}|\d{1,2}[/.]\d{1,2}[/.]\d{2,4})|([$\u20ac\u00a3]\s?\d[\d,]*(?:\.\d+)?|\d[\d,]*(?:\.\d+)?\s?(?:US\s)?(?:dollars?|euros?|pounds?|USD|EUR|GBP)\b)|(\d[\d,]*(?:\.\d+)?)/gi;
626
+ for (const m of text.matchAll(re)) {
627
+ if (m[1]) out.time.add(timeKey(m[1]));
628
+ else if (m[2]) out.date.add(m[2]);
629
+ else if (m[3]) out.amount.add(Number(m[3].match(/\d[\d,]*(?:\.\d+)?/)?.[0].replace(/,/g, '')));
630
+ else out.bare.add(Number(m[4].replace(/,/g, '')));
631
+ }
632
+ return out;
553
633
  };
554
634
 
635
+ /**
636
+ * Is this rule bound in the words the person wrote? The ONE test for "a number the AI made" (`rubric-fact-unmarked`, the L3 settle, and
637
+ * the early ask of PRD v1.107 Part 5b all read it; never a second spelling). By kind: a number is said only as a bare number or an
638
+ * amount's number; a time only as a time token (hour leading zero ignored); a date only as a date token. Never a piece of a token.
639
+ * @param {unknown} b @param {string} said @returns {boolean}
640
+ */
641
+ export const boundInWords = (b, said) => {
642
+ const t = tokensSaid(said);
643
+ if (typeof b === 'number') return t.bare.has(b) || t.amount.has(b);
644
+ if (typeof b !== 'string') return false;
645
+ return /^\s*\d{1,2}:\d{2}/.test(b) ? t.time.has(timeKey(b)) : t.date.has(b.trim());
646
+ };
647
+
648
+ /** @param {any} jl one job line @returns {string} the words the person wrote on it: the line and its `~` rules */
649
+ export const wordsSaidOn = (jl) => `${jl.text ?? ''} ${lineRules(jl).join(' ')}`;
650
+
555
651
  /**
556
652
  * PART 1b, RF16 + RF17 — the rules that need the person's own job lines. Only a close built for REQUIRED FACTS is held to them (a
557
653
  * close with a fact part, or any part that says which `~` rules it carries out): an older Part 1a close is left as it was signed.
@@ -581,12 +677,12 @@ export function checkRubricRules(stages, { jobLines = null, refused = null, red
581
677
  const fp = factPartOf(st.params);
582
678
  if (!fp || !Array.isArray(fp.facts)) continue;
583
679
  const jl = jobLines.find((l) => isRec(l) && l.n === st.params.step);
584
- const said = jl ? `${jl.text ?? ''} ${lineRules(jl).join(' ')}` : null;
680
+ const said = jl ? wordsSaidOn(jl) : null;
585
681
  fp.facts.forEach((/** @type {any} */ f, /** @type {number} */ j) => {
586
682
  if (!isRec(f) || !isRec(f.rule)) return;
587
683
  const w = `stages[${i}].params.parts[${st.params.parts.indexOf(fp)}].facts[${j}]`;
588
684
  const bounds = f.rule.op === 'between' ? [f.rule.lo, f.rule.hi] : [f.rule.value];
589
- if (said !== null && !f.aiProposal && bounds.some((b) => !boundSpellings(b).some((sp) => said.includes(sp)))) {
685
+ if (said !== null && !f.aiProposal && bounds.some((b) => !boundInWords(b, said))) {
590
686
  red('rubric-fact-unmarked', w, `line ${st.params.step}: the limit on fact "${f.name}" (${bounds.join(' to ')}) is not in the words the person wrote, so it is the AI's own number. Set "aiProposal": true on that fact so the card says "AI's proposal — check it". Nothing spent.`, { line: st.params.step });
591
687
  }
592
688
  if (f.aiProposal && f.shape === 'time' && f.rule.op !== 'between') {
@@ -620,12 +716,12 @@ export function settlePersonsWords(declaration, jobLines) {
620
716
  const jl = jobLines.find((l) => isRec(l) && l.n === st.params.step);
621
717
  if (!fp || !Array.isArray(fp.facts) || !jl) return st;
622
718
  const rules = lineRules(jl);
623
- const said = `${jl.text ?? ''} ${rules.join(' ')}`;
719
+ const said = wordsSaidOn(jl);
624
720
  const ranges = rules.filter((r) => !NEGATING_RE.test(r)).map((r) => TIME_RANGE_RE.exec(r)).filter((m) => m !== null);
625
721
  const proposed = fp.facts.filter((/** @type {any} */ f) => isRec(f) && f.aiProposal === true && f.shape === 'time' && isRec(f.rule) && f.rule.op === 'between');
626
722
  const facts = fp.facts.map((/** @type {any} */ f) => {
627
723
  if (!isRec(f) || !isRec(f.rule) || f.aiProposal !== true) return f;
628
- const own = (/** @type {unknown[]} */ bounds) => bounds.every((b) => boundSpellings(b).some((sp) => said.includes(sp)));
724
+ const own = (/** @type {unknown[]} */ bounds) => bounds.every((b) => boundInWords(b, said));
629
725
  const bounds = f.rule.op === 'between' ? [f.rule.lo, f.rule.hi] : [f.rule.value];
630
726
  const { aiProposal: _drop, ...rest } = f;
631
727
  if (own(bounds)) return rest;
@@ -667,6 +763,36 @@ export function withFactBindings(declaration) {
667
763
  return { ...declaration, stages };
668
764
  }
669
765
 
766
+ /**
767
+ * NO SILENT DROP (idea A core, spec 4.4): code writes the Not checked rows for what the page rules removed, before the declaration is validated.
768
+ * - a dropped FACT that carries a `~` rule no part carries and no `refused` entry names -> `closeDecl.refused` gets {line, rule, reason}
769
+ * (shown under that line in the person's own rule words by {@link ruleNotChecked});
770
+ * - any other drop (a fact from the job line itself, a rule another fact carries, a word dropped from a fact that is still checked, so a
771
+ * narrower check is still said) -> one line in `closeDecl.notes`, shown as Not checked (the AI's own reading).
772
+ * Never a line-level `refused` entry without `rule` (that would silence every other rule red on the line). A rule the drafter already listed
773
+ * itself is never listed twice, and running this twice adds nothing. Pure; the input is untouched.
774
+ * @param {any} declaration @param {import('./pagerules.js').Dropped[]|null|undefined} dropped @param {any[]|null|undefined} jobLines @returns {any}
775
+ */
776
+ export function withDropNotes(declaration, dropped, jobLines) {
777
+ if (!isRec(declaration) || !Array.isArray(dropped) || dropped.length === 0) return declaration;
778
+ const stages = Array.isArray(declaration.stages) ? declaration.stages : [];
779
+ const carried = (/** @type {number} */ line, /** @type {number} */ n) => stages.some((/** @type {any} */ st) => isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && st.params.step === line
780
+ && Array.isArray(st.params.parts) && st.params.parts.some((/** @type {any} */ x) => isRec(x) && Array.isArray(x.rules) && x.rules.includes(n)));
781
+ const refused = Array.isArray(declaration.refused) ? [...declaration.refused] : [];
782
+ const notes = Array.isArray(declaration.notes) ? [...declaration.notes] : [];
783
+ for (const row of dropRows(dropped)) {
784
+ const named = row.rule !== undefined && refused.some((/** @type {any} */ r) => isRec(r) && r.line === row.line && r.rule === row.rule);
785
+ if (named) continue;
786
+ if (row.full && row.rule !== undefined && !carried(row.line, row.rule)) {
787
+ refused.push({ line: row.line, rule: row.rule, reason: row.sentences.join(' ') });
788
+ continue;
789
+ }
790
+ const text = dropLine(row, jobLines, lineRules);
791
+ if (!notes.includes(text)) notes.push(text);
792
+ }
793
+ return { ...declaration, ...(refused.length ? { refused } : {}), ...(notes.length ? { notes } : {}) };
794
+ }
795
+
670
796
  /**
671
797
  * The ~ rules the drafter listed under Not checked (`refused` entries that name a `rule`), each as one line in the person's own
672
798
  * words: `line N: "<the rule as written>" — <the drafter's reason>` (PART 1b, RF6 / RF16).
@@ -686,9 +812,14 @@ export function ruleNotChecked(closeDecl, jobLines) {
686
812
  * @param {any} closeDecl @returns {string[]}
687
813
  */
688
814
  export function proseNotChecked(closeDecl) {
815
+ return proseNotCheckedItems(closeDecl).map((i) => i.text);
816
+ }
817
+
818
+ /** the same items as {@link proseNotChecked} with the job line each belongs to (the stage's own `step`) @param {any} closeDecl @returns {{line: number, text: string}[]} */
819
+ export function proseNotCheckedItems(closeDecl) {
689
820
  const stages = isRec(closeDecl) && Array.isArray(closeDecl.stages) ? closeDecl.stages : [];
690
821
  return stages.filter((/** @type {any} */ st) => isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts) && !factPartOf(st.params))
691
- .map((/** @type {any} */ st) => `line ${st.params.step}: what the text says — you judge it at Accept`);
822
+ .map((/** @type {any} */ st) => ({ line: st.params.step, text: `line ${st.params.step}: what the text says — you judge it at Accept` }));
692
823
  }
693
824
 
694
825
  // ── the sign-vs-run invariant: the checks the person is SHOWN are the checks the signed close RUNS ───────────────────
@@ -732,6 +863,17 @@ export function checksOfClose(closeDecl) {
732
863
  : []));
733
864
  }
734
865
 
866
+ /**
867
+ * The names of the facts a signed close checks (every fact part of every rubric-step stage). Pure.
868
+ * @param {any} closeDecl @returns {string[]}
869
+ */
870
+ export function signedFactNames(closeDecl) {
871
+ const stages = isRec(closeDecl) && Array.isArray(closeDecl.stages) ? closeDecl.stages : [];
872
+ return stages.flatMap((/** @type {any} */ st) => (isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts)
873
+ ? st.params.parts.filter((/** @type {any} */ p) => isRec(p) && p.class === 'fact' && Array.isArray(p.facts)).flatMap((/** @type {any} */ p) => p.facts.filter((/** @type {any} */ f) => isRec(f) && typeof f.name === 'string').map((/** @type {any} */ f) => f.name))
874
+ : []));
875
+ }
876
+
735
877
  /**
736
878
  * Reconcile the CONFIRMED plan with the close the composer actually wrote. The plan card after drafting shows exactly
737
879
  * the close's own checks (`checksOfClose`); every confirmed check that no part carries out (no part lists its number in
package/src/runlist.js CHANGED
@@ -104,7 +104,8 @@ export function appendRun(row, opts = {}) {
104
104
  }
105
105
 
106
106
  /**
107
- * @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean, leg?: number }} RunEvent
107
+ * @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, words?: string, spentUsd?: number, spendComplete?: boolean, leg?: number }} RunEvent
108
+ * `words` (2026-10-19): on a `settled` with `reason: 'refused before start'`, the plain refusal the panel shows for that run (it has no spine).
108
109
  * `leg` (P5-R): which leg of the run the write-back is about; absent = leg 1 (every entry written before P5-R).
109
110
  * A resumed run is ONE row, so `settled`/`released` are per leg: a later leg's `released` gives back THAT leg's
110
111
  * claim and never removes the run.
@@ -6,6 +6,7 @@
6
6
  // its ENDED block is `endedFor` in src/panel/server.js).
7
7
  import { basename } from 'node:path';
8
8
  import { panelMoney2 } from './panel/authorroutes.js';
9
+ import { STATUS } from './panel/status.js';
9
10
 
10
11
  /** @typedef {import('./trigger.js').Trigger} Trigger */
11
12
 
@@ -272,11 +273,13 @@ export function retryNextWords(ms) {
272
273
 
273
274
  /**
274
275
  * RULED 2026-10-15: the sign on the head of the Audit's `schedule` section. ONE small rule (hamr may still adjust it): [▶] only while a
275
- * run of the job is running (as everywhere in the panel), [✗] when the SCHEDULE row is red, else [·] (on, waiting).
276
- * @param {{running: boolean, red: boolean}} f @returns {{sign: string}}
276
+ * run of the job is running (as everywhere in the panel), [✗] when the SCHEDULE row is red, else [·] (on, waiting). RULED 2026-10-18 (v1.106
277
+ * Part 4): the header reads `[sign] word schedule · ...`, the sign AND the word both from the one status table (src/panel/status.js), never typed here.
278
+ * @param {{running: boolean, red: boolean}} f @returns {{sign: string, word: string}}
277
279
  */
278
280
  export function scheduleSignFor(f) {
279
- return { sign: f.running ? '▶' : f.red ? '✗' : '·' };
281
+ const { sign, word } = f.running ? STATUS.running : f.red ? STATUS.failed : STATUS.waiting;
282
+ return { sign, word };
280
283
  }
281
284
 
282
285
  /** Audit schedule section, the `this run` words of a run the schedule started (the head already says which schedule and from where). @param {any} rec `trigger.json` */
package/src/source.js CHANGED
@@ -1388,3 +1388,21 @@ export async function refreezeUrlInputs({ into, fetchUrl = (u) => fetchPageText(
1388
1388
  await writeFile(join(resolve(into), 'source.json'), `${JSON.stringify(next, null, 2)}\n`);
1389
1389
  return { ok: true, refreshed: rows.map((/** @type {any} */ r) => r.label), seed };
1390
1390
  }
1391
+
1392
+ /**
1393
+ * PRD v1.106 Part 5 Q2: what `source.json` says about web inputs, read synchronously and never throwing: whether the copy has any web input
1394
+ * (`inputs[].url`) and when the run-start refetch last finished (`refrozenAt`, written last by {@link refreezeUrlInputs}). The panel decides
1395
+ * "fetching the page..." from this: it applies while a web input exists and no refetch has finished at or after `sinceIso`.
1396
+ * @param {string} sourceJsonPath @param {string|null|undefined} sinceIso
1397
+ * @returns {{ hasWeb: boolean, fetching: boolean }}
1398
+ */
1399
+ export function webFetchState(sourceJsonPath, sinceIso) {
1400
+ try {
1401
+ const m = JSON.parse(readFileSync(sourceJsonPath, 'utf8'));
1402
+ const hasWeb = Array.isArray(m?.inputs) && m.inputs.some((/** @type {any} */ r) => r && typeof r.url === 'string' && r.url !== '');
1403
+ if (!hasWeb) return { hasWeb: false, fetching: false };
1404
+ const done = typeof m.refrozenAt === 'string' ? Date.parse(m.refrozenAt) : NaN;
1405
+ const since = typeof sinceIso === 'string' ? Date.parse(sinceIso) : NaN;
1406
+ return { hasWeb: true, fetching: !(Number.isFinite(done) && (!Number.isFinite(since) || done >= since)) };
1407
+ } catch { return { hasWeb: false, fetching: false }; }
1408
+ }
package/src/thinking.js CHANGED
@@ -5,8 +5,9 @@
5
5
  // NOT switchable), `'unverified'` (not measured — the shape ladder is tried at runtime; if every shape fails with a client
6
6
  // validation error ONE plain request decides, and the call still runs, reported `thinkingStatus: 'unsupported'`).
7
7
  //
8
- // The tick is the person's, per job ("Thinking on", the signed optional `thinking` field; absent = off). It covers DRAFTING and the
9
- // rubric JUDGE; the WORKER is never handed a thinking option. PER-CALL options, never constructor options (the drafter's provider is
8
+ // The tick is the person's, per job ("Thinking on", the signed optional `thinking` field; absent = off). It covers DRAFTING, the
9
+ // rubric JUDGE and, as the think-everywhere override, every WORKER call. Without the tick the WORKER's mode is decided here too, in
10
+ // code, from the phase and attempt (`workerThinkingMode`, PRD v1.106 Part 1) — never by the model. PER-CALL options, never constructor options (the drafter's provider is
10
11
  // the worker's instance, deliberately left as it was). The raw `thinking` option is never sent (bare-agent throws if both are set).
11
12
  import { createRequire } from 'node:module';
12
13
 
@@ -128,3 +129,63 @@ export function thinkingRowText({ mode, support, status }) {
128
129
  if (support === 'unverified') return `thinking ${mode} (not verified for this model)`;
129
130
  return `thinking ${mode}`;
130
131
  }
132
+
133
+ /**
134
+ * THE one decision for a worker model call (PRD v1.106 Part 1): from the phase label and attempt iteration `mkWorker` already holds,
135
+ * and the job's tick (`job.thinking === true`). Decided in code, never by the model.
136
+ * tick on -> 'on' for every call
137
+ * plan (draft, redraft, replan) -> 'on' (the planner is the hard part of a run)
138
+ * scout -> 'off'
139
+ * step:<id> attempt 1 -> 'off'
140
+ * step:<id> attempt >= 2 -> 'on'
141
+ * fix (close-fix) -> 'on'
142
+ * A step or fix iteration that is not a whole number >= 1 throws (operator error), as does an unknown phase — never a guess.
143
+ * @param {{ phase: unknown, iteration?: unknown, tick?: unknown }} o
144
+ * @returns {'on'|'off'}
145
+ */
146
+ export function workerThinkingMode({ phase, iteration, tick }) {
147
+ if (typeof phase !== 'string') throw new Error(`[thinking] worker phase must be a string, got ${JSON.stringify(phase)}`);
148
+ const isStep = phase.startsWith('step:');
149
+ if (isStep || phase === 'fix') {
150
+ if (!Number.isInteger(iteration) || /** @type {number} */ (iteration) < 1) {
151
+ throw new Error(`[thinking] ${isStep ? 'step' : 'fix'} iteration must be a whole number >= 1, got ${JSON.stringify(iteration)} (phase "${phase}")`);
152
+ }
153
+ } else if (phase !== 'scout' && phase !== 'plan') {
154
+ throw new Error(`[thinking] unknown worker phase ${JSON.stringify(phase)}`);
155
+ }
156
+ if (tick === true) return 'on';
157
+ if (phase === 'scout') return 'off';
158
+ if (isStep) return iteration === 1 ? 'off' : 'on';
159
+ return 'on';
160
+ }
161
+
162
+ /**
163
+ * PRD v1.106 Part 3 — the words one worker call carries in the Audit, READ from the run's own spine, never guessed. Finds the LAST
164
+ * `thinking-decision` of `phase` (and `iteration`, when given) whose seq lies strictly between `lo` and `hi`, pairs it with the first
165
+ * `thinking-status` of the same phase and iteration after it (before the next decision), and words both through {@link thinkingRowText}
166
+ * (drafting's own words). `null` when the window holds no decision: a run from before the records existed shows no thinking word.
167
+ * @param {any[]} spine the run's spine records (each with a numeric `seq`)
168
+ * @param {{ phase: string, lo?: number, hi?: number, iteration?: number|null }} q
169
+ * @returns {string|null}
170
+ */
171
+ export function thinkingWordsFrom(spine, { phase, lo = -Infinity, hi = Infinity, iteration }) {
172
+ const seqOf = (/** @type {any} */ e) => (typeof e?.seq === 'number' ? e.seq : NaN);
173
+ const sameIteration = (/** @type {any} */ e) => iteration === undefined || e.iteration === iteration;
174
+ const decisions = spine.filter((e) => e?.type === 'thinking-decision' && e.phase === phase && sameIteration(e) && seqOf(e) > lo && seqOf(e) < hi && (e.mode === 'on' || e.mode === 'off'));
175
+ const dec = decisions[decisions.length - 1];
176
+ if (!dec) return null;
177
+ const nextDecision = spine.find((e) => e?.type === 'thinking-decision' && seqOf(e) > seqOf(dec));
178
+ const stop = nextDecision ? seqOf(nextDecision) : Infinity;
179
+ const st = spine.find((e) => e?.type === 'thinking-status' && e.phase === dec.phase && e.iteration === dec.iteration && seqOf(e) > seqOf(dec) && seqOf(e) < stop);
180
+ return thinkingRowText({ mode: dec.mode, status: typeof st?.thinkingStatus === 'string' ? st.thinkingStatus : undefined });
181
+ }
182
+
183
+ /**
184
+ * The Job tab's row words (PRD v1.106 Part 3): the default rule, or the job's "Thinking on" tick, but only for a run whose spine
185
+ * recorded a `thinking-decision`; a run from before the thinking rule reads `—`, never a guess from the job's tick (fwdloop M4d am28).
186
+ * @param {boolean} tick @param {boolean} recorded the run's spine holds at least one `thinking-decision` @returns {string}
187
+ */
188
+ export function thinkingJobText(tick, recorded) {
189
+ if (!recorded) return '\u2014';
190
+ return tick ? 'always on' : 'on retries';
191
+ }
package/src/userrun.js CHANGED
@@ -304,6 +304,7 @@ class ExitSignal extends Error {
304
304
  * @property {string} seed
305
305
  * @property {string} spineName
306
306
  * @property {string} [into] see `RunOpts.into`
307
+ * @property {boolean} [refreeze] a FRESH `--spec` run: fetch the web inputs again AFTER the run is listed (never on resume/door/decide)
307
308
  * @property {string|null} model
308
309
  * @property {string|null} providerKey
309
310
  * @property {string|null} readShim
@@ -493,7 +494,8 @@ async function execute(ctx) {
493
494
  const REGISTRY_TAIL = (ctx.registry ?? null) !== null ? ` --registry ${(ctx.registry ?? null)} --workflow ${(ctx.workflow ?? null) ?? spec.job}` : '';
494
495
 
495
496
  const WORKDIR = target.workdir;
496
- const SEED = target.seed;
497
+ // `let`: a fresh run's refetch of its web inputs commits a NEW seed (below, after the run is listed)
498
+ let SEED = target.seed;
497
499
  const specHash = jobSpecHash(spec);
498
500
  // the re-invocation commands this engine prints: a bundle names its own door and hash,
499
501
  // every other caller gets today's byte-identical strings.
@@ -1584,6 +1586,24 @@ async function execute(ctx) {
1584
1586
  err(`WARNING: could not add this run to ~/.config/bareloop/runs.jsonl (${e.message}) — the run continues; the panel's list will be missing this row.`);
1585
1587
  }
1586
1588
 
1589
+ // RUN START REFETCH (PART 1c RF22; moved here 2026-10-19, run mvez8s54: the ~18 s barebrowse fetch ran in main() BEFORE this run was
1590
+ // listed, so Sign & run showed nothing for 18 s). The run is listed FIRST (row above, with its pid and cap = the monthly claim), THEN
1591
+ // fetches: the Run tab reads "fetching the page…" while source.json has no refrozenAt at or after the row. A refusal cannot be a
1592
+ // `released` row (that unlists the run and its words); it SETTLES the claim at $0 and keeps the plain reason for the panel.
1593
+ if (ctx.refreeze === true && ctx.into !== undefined) {
1594
+ const fresh = await refreezeUrlInputs({ into: ctx.into, ...(deps.fetchUrl ? { fetchUrl: deps.fetchUrl } : {}) });
1595
+ if (!fresh.ok) {
1596
+ const words = /did not start/.test(fresh.error) ? fresh.error : `${fresh.error}. The run did not start. Nothing spent.`;
1597
+ err(`${SELECTOR}: ${fresh.error}`);
1598
+ if (claimedRow) {
1599
+ try { appendRunEvent({ runid, type: 'settled', by: runid, reason: 'refused before start', words, spentUsd: 0, spendComplete: true, at: new Date().toISOString() }, { home: claimedRow.home }); } catch { /* the exit stands; the dead pid's claim is closed by the next run */ }
1600
+ claimedRow = null; // settled, not released: a released leg-1 row is unlisted, and the person must see why
1601
+ }
1602
+ throw new ExitSignal(2);
1603
+ }
1604
+ if (fresh.refreshed.length > 0 && typeof fresh.seed === 'string' && fresh.seed) SEED = fresh.seed;
1605
+ }
1606
+
1587
1607
  const git = (/** @type {string[]} */ a) => execFileSync('git', ['-C', wd, ...a], { encoding: 'utf8' }).trim();
1588
1608
  if (dead) {
1589
1609
  // A RESUME is the OPPOSITE case from a cold launch: the halted run's edits are work
@@ -2054,7 +2074,7 @@ async function execute(ctx) {
2054
2074
  const legDraftSpentUsd = typeof jobStartRec?.draftSpentUsd === 'number' && jobStartRec.draftSpentUsd > 0 ? jobStartRec.draftSpentUsd : null;
2055
2075
  const legDraftSpendComplete = legDraftSpentUsd === null ? null : jobStartRec.draftSpendComplete !== false;
2056
2076
  const spendCore = je?.spentUsd == null ? 'UNKNOWN' : `${je.spendComplete === false ? '≥' : ''}$${je.spentUsd.toFixed(4)}`;
2057
- out(`spent ${moneyWithDraft(spendCore, legDraftSpentUsd, spec.budgetUsd, legDraftSpendComplete)}`);
2077
+ out(`spent ${moneyWithDraft(spendCore, legDraftSpentUsd, spec.budgetUsd, legDraftSpendComplete, je?.spentUsd ?? null)}`);
2058
2078
  // FOLDED, exactly like the money line above it: on a resume the cap governs both legs
2059
2079
  // together, and a leg-only wall next to a folded spend is two framings on one cap with
2060
2080
  // no label to tell them apart (F83). The leg stays on the line beside it.
@@ -2516,6 +2536,7 @@ export async function main(argv, deps = {}) {
2516
2536
  /** how a "go edit the spec" hint renders — `jobs/<file>` for `--job`, the
2517
2537
  * spec's own path for `--spec` (it has no `jobs/` home to point at). */
2518
2538
  let SPEC_DESC;
2539
+ let refreeze = false;
2519
2540
  let spec;
2520
2541
 
2521
2542
  if (jobArg !== null) {
@@ -2592,24 +2613,16 @@ export async function main(argv, deps = {}) {
2592
2613
  + 'with exactly one source-*/ sibling.');
2593
2614
  }
2594
2615
  const into = candidates[0];
2595
- let manifestRead = await readSourceManifest(into);
2616
+ const manifestRead = await readSourceManifest(into);
2596
2617
  if (manifestRead.stop !== null) {
2597
2618
  die(`--spec ${specPath}: ${manifestRead.code} — ${manifestRead.stop}`);
2598
2619
  }
2599
2620
  if (!manifestRead.present || !manifestRead.manifest) {
2600
2621
  die(`--spec ${specPath}: ${into} carries no source.json — not a prepared copy this runner can trust`);
2601
2622
  }
2602
- // PART 1c (RF22): a FRESH run of a rubric job with web inputs fetches each address again and makes that text the run's frozen
2603
- // input BEFORE the worker starts (the drafting copy was only for the look). A resume / door / decision keeps the copy its
2604
- // first start froze. A refusal is plain words, $0.
2605
- if (argFlag('resume') === null && argFlag('door') === null && argFlag('decide') === null) {
2606
- const fresh = await refreezeUrlInputs({ into, ...(deps.fetchUrl ? { fetchUrl: deps.fetchUrl } : {}) });
2607
- if (!fresh.ok) die(`--spec ${specPath}: ${fresh.error}`);
2608
- if (fresh.refreshed.length > 0) {
2609
- manifestRead = await readSourceManifest(into);
2610
- if (manifestRead.stop !== null || !manifestRead.manifest) die(`--spec ${specPath}: the run copy's record could not be read after the fresh fetch`);
2611
- }
2612
- }
2623
+ // PART 1c (RF22): a FRESH run with web inputs fetches each address again before the worker starts. That happens in execute(),
2624
+ // AFTER the run is listed (`ctx.refreeze`); a resume / door / decision keeps the copy its first start froze.
2625
+ refreeze = argFlag('resume') === null && argFlag('door') === null && argFlag('decide') === null;
2613
2626
  const seed = manifestRead.manifest.seed;
2614
2627
  if (typeof seed !== 'string' || !seed) {
2615
2628
  die(`--spec ${specPath}: ${into}/source.json carries no seed commit — not a prepared copy this runner can trust`);
@@ -2647,6 +2660,7 @@ export async function main(argv, deps = {}) {
2647
2660
  seed: target.seed,
2648
2661
  spineName: target.spine,
2649
2662
  ...(target.into === undefined ? {} : { into: target.into }),
2663
+ ...(refreeze ? { refreeze: true } : {}),
2650
2664
  model: argFlag('model'),
2651
2665
  providerKey: argFlag('provider'),
2652
2666
  readShim: argFlag('read-shim'),
@@ -10,12 +10,16 @@ export function linesNamedByReds(reds: Red[], declaration: any, lineNums: number
10
10
  * The machine's keep-or-drop of the try-3 questions. Kept only when its line is one the previous try's reds named and its
11
11
  * text is non-blank, at most {@link MAX_QUESTIONS}; every drop comes back with its reason, for the draft's log.
12
12
  * @param {any} raw @param {number[]} failedLines
13
- * @returns {{kept: {line: number, question: string}[], dropped: {reason: string, raw: any}[]}}
13
+ * @returns {{kept: {line: number, question: string, rule?: number, source?: string, ask?: 'reading'|'cant-check', reading?: string}[], dropped: {reason: string, raw: any}[]}}
14
14
  */
15
15
  export function normalizeQuestions(raw: any, failedLines: number[]): {
16
16
  kept: {
17
17
  line: number;
18
18
  question: string;
19
+ rule?: number;
20
+ source?: string;
21
+ ask?: "reading" | "cant-check";
22
+ reading?: string;
19
23
  }[];
20
24
  dropped: {
21
25
  reason: string;
@@ -1093,8 +1097,8 @@ export const REVISE_RED_CAP: 20;
1093
1097
  export const AUTHOR_MAX_TOKENS: 32000;
1094
1098
  /** the one slot the model fills on an injected guard */
1095
1099
  export const FILL_MARKER: "<FILL IN>";
1096
- /** P7 item 5 (fwdloop am24/25): the most questions the MACHINE keeps from the try-3 drafter. The drafter is never told this number. */
1097
- export const MAX_QUESTIONS: 2;
1100
+ /** P7 item 5 (fwdloop am24/25): the most questions the MACHINE keeps from the try-3 drafter. The drafter is never told this number. PRD v1.107 Part 5b: three (hamr's ruling), shared with the code-forced early asks. */
1101
+ export const MAX_QUESTIONS: 3;
1098
1102
  /**
1099
1103
  * The model-visible words of the optional `questions` property — offered ONLY on the last drafting try and only after the
1100
1104
  * two before it both failed validation (a mechanism, never a wish: the schema carries it on that one call and nowhere
@@ -1680,12 +1684,44 @@ export const PARAM_SCHEMAS: Readonly<{
1680
1684
  description: string;
1681
1685
  };
1682
1686
  unit: {
1683
- type: string;
1684
- minLength: number;
1687
+ anyOf: ({
1688
+ type: string;
1689
+ minLength: number;
1690
+ minItems?: undefined;
1691
+ maxItems?: undefined;
1692
+ uniqueItems?: undefined;
1693
+ items?: undefined;
1694
+ } | {
1695
+ type: string;
1696
+ minItems: number;
1697
+ maxItems: number;
1698
+ uniqueItems: boolean;
1699
+ items: {
1700
+ type: string;
1701
+ minLength: number;
1702
+ };
1703
+ minLength?: undefined;
1704
+ })[];
1685
1705
  description: string;
1686
1706
  };
1687
1707
  currency: {
1688
- enum: string[];
1708
+ anyOf: ({
1709
+ enum: string[];
1710
+ type?: undefined;
1711
+ minItems?: undefined;
1712
+ maxItems?: undefined;
1713
+ uniqueItems?: undefined;
1714
+ items?: undefined;
1715
+ } | {
1716
+ type: string;
1717
+ minItems: number;
1718
+ maxItems: number;
1719
+ uniqueItems: boolean;
1720
+ items: {
1721
+ enum: string[];
1722
+ };
1723
+ enum?: undefined;
1724
+ })[];
1689
1725
  description: string;
1690
1726
  };
1691
1727
  phrase: {