bareloop 0.41.0 → 0.42.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 (47) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +44 -4
  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 +78 -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 +23 -12
  12. package/src/facts.js +317 -0
  13. package/src/factwords.js +129 -0
  14. package/src/inputlook.js +301 -0
  15. package/src/job.js +2 -1
  16. package/src/jobblock.js +7 -2
  17. package/src/kinds.js +84 -3
  18. package/src/labelscan.js +170 -0
  19. package/src/pagefetch.js +125 -0
  20. package/src/panel/authorroutes.js +15 -2
  21. package/src/panel/authorsession.js +99 -22
  22. package/src/panel/index.html +33 -7
  23. package/src/panel/jobplan.js +34 -11
  24. package/src/panel/server.js +19 -3
  25. package/src/planrun.js +3 -2
  26. package/src/rubricstep.js +804 -0
  27. package/src/source.js +166 -1
  28. package/src/u-readout.js +1 -1
  29. package/src/userrun.js +14 -2
  30. package/types/authorflow.d.ts +267 -13
  31. package/types/authoring.d.ts +29 -1
  32. package/types/authorjob.d.ts +7 -5
  33. package/types/authorreadout.d.ts +27 -2
  34. package/types/authorscout.d.ts +70 -0
  35. package/types/declaredclose.d.ts +4 -2
  36. package/types/facts.d.ts +71 -0
  37. package/types/factwords.d.ts +42 -0
  38. package/types/inputlook.d.ts +238 -0
  39. package/types/jobblock.d.ts +8 -1
  40. package/types/labelscan.d.ts +38 -0
  41. package/types/pagefetch.d.ts +71 -0
  42. package/types/panel/authorsession.d.ts +18 -2
  43. package/types/panel/jobplan.d.ts +12 -4
  44. package/types/panel/server.d.ts +4 -2
  45. package/types/rubricstep.d.ts +286 -0
  46. package/types/source.d.ts +94 -0
  47. package/types/userrun.d.ts +11 -0
package/src/authorrun.js CHANGED
@@ -120,11 +120,13 @@ import { defaultJudgeLoop, resolveJobJudge } from './judged.js';
120
120
  import { validateJob, jobSpecHash, resolveWorkerModel } from './job.js';
121
121
  import { scanSecrets, redactSecrets } from './validate.js';
122
122
  import { detectLanguage } from './detectlang.js';
123
+ import { RUBRIC_LANG } from './authoring.js';
124
+ import { rubricGoal } from './rubricstep.js';
123
125
  import { moveFile } from './movefile.js';
124
126
  import { closeJudges, GATE_AUDIT_FILE } from './kinds.js';
125
127
  import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
126
128
  import { commandFor } from './invoke.js';
127
- import { readSourceManifest, missingDependencies, nonRepoSourceMessage } from './source.js';
129
+ import { readSourceManifest, missingDependencies, nonRepoSourceMessage, isRubricFilesManifest } from './source.js';
128
130
  import { tallyCalls } from './text.js';
129
131
  import {
130
132
  declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
@@ -327,6 +329,9 @@ export async function main(argv, deps = {}) {
327
329
  // folder has no genre to detect) and the branch further down that decides
328
330
  // which of the two paths this run actually takes.
329
331
  const IS_REPO_SOURCE = manifestRead.manifest.kind === 'repo';
332
+ // PART 1a: a soft-green job whose source froze the person's own local FILES (`proveRubricInputs` left `inputs` in the
333
+ // manifest) is NOT the plain-folder stop below: it is authored against the hidden tree with the text genre, no scout.
334
+ const RUBRIC_FILES = !IS_REPO_SOURCE && VERDICT === 'soft-green' && isRubricFilesManifest(manifestRead.manifest);
330
335
 
331
336
  // ── LANGUAGE, DETECTED — never asked (PRD item 33 M3, ruling 3) ──────────────
332
337
  // $0, no provider, run BEFORE the API key is even read — this script must
@@ -361,7 +366,7 @@ export async function main(argv, deps = {}) {
361
366
  // the language pick (the old repo-only "worse than before" ask is retired
362
367
  // entirely as of 2026-09-28 — see src/authorflow.js's own retirement note).
363
368
  let langResult = /** @type {ReturnType<typeof detectLanguage>|null} */ (null);
364
- let LANG = 'none-detected';
369
+ let LANG = RUBRIC_FILES ? RUBRIC_LANG : 'none-detected';
365
370
  if (IS_REPO_SOURCE) {
366
371
  langResult = detectLanguage(SOURCE);
367
372
  // AMBIGUOUS NO LONGER DIES (PRD item 33 M3 piece 4, step S4): the confirm
@@ -834,7 +839,7 @@ export async function main(argv, deps = {}) {
834
839
  // build can close. The stop fires HERE, immediately, at $0, no scout, no
835
840
  // confirm turn, no model call — a plain folder's spine is exactly
836
841
  // `author-start` → `author-end{outcome:'not-authored', stop:'non-code-source'}`.
837
- if (!IS_REPO_SOURCE) {
842
+ if (!IS_REPO_SOURCE && !RUBRIC_FILES) {
838
843
  const message = nonRepoSourceMessage(manifestRead.manifest.kind);
839
844
  out(`\n${message}`);
840
845
  const red = {
@@ -896,7 +901,7 @@ export async function main(argv, deps = {}) {
896
901
  // so every path that reaches this call is a repo job. `langResult`
897
902
  // travels through unchanged — its `ambiguous` shape is what the confirm
898
903
  // turn's own $0 half (D7) asks about, before the scout.
899
- ask, confirmGenerate, isRepo: true, langResult,
904
+ ask, confirmGenerate, isRepo: !RUBRIC_FILES, langResult,
900
905
  });
901
906
  const authoredFile = writeOut('authored.json', authored);
902
907
  emit('authored', { ok: authored.ok, stop: authored.stop, seedRef: authored.seedRef, cost: authored.cost, reds: authored.reds });
@@ -1008,7 +1013,11 @@ export async function main(argv, deps = {}) {
1008
1013
  // predates the confirm turn's own `ask` wiring (`authored.confirmed` is
1009
1014
  // `null` for every caller that runs no confirm turn) — the draft's own
1010
1015
  // goal (if any) then stands untouched.
1011
- if (authored.confirmed?.goal) {
1016
+ if (RUBRIC_FILES && Array.isArray(draft.jobLines) && draft.jobLines.length > 0) {
1017
+ // PART 1a: the worker's goal for a rubric job on files is written by code from the person's own numbered lines and the
1018
+ // frozen input files the manifest recorded (src/rubricstep.js); the confirmed goal sentence rides as the plan line
1019
+ draft.goal = redactSecrets(rubricGoal({ jobLines: draft.jobLines, files: manifestRead.manifest?.inputs ?? [], confirmedGoal: authored.confirmed?.goal ?? null, closeDecl: authored.closeDecl }));
1020
+ } else if (authored.confirmed?.goal) {
1012
1021
  draft.goal = redactSecrets(String(authored.confirmed.goal));
1013
1022
  }
1014
1023
  // ── 2. the operator's half + the authored half → one spec ──────────────────
@@ -56,6 +56,7 @@ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, strategyFor } from
56
56
  import { redactSecrets, SECRET_PATTERNS } from './validate.js';
57
57
  import { extractArtifact, priceOf, scrubRaw, stampRaw, tallyCalls, capStop, callCasualty } from './text.js';
58
58
  import { PROVIDER_TIMEOUT_MS } from './clock.js';
59
+ import { lookReadTools, lookReportSchema, lookPrompt, verifyLookReport, labelListsOf, LOOK_REPORT_TOOL } from './inputlook.js';
59
60
 
60
61
  const require = createRequire(import.meta.url);
61
62
  const { Loop, wireGate } = require('bare-agent');
@@ -656,6 +657,113 @@ export async function runAuthorScout({
656
657
  }
657
658
  }
658
659
 
660
+ // ── THE LOOK AT A RUBRIC JOB'S INPUTS (PART 1c, docs/product/ITEM33-BUILD.md "3. The look") ─────────────────────────────────────
661
+ //
662
+ // The same pattern as the repo survey above, aimed at the person's own input text instead of a repository: a FIXED read-only grant
663
+ // (search the input, read a slice around a hit - closures over in-memory text, so there is nothing to fence), a hard round bound, a
664
+ // reserved final round whose ONLY tool is the answer form (no more looking), the spend metered per call into the same `calls` the
665
+ // drafting book absorbs, and the answer through a strict tool call (never free prose with JSON inside). The result is shaped like a
666
+ // survey (`state`, `facts`, `calls`, `raws`, `budgetStop`) so the drafting pipeline absorbs it exactly as it absorbs the repo scout.
667
+
668
+ /** the look's hard round bound */
669
+ export const LOOK_ROUNDS = AUTHOR_SCOUT_ROUNDS;
670
+
671
+ export const LOOK_SYSTEM = 'You are a careful analyst setting up a checkable job. You are given the person\'s job and the text of their inputs. '
672
+ + 'You can only search and read that text - you cannot write, run anything or change anything. You have a hard, small number of turns. '
673
+ + 'Your single deliverable is a report of which facts the job asks for can be CHECKED in the input, with the label that sits right before each value (picked from the list you are shown), and what you could not find. '
674
+ + 'You do not judge whether a result is good and you do not decide what is done with the job.';
675
+
676
+ export const LOOK_RECOVERY_PROMPT = 'You are out of looking turns. Report NOW, from what you have already read: call the report tool once with everything you can check and everything you could not find.';
677
+
678
+ /**
679
+ * Run the look. `createLoop` is the same seam the repo scout has; nothing else here needs a live process.
680
+ * @param {{texts: Record<string, string>, inputs: {n: number, label: string}[], jobLines: {n: number, text: string, rule?: string, rules?: string[]}[],
681
+ * provider?: any, rounds?: number, maxTokens?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
682
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
683
+ * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
684
+ * rates?: {in: number, out: number}|null, callOptions?: Record<string, any>,
685
+ * prove?: (source: string, phrase: string) => number}} o
686
+ * @returns {Promise<{state: 'PRESENT'|'ABSENT', facts: any, reason: string|null, cause: string|null, meta: any, raw: string,
687
+ * raws: ReturnType<typeof scrubRaw>[], calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
688
+ * budgetStop: 'cap-halt'|'pricing-red'|null}>}
689
+ */
690
+ export async function runInputLook({
691
+ texts, inputs, jobLines, provider = null, rounds = LOOK_ROUNDS, maxTokens = AUTHOR_SCOUT_MAX_TOKENS, ceilingUsd = null,
692
+ callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS, onCall = () => {}, createLoop = defaultLoop, rates = null, callOptions = {}, prove,
693
+ }) {
694
+ const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
695
+ ? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS) : AUTHOR_CALL_TIMEOUT_MS;
696
+ const priced = rates ? { rates } : {};
697
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
698
+ const calls = [];
699
+ /** @type {ReturnType<typeof scrubRaw>[]} */
700
+ const raws = [];
701
+ const record = (/** @type {string} */ label, /** @type {any} */ r) => {
702
+ const call = { label, ...priceOf(r) };
703
+ calls.push(call);
704
+ onCall({ ...call, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
705
+ raws.push(scrubRaw({ label, attempt: 1, text: r?.text }));
706
+ };
707
+ const settled = async (/** @type {Promise<any>} */ p) => {
708
+ try { return await p; } catch (e) {
709
+ const reason = callCasualty(e);
710
+ if (reason === null) throw e;
711
+ return { error: reason };
712
+ }
713
+ };
714
+ const { scans, lists } = labelListsOf(texts);
715
+ /** @type {{calls: any[]}} */
716
+ const box = { calls: [] };
717
+ /** @type {any} */
718
+ let loop = null;
719
+ const reportTool = {
720
+ name: LOOK_REPORT_TOOL,
721
+ description: 'Deliver the report. Call this exactly once, with the whole report as its arguments.',
722
+ parameters: lookReportSchema(),
723
+ execute: async (/** @type {any} */ args) => { box.calls.push(args); if (loop) loop.stop(); return 'received'; },
724
+ };
725
+ const tools = [...lookReadTools(texts), reportTool];
726
+ const absent = (/** @type {string} */ reason, /** @type {string} */ cause, /** @type {'cap-halt'|'pricing-red'|null} */ budgetStop = null) => ({
727
+ state: /** @type {'ABSENT'} */ ('ABSENT'), facts: null, reason, cause, meta: { rounds: seen, bounded }, raw: '', raws, calls, budgetStop,
728
+ });
729
+ let seen = 0;
730
+ let bounded = false;
731
+ const halt0 = capStop({ ceilingUsd, ...tallyCalls(calls) });
732
+ if (halt0) return absent(`the look was never asked for: the $${ceilingUsd} ceiling was already spent`, SURVEY_CAUSES.NOT_FUNDED, halt0);
733
+
734
+ const metered = async (/** @type {any} */ arg) => {
735
+ if ((arg?.kind ?? 'turn') === 'turn') {
736
+ seen += 1;
737
+ if (seen >= rounds) { bounded = true; loop.stop(); }
738
+ }
739
+ };
740
+ loop = createLoop({ system: LOOK_SYSTEM, policy: undefined, onLlmResult: metered, provider, ...priced });
741
+ const r = await settled(loop.run([{ role: 'user', content: lookPrompt({ jobLines, labels: Object.keys(texts), lists }) }], tools,
742
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions }));
743
+ record('input-look', r);
744
+ /** @type {any} */
745
+ let last = r;
746
+ if (box.calls.length === 0 && !r?.error) {
747
+ // THE RESERVED FINAL ROUND: no more looking - the only tool is the answer form
748
+ const halt = capStop({ ceilingUsd, ...tallyCalls(calls) });
749
+ if (halt) return absent(`the look stopped on the cost ceiling before it reported: ${halt}`, SURVEY_CAUSES.NOT_FUNDED, halt);
750
+ const recovery = createLoop({ system: LOOK_SYSTEM, policy: undefined, onLlmResult: undefined, provider, ...priced });
751
+ const rMsgs = Array.isArray(r?.msgs) && r.msgs.length ? (r.msgs[0]?.role === 'system' ? r.msgs.slice(1) : r.msgs) : [];
752
+ const only = { ...reportTool, execute: async (/** @type {any} */ args) => { box.calls.push(args); recovery.stop(); return 'received'; } };
753
+ last = await settled(recovery.run([...rMsgs, { role: 'user', content: LOOK_RECOVERY_PROMPT }], [only],
754
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions }));
755
+ record('input-look-recovery', last);
756
+ }
757
+ if (box.calls.length === 0) {
758
+ return absent(last?.error ? `the look call failed: ${redactSecrets(String(last.error))}` : 'the look ended without a report', last?.error ? SURVEY_CAUSES.CALL_FAILED : SURVEY_CAUSES.EMPTY);
759
+ }
760
+ const report = verifyLookReport(box.calls[0], { scans, jobLines, ...(prove ? { prove } : {}) });
761
+ return {
762
+ state: 'PRESENT', reason: null, cause: null, meta: { rounds: seen, bounded }, raw: '', raws, calls, budgetStop: null,
763
+ facts: { rubricFiles: true, inputs: inputs.map((i) => ({ n: i.n, label: i.label })), look: report },
764
+ };
765
+ }
766
+
659
767
  // ── L1: THE MECHANICAL SEED LISTING ──────────────────────────────────────────
660
768
 
661
769
  /** per-path full-listing cap; over it, names give way to counted directories */
@@ -70,7 +70,7 @@
70
70
 
71
71
  import { runStage, makeSeedTrees, STOP_FAULTS, EXIT_RED, JUDGED_FLOOR_KIND, judgedStages } from './kinds.js';
72
72
  import {
73
- validateDeclaration, classGuards, genreOwnedEnvNames, ungroundedGenreEnv, GENRE_LANGUAGES, TYPES_GENRE,
73
+ validateDeclaration, classGuards, genreOwnedEnvNames, ungroundedGenreEnv, DECLARABLE_LANGUAGES, TYPES_GENRE, catalogueForLang, KIND_CATALOGUE, TEXT_GENRE, genreForLang,
74
74
  VERDICT_CLASSES, LIVE_CLASSES, NEVER_OFFERED_KINDS,
75
75
  } from './authoring.js';
76
76
  // the calibration set's own gate lives beside the rulebook it is graded against
@@ -83,7 +83,7 @@ import { isObj, isNonEmptyString } from './validate.js';
83
83
 
84
84
  /** The genres a `closeDecl` may name. v1 admits exactly one (D13) — a genre
85
85
  * classifier is designed the day there is a second genre to classify. */
86
- export const DECLARED_GENRES = Object.freeze([TYPES_GENRE.name]);
86
+ export const DECLARED_GENRES = Object.freeze([TYPES_GENRE.name, TEXT_GENRE]);
87
87
  /**
88
88
  * The exact field set of a `closeDecl` — anything else is a smuggle channel, the
89
89
  * same rule `STAGE_FIELDS` enforces on a command stage.
@@ -272,13 +272,14 @@ export function declaredStages(closeDecl) {
272
272
  *
273
273
  * @param {any} closeDecl
274
274
  * @param {{at?: string, listing?: string[]|null, deferListing?: boolean,
275
- * catalogue?: Record<string, any>, verdictType?: string|null, writeScope?: string[]|null, jobLines?: {n: number}[]|null}} [opts]
275
+ * catalogue?: Record<string, any>, verdictType?: string|null, writeScope?: string[]|null, jobLines?: {n: number}[]|null,
276
+ * inputLabels?: string[]|null}} [opts]
276
277
  * @returns {{ok: boolean, reds: Red[], closeDecl: any, grounded: boolean,
277
278
  * scoped: {scoped: boolean, via: string|null},
278
279
  * ceiling: {class: string, kind: string, stage: string|null, locked: boolean}|null}}
279
280
  */
280
281
  export function validateCloseDecl(closeDecl, opts = {}) {
281
- const { at = 'closeDecl', listing = null, catalogue, verdictType = null, writeScope = null, jobLines = null } = opts;
282
+ const { at = 'closeDecl', listing = null, catalogue, verdictType = null, writeScope = null, jobLines = null, inputLabels = null } = opts;
282
283
  const deferListing = opts.deferListing === true;
283
284
  /** @type {Red[]} */
284
285
  const reds = [];
@@ -320,13 +321,15 @@ export function validateCloseDecl(closeDecl, opts = {}) {
320
321
  + `land here); fields: ${CLOSE_DECL_FIELDS.join(', ')}`);
321
322
  }
322
323
  }
323
- if (!DECLARED_GENRES.includes(closeDecl.genre)) {
324
- red('invalid-value', `${at}.genre`, `menu: ${DECLARED_GENRES.join('|')} — v1 admits exactly one genre (D13), and a `
325
- + 'genre with no guard battery must refuse rather than author a close without the guards it cannot supply');
324
+ // PART 1a: a genre is bound to its language — code languages are TYPES, the rubric-files language is TEXT, and neither may
325
+ // wear the other's label (`lang` is checked just below; an unknown lang reds there)
326
+ if (!DECLARED_GENRES.includes(closeDecl.genre) || closeDecl.genre !== genreForLang(closeDecl.lang)) {
327
+ red('invalid-value', `${at}.genre`, `menu: ${DECLARED_GENRES.join('|')} — a close's genre follows its language (${genreForLang(closeDecl.lang)} for "${String(closeDecl.lang)}"), `
328
+ + 'and a genre with no guard battery must refuse rather than author a close without the guards it cannot supply');
326
329
  }
327
- const langOk = GENRE_LANGUAGES.includes(closeDecl.lang);
330
+ const langOk = DECLARABLE_LANGUAGES.includes(closeDecl.lang);
328
331
  if (!langOk) {
329
- red('invalid-value', `${at}.lang`, `menu: ${GENRE_LANGUAGES.join('|')} — the language selects the genre's guard `
332
+ red('invalid-value', `${at}.lang`, `menu: ${DECLARABLE_LANGUAGES.join('|')} — the language selects the genre's guard `
330
333
  + 'battery and its environment, both of which are ours and neither of which has a default');
331
334
  }
332
335
  // `notes` is NOT checked here. It is M2's rule now (`validateDeclaration`),
@@ -391,7 +394,9 @@ export function validateCloseDecl(closeDecl, opts = {}) {
391
394
  }
392
395
 
393
396
  const v = validateDeclaration(closeDecl, {
394
- ...(catalogue ? { catalogue } : {}),
397
+ // PART 1a: the kinds a close may use depend on its language — a rubric-files ("text") close sees its own two kinds, every
398
+ // code close never sees a rubric-only one. A caller-supplied catalogue is narrowed the same way.
399
+ catalogue: catalogueForLang(closeDecl.lang, catalogue ?? KIND_CATALOGUE),
395
400
  // BOTH are forwarded, always. Forwarding only one would hide the case M2
396
401
  // reds as `listing-conflict` — a caller that defers the tree-grounded half
397
402
  // AND hands over a tree does not know which gate it is running, and silently
@@ -403,6 +408,7 @@ export function validateCloseDecl(closeDecl, opts = {}) {
403
408
  verdictType: picked,
404
409
  writeScope,
405
410
  jobLines,
411
+ inputLabels,
406
412
  envOwned: owned,
407
413
  // POST-injection: an owned name is accepted only when its value is EXACTLY
408
414
  // the recorded one. `{}` (nothing recorded) keeps the pre-injection rule, so
@@ -486,7 +492,7 @@ export function validateCloseDecl(closeDecl, opts = {}) {
486
492
  * @param {any} closeDecl @param {string} verdictType @returns {string[]}
487
493
  */
488
494
  export function guardNames(closeDecl, verdictType) {
489
- if (!isObj(closeDecl) || !GENRE_LANGUAGES.includes(closeDecl.lang)) return [];
495
+ if (!isObj(closeDecl) || !DECLARABLE_LANGUAGES.includes(closeDecl.lang)) return [];
490
496
  if (!LIVE_CLASSES.includes(String(verdictType))) return [];
491
497
  return classGuards({ verdictType: String(verdictType), lang: closeDecl.lang }).map((g) => g.name);
492
498
  }
@@ -592,6 +598,7 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
592
598
  }
593
599
  const owned = !shared;
594
600
  const seedTrees = shared ?? makeSeedTrees();
601
+ const textClose = stages.some((st) => st?.kind === 'rubric-step');
595
602
  /** @type {any} */
596
603
  const ctx = {
597
604
  workdir: cwd,
@@ -633,10 +640,14 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
633
640
  /** @type {any[]} */
634
641
  const ran = [];
635
642
  /** @type {any} */
636
- let last = { verdict: 'satisfied', judgedCount: 1 };
643
+ /** @type {any} */
644
+ let last = textClose ? { verdict: 'satisfied' } : { verdict: 'satisfied', judgedCount: 1 };
637
645
  for (const st of stages) {
638
646
  const r = await runStage(st, ctx);
639
647
  const t = translate(r, redact);
648
+ // PART 1a: `judgedCount` marks a judgment rendered by the close's judging kinds. A rubric-files (TEXT) close renders only
649
+ // mechanical verdicts and runs no judge, so none of its records may claim one
650
+ if (textClose) delete t.judgedCount;
640
651
  ran.push({
641
652
  name: st?.name ?? null,
642
653
  verdict: t.verdict,
package/src/facts.js ADDED
@@ -0,0 +1,317 @@
1
+ // PART 1b (docs/product/ITEM33-BUILD.md, "Addendum 2026-10-13 — Part 1b: required facts"): the FACT step.
2
+ //
3
+ // A job line the person phrased as "find / list / look up" leaves behind a step file that holds ONLY filled facts:
4
+ //
5
+ // { "results": [ { "quote": "<words copied from the input>", "values": { "<fact name>": "<value as the quote writes it>" } } ] }
6
+ //
7
+ // What each result must hold is the signed list of REQUIRED FACTS on the line's `fact` part (name, shape, rule, source
8
+ // input). The check is bareguard's `cited` + `decides`, built here by code from the signed part: the quote is really in the
9
+ // frozen input, every value really is in the quote (compared exactly by shape), every value meets its rule. No model is in
10
+ // the check. The claim TEXT that bareguard's number scan reads is written by code from the values, never by the worker.
11
+
12
+ import { rubricVocabulary } from 'bareguard';
13
+
14
+ /** the shapes a fact may have (bareguard's value kinds, plus `phrase` = a presence decide) */
15
+ export const FACT_SHAPES = Object.freeze(['money', 'number', 'date', 'time', 'phrase', 'text']);
16
+ /** the comparison ops a rule may use (bareguard's) */
17
+ export const FACT_OPS = Object.freeze(['lt', 'lte', 'gt', 'gte', 'between']);
18
+ /** bareguard signs at most this many decides on one `cited` check */
19
+ export const MAX_FACTS = 10;
20
+ /** every field a fact may carry */
21
+ const FACT_FIELDS = Object.freeze(['name', 'shape', 'unit', 'currency', 'phrase', 'anchor', 'until', 'rule', 'aiProposal']);
22
+ /** every field a part of class `fact` may carry (the shared ones are checked by rubricStepShapeReds) */
23
+ export const FACT_PART_FIELDS = Object.freeze(['source', 'facts', 'count']);
24
+ /** a fact's name appears in the claim text bareguard scans for numbers, so it carries letters only (no digit can red the claim) */
25
+ const NAME_RE = /^[A-Za-z][A-Za-z _-]{0,63}$/;
26
+
27
+ /** @param {unknown} v @returns {v is Record<string, any>} */
28
+ const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
29
+ /** @param {unknown} v @returns {v is string} */
30
+ const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
31
+
32
+ /** the `fact` part of a step's parameters, or null (a step has at most one) @param {any} params @returns {any|null} */
33
+ export function factPartOf(params) {
34
+ const parts = Array.isArray(params?.parts) ? params.parts : [];
35
+ return parts.find((/** @type {any} */ p) => isRec(p) && p.class === 'fact') ?? null;
36
+ }
37
+
38
+ /** @param {any} v @returns {boolean} a rule bound has the type its shape needs: a number for number/money, a string for date/time */
39
+ const boundOk = (/** @type {string} */ shape, /** @type {unknown} */ v) => (shape === 'number' || shape === 'money' ? typeof v === 'number' && Number.isFinite(v) : typeof v === 'string' && v !== '');
40
+
41
+ /**
42
+ * The SHAPE of a `fact` part (what it may carry), pushed as reds in the same form `rubricStepShapeReds` uses. Whether the
43
+ * bareguard rubric it builds is signable is asked of bareguard itself (`checkRubricParts` builds it once at drafting).
44
+ * @param {any} part @param {string} w the path prefix @param {(code: string, path: string, detail: string) => void} red
45
+ */
46
+ export function factShapeReds(part, w, red) {
47
+ for (const k of Object.keys(part)) {
48
+ if (!['text', 'class', 'covers', 'rules', 'source', 'facts', 'count'].includes(k)) {
49
+ red('invalid-value', `${w}.${k}`, `"${k}" is not a field of a fact part — fields: text, class, covers, rules, ${FACT_PART_FIELDS.join(', ')}`);
50
+ }
51
+ }
52
+ if (!(nonEmpty(part.source) && part.source.length <= 128)) red('invalid-value', `${w}.source`, 'the label of the input the quotes come from');
53
+ if (!Array.isArray(part.facts) || part.facts.length === 0 || part.facts.length > MAX_FACTS) {
54
+ red('invalid-value', `${w}.facts`, `a list of 1 to ${MAX_FACTS} facts {name, shape, ...}`);
55
+ } else {
56
+ /** @type {Set<string>} */
57
+ const seen = new Set();
58
+ part.facts.forEach((/** @type {any} */ f, /** @type {number} */ j) => {
59
+ const fw = `${w}.facts[${j}]`;
60
+ if (!isRec(f)) { red('invalid-value', fw, '{name, shape, ...}'); return; }
61
+ for (const k of Object.keys(f)) {
62
+ if (FACT_FIELDS.includes(k)) continue;
63
+ // L4 (smv710w8c3wdh, 3 of 3 tries): "covers" / "rules" are fields of the PART, written once, never of a fact inside it
64
+ red('invalid-value', `${fw}.${k}`, k === 'covers' || k === 'rules'
65
+ ? `"${k}" is a field of the PART, not of a fact - write the numbers once on the fact part ("${k}": [..]) and take it off every fact. Fact fields: ${FACT_FIELDS.join(', ')}`
66
+ : `"${k}" is not a field of a fact — fields: ${FACT_FIELDS.join(', ')}`);
67
+ }
68
+ if (!(typeof f.name === 'string' && NAME_RE.test(f.name))) red('invalid-value', `${fw}.name`, 'the fact\'s name: letters, spaces, "_" or "-" only (no digits), at most 64 characters');
69
+ else if (seen.has(f.name)) red('invalid-value', `${fw}.name`, `the name "${f.name}" is used twice on this line`);
70
+ else seen.add(f.name);
71
+ if (!FACT_SHAPES.includes(f.shape)) { red('invalid-value', `${fw}.shape`, `one of ${FACT_SHAPES.join(' | ')}`); return; }
72
+ if (Object.hasOwn(f, 'aiProposal') && typeof f.aiProposal !== 'boolean') red('invalid-value', `${fw}.aiProposal`, 'true when the AI turned the person\'s words into this number');
73
+ const has = (/** @type {string} */ k) => Object.hasOwn(f, k);
74
+ if (f.shape === 'money') {
75
+ if (has('unit') === has('currency')) red('invalid-value', `${fw}.unit`, 'money names exactly one of unit (words the input writes after the amount, e.g. "US dollars") or currency ("$", "€" or "£")');
76
+ } else if (has('unit') || has('currency')) red('invalid-value', `${fw}.${has('unit') ? 'unit' : 'currency'}`, 'only money has a unit or a currency');
77
+ if (has('unit') && !nonEmpty(f.unit)) red('invalid-value', `${fw}.unit`, 'a non-empty string copied from the input');
78
+ if (has('currency') && !['$', '€', '£'].includes(f.currency)) red('invalid-value', `${fw}.currency`, 'one of $ | € | £');
79
+ if (f.shape === 'phrase') {
80
+ if (!nonEmpty(f.phrase)) red('invalid-value', `${fw}.phrase`, 'the fixed words the quote must contain, copied from the input');
81
+ for (const k of ['anchor', 'until', 'rule']) if (has(k)) red('invalid-value', `${fw}.${k}`, 'a phrase is fixed words the quote must contain; it has no anchor, until or rule');
82
+ } else if (has('phrase')) red('invalid-value', `${fw}.phrase`, 'only a phrase fact carries phrase');
83
+ if (has('anchor') && !nonEmpty(f.anchor)) red('invalid-value', `${fw}.anchor`, 'a label copied VERBATIM from the input');
84
+ if (has('until') && !nonEmpty(f.until)) red('invalid-value', `${fw}.until`, 'a label copied VERBATIM from the input');
85
+ if (has('until') && !has('anchor') && f.shape !== 'phrase') red('invalid-value', `${fw}.until`, 'until needs an anchor');
86
+ if (has('until') && f.shape !== 'text' && f.shape !== 'phrase') red('invalid-value', `${fw}.until`, 'until closes a text fact only');
87
+ if (has('rule') && f.shape !== 'phrase') {
88
+ const r = f.rule;
89
+ if (f.shape === 'text') red('invalid-value', `${fw}.rule`, 'a text fact is bind only: it has no rule');
90
+ else if (!isRec(r) || !FACT_OPS.includes(r.op)) red('invalid-value', `${fw}.rule`, `{op: ${FACT_OPS.join(' | ')}, value} or {op: "between", lo, hi}`);
91
+ else if (r.op === 'between' ? !(boundOk(f.shape, r.lo) && boundOk(f.shape, r.hi) && !Object.hasOwn(r, 'value')) : !(boundOk(f.shape, r.value) && !Object.hasOwn(r, 'lo') && !Object.hasOwn(r, 'hi'))) {
92
+ red('invalid-value', `${fw}.rule`, r.op === 'between' ? 'between needs lo and hi (numbers for number/money, strings for date/time) and no value' : 'a comparison needs value (a number for number/money, a string for date/time)');
93
+ }
94
+ }
95
+ });
96
+ }
97
+ if (Object.hasOwn(part, 'count')) {
98
+ const c = part.count;
99
+ const ok = isRec(c) && Object.keys(c).every((k) => k === 'min' || k === 'max') && (Object.hasOwn(c, 'min') || Object.hasOwn(c, 'max'))
100
+ && ['min', 'max'].every((k) => !Object.hasOwn(c, k) || (Number.isInteger(c[k]) && c[k] >= 1))
101
+ && (!Object.hasOwn(c, 'min') || !Object.hasOwn(c, 'max') || c.min <= c.max);
102
+ if (!ok) red('invalid-value', `${w}.count`, '{min?, max?} — how many results the person asked for (integers, 1 or more, min no more than max)');
103
+ }
104
+ }
105
+
106
+ /**
107
+ * bareguard's `decides` list for a fact part: one decide per fact, in the fact order (so `d<j>` is the fact's own index).
108
+ * @param {any} part @returns {Record<string, any>[]}
109
+ */
110
+ export function factDecides(part) {
111
+ return part.facts.map((/** @type {any} */ f) => {
112
+ if (f.shape === 'phrase') return { phrases: [f.phrase] };
113
+ /** @type {Record<string, any>} */
114
+ const d = { valueKind: f.shape };
115
+ if (f.shape === 'money') { if (f.unit !== undefined) d.unit = f.unit; else d.currency = f.currency; }
116
+ if (f.anchor !== undefined) d.anchor = f.anchor;
117
+ if (f.until !== undefined) d.until = f.until;
118
+ if (f.rule) {
119
+ d.op = f.rule.op;
120
+ if (f.rule.op === 'between') { d.lo = f.rule.lo; d.hi = f.rule.hi; } else d.value = f.rule.value;
121
+ }
122
+ return d;
123
+ });
124
+ }
125
+
126
+ /**
127
+ * The claims bareguard reads for a fact step, built from the step file's `results`. The claim TEXT is written here, by code,
128
+ * from the values the worker gave (the worker writes no claim text): `name: value; name: value`. A phrase fact contributes its
129
+ * own signed words. A result that is not an object, or has no string quote, still becomes a claim — an empty one — so
130
+ * bareguard reds it by name rather than the result vanishing.
131
+ * @param {any} part @param {unknown} results @returns {{claim: string, quote: string, values?: Record<string, string>}[]}
132
+ */
133
+ export function factClaims(part, results) {
134
+ const list = Array.isArray(results) ? results : [];
135
+ return list.map((/** @type {any} */ r) => {
136
+ const given = isRec(r) && isRec(r.values) ? r.values : null;
137
+ /** @type {Record<string, string>} */
138
+ const values = {};
139
+ /** @type {string[]} */
140
+ const said = [];
141
+ part.facts.forEach((/** @type {any} */ f, /** @type {number} */ j) => {
142
+ if (f.shape === 'phrase') { said.push(`${f.name}: ${f.phrase}`); return; }
143
+ const v = given && Object.hasOwn(given, f.name) ? given[f.name] : undefined;
144
+ // a JSON number is the value as written ("389"): converted, never repaired; anything else is left for bareguard to red
145
+ const s = typeof v === 'number' && Number.isFinite(v) ? String(v) : v;
146
+ if (typeof s === 'string') { values[`d${j}`] = s; said.push(`${f.name}: ${s}`); } else if (s !== undefined) { /** @type {any} */ (values)[`d${j}`] = s; }
147
+ });
148
+ return { claim: said.join('; '), quote: isRec(r) && typeof r.quote === 'string' ? r.quote : '', values };
149
+ });
150
+ }
151
+
152
+ /** @param {unknown} n @param {string} shape @param {any} f @returns {string} one bound, as the person would say it */
153
+ function sayBound(n, shape, f) {
154
+ if (shape === 'money') return f.unit !== undefined ? `${n} ${f.unit}` : `${f.currency}${n}`;
155
+ return String(n);
156
+ }
157
+
158
+ /** @param {any} f a fact with a rule @returns {string} the rule in words, e.g. "at most 450 US dollars" */
159
+ function ruleWords(f) {
160
+ const r = f.rule;
161
+ return r.op === 'between' ? `between ${sayBound(r.lo, f.shape, f)} and ${sayBound(r.hi, f.shape, f)}`
162
+ : `${{ lt: 'under', lte: 'at most', gt: 'over', gte: 'at least' }[/** @type {'lt'|'lte'|'gt'|'gte'} */ (r.op)]} ${sayBound(r.value, f.shape, f)}`;
163
+ }
164
+
165
+ /**
166
+ * One fact as a plain row, written by code from the signed fields:
167
+ * `fact · price — money, at most 450 US dollars, after "Price", quoted from "Flights"`.
168
+ * @param {any} f @param {string} source
169
+ * @returns {string}
170
+ */
171
+ export function factSentence(f, source) {
172
+ /** @type {string[]} */
173
+ const bits = [];
174
+ if (f.shape === 'phrase') bits.push('phrase', `must say "${f.phrase}"`);
175
+ else {
176
+ bits.push(f.shape === 'money' ? `money in ${f.unit !== undefined ? f.unit : f.currency}` : f.shape);
177
+ if (f.rule) bits.push(ruleWords(f));
178
+ else if (f.anchor === undefined) bits.push('as the quote writes it');
179
+ if (f.anchor !== undefined) bits.push(f.until !== undefined ? `the words after "${f.anchor}" up to "${f.until}"` : `after "${f.anchor}"`);
180
+ }
181
+ bits.push(`quoted from "${source}"`);
182
+ return `fact · ${f.name} — ${bits.join(', ')}${f.aiProposal ? ' — AI\'s proposal — check it' : ''}`;
183
+ }
184
+
185
+ /**
186
+ * One fact as the WORKER is told it (item 7): what to write for it and where in the quote it sits, in plain words, from the signed
187
+ * fields. A phrase fact has no value to write — the quote has to say it.
188
+ * @param {any} f @returns {string}
189
+ */
190
+ export function factBrief(f) {
191
+ if (f.shape === 'phrase') return `${f.name} — no value to write; the quote you copy must say "${f.phrase}"`;
192
+ /** @type {string[]} */
193
+ const bits = [f.shape === 'money' ? `an amount in ${f.unit !== undefined ? f.unit : f.currency}` : `a ${f.shape}`];
194
+ if (f.shape === 'text') bits[0] = 'words';
195
+ if (f.anchor !== undefined) bits.push(f.until !== undefined ? `the words right after "${f.anchor}" up to "${f.until}"` : `right after "${f.anchor}"`);
196
+ if (f.rule) bits.push(`must be ${ruleWords(f)}`);
197
+ return `${f.name} — ${bits.join(', ')}`;
198
+ }
199
+
200
+ /** the "N results" row of a fact part, or null @param {any} part @returns {string|null} */
201
+ export function countSentence(part) {
202
+ const c = isRec(part.count) ? part.count : null;
203
+ if (!c) return null;
204
+ const n = (/** @type {number} */ v) => `${v} result${v === 1 ? '' : 's'}`;
205
+ if (c.min !== undefined && c.max !== undefined) return c.min === c.max ? `fact · results — exactly ${n(c.min)}, each quoted from "${part.source}"` : `fact · results — ${c.min} to ${c.max} results, each quoted from "${part.source}"`;
206
+ return c.min !== undefined ? `fact · results — at least ${n(c.min)}, each quoted from "${part.source}"` : `fact · results — at most ${n(c.max)}, each quoted from "${part.source}"`;
207
+ }
208
+
209
+ /**
210
+ * Every plan-card row a fact part carries: its facts, then its count.
211
+ * @param {any} part @returns {string[]}
212
+ */
213
+ export function factRows(part) {
214
+ const rows = (Array.isArray(part.facts) ? part.facts : []).map((/** @type {any} */ f) => factSentence(f, part.source));
215
+ const c = countSentence(part);
216
+ return [...rows, ...(c ? [c] : [])];
217
+ }
218
+
219
+ /**
220
+ * One bareguard `cited` item (`#<result>:d<j>:<reason>[:<detail>]`, or `#<result>:<reason>`) as a sentence the worker can act
221
+ * on. The names and numbers are bareguard's measurements and the person's signed facts, never inferred here.
222
+ * @param {string} item @param {any} part @returns {string}
223
+ */
224
+ export function plainFactItem(item, part) {
225
+ const m = /^#(\d+):(?:d(\d+):)?([a-z-]+)(?::([\s\S]*))?$/.exec(item);
226
+ if (!m) return item;
227
+ const at = `result ${Number(m[1]) + 1}`;
228
+ const f = m[2] !== undefined && Array.isArray(part.facts) ? part.facts[Number(m[2])] : null;
229
+ const who = f ? `${at}, fact "${f.name}"` : at;
230
+ const detail = m[4] ?? '';
231
+ const say = (/** @type {string} */ s) => `${who}: ${s}`;
232
+ switch (m[3]) {
233
+ case 'quote-not-found': return `${at}: the quote is not found word for word in "${part.source}"`;
234
+ case 'empty-quote': return `${at}: the quote is empty — copy the words from "${part.source}" that hold the facts`;
235
+ case 'quote-too-long': return `${at}: the quote is too long — copy only that one result's own words, not more`;
236
+ case 'numbers': return `${at}: the number ${detail} is not in the quote the way the value writes it — write each value as the quote writes it (for example "9:27 AM", not "09:27")`;
237
+ case 'value-over': case 'value-under': return say(`the value in the quote is ${m[3] === 'value-over' ? 'above' : 'below'} what the rule allows${f && f.rule ? ` (${ruleWords(f)})` : ''}`);
238
+ case 'value-none': return say(`the quote holds no ${f ? f.shape : 'value'}${f && f.anchor !== undefined ? ` after "${f.anchor}"` : ''}`);
239
+ case 'value-many': return say(`the quote holds more than one ${f ? f.shape : 'value'} — quote only the words that hold this one`);
240
+ case 'value-malformed': return say(`a ${f ? f.shape : 'value'} in the quote is not written in a form the check can read`);
241
+ case 'anchor-missing': return say(`the label "${f ? f.anchor : ''}" is not in the quote`);
242
+ case 'anchor-ambiguous': return say(`the label "${f ? f.anchor : ''}" is in the quote more than once`);
243
+ case 'until-missing': return say(`"${f ? f.until : ''}" does not follow the label "${f ? f.anchor : ''}" in the quote`);
244
+ case 'phrase-missing': return say(`the quote does not say "${detail}"`);
245
+ case 'claim-value-missing': return say('no value was given in "values"');
246
+ case 'claim-value-malformed': return say('the value is not written as a value of this kind');
247
+ case 'claim-value-absent': return say('the value is not in the quote as a whole word');
248
+ case 'claim-value-unknown': return say('this value is not one of the signed facts');
249
+ case 'claim-value-mismatch': return say(`the value is not what the quote holds (${detail.replace('!=', ' is not ')})`);
250
+ default: return `${who}: ${m[3]}${detail ? ` ${detail}` : ''}`;
251
+ }
252
+ }
253
+
254
+ // ── which shape takes which fields (live fix B2) ─────────────────────────────────────────────────────────────────────────────
255
+ //
256
+ // The drafter put `until` on a money fact and `anchor`/`until`/`rule` on a phrase (smv6k6l8dxkzs). What a shape takes is bareguard's
257
+ // to say, so the table the drafter is shown is READ OFF `rubricVocabulary` (the `cited` rule's decide shapes and their stated
258
+ // conditions), never hand-typed. If bareguard's wording of a condition changes so that it cannot be read, this throws at load time
259
+ // and the test that pins it fails: a stale guide is never shown quietly.
260
+
261
+ /**
262
+ * The fields each fact shape takes, in the fact part's own names (`rule` = bareguard's op/value/lo/hi; `unit`/`currency`; `phrase`
263
+ * = the presence decide's phrases), read off bareguard's rubric vocabulary.
264
+ * @returns {Record<string, string[]>} shape -> the fields it takes besides name and shape
265
+ */
266
+ export function factShapeFields() {
267
+ const decides = /** @type {any} */ (rubricVocabulary).rules?.cited?.fields?.decides?.shapes;
268
+ const value = decides?.value;
269
+ const presence = decides?.presence;
270
+ const kinds = /** @type {string[]} */ (value?.fields?.valueKind?.enum);
271
+ /** @type {string[]} */
272
+ const conditions = value?.conditions ?? [];
273
+ const find = (/** @type {RegExp} */ re) => {
274
+ for (const c of conditions) { const m = re.exec(c); if (m) return m; }
275
+ throw new Error(`bareguard's rubric vocabulary no longer states the condition ${re} — update src/facts.js factShapeFields`);
276
+ };
277
+ if (!Array.isArray(kinds) || !presence?.fields?.phrases) throw new Error('bareguard\'s rubric vocabulary has no cited decide shapes — update src/facts.js factShapeFields');
278
+ const bindOnlyKind = find(/^valueKind (\w+) is bind-only: it refuses op, value, lo, hi, currency and unit/)[1];
279
+ const untilKind = find(/^until is allowed only on a (\w+) decide that also signs an anchor/)[1];
280
+ const moneyKind = find(/^(\w+) needs (?:exactly|at least) one of currency or unit/)[1];
281
+ /** @type {Record<string, string[]>} */
282
+ const out = {};
283
+ for (const k of kinds) {
284
+ /** @type {string[]} */
285
+ const f = [];
286
+ if (k !== bindOnlyKind) f.push('rule');
287
+ if (value.fields.anchor) f.push('anchor');
288
+ if (k === untilKind) f.push('until');
289
+ if (k === moneyKind) f.push('unit or currency');
290
+ out[k] = f;
291
+ }
292
+ out.phrase = ['phrase'];
293
+ return out;
294
+ }
295
+
296
+ /**
297
+ * The lines the drafter is shown about which shape takes which fields, generated from {@link factShapeFields} (shapes with the same
298
+ * fields are listed together). One owner: the composer brief and the confirm note both print this.
299
+ * @returns {string}
300
+ */
301
+ export function factShapeGuide() {
302
+ const by = factShapeFields();
303
+ /** @type {Map<string, string[]>} */
304
+ const groups = new Map();
305
+ for (const shape of FACT_SHAPES) {
306
+ const key = (by[shape] ?? []).join(' | ');
307
+ groups.set(key, [...(groups.get(key) ?? []), shape]);
308
+ }
309
+ const all = [...new Set(Object.values(by).flat())];
310
+ const lines = [];
311
+ for (const [key, shapes] of groups) {
312
+ const takes = key === '' ? [] : key.split(' | ');
313
+ const never = all.filter((f) => !takes.includes(f));
314
+ lines.push(`${shapes.join(' / ')}: takes ${takes.length ? takes.join(', ') : 'no extra field'}${never.length ? `; never ${never.join(', ')}` : ''}`);
315
+ }
316
+ return lines.map((l) => ` - ${l}`).join('\n');
317
+ }