bareloop 0.40.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 (77) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +141 -128
  4. package/package.json +5 -4
  5. package/src/authorflow.js +346 -38
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +82 -20
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +39 -30
  10. package/src/authorscout.js +112 -4
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/cli.js +1 -1
  14. package/src/config.js +129 -29
  15. package/src/declaredclose.js +23 -12
  16. package/src/draftspend.js +4 -4
  17. package/src/facts.js +317 -0
  18. package/src/factwords.js +129 -0
  19. package/src/index.js +2 -4
  20. package/src/inputlook.js +301 -0
  21. package/src/interviewrun.js +17 -25
  22. package/src/job.js +15 -8
  23. package/src/jobblock.js +7 -2
  24. package/src/judged.js +11 -43
  25. package/src/keysfile.js +15 -7
  26. package/src/kinds.js +86 -5
  27. package/src/labelscan.js +170 -0
  28. package/src/modellist.js +84 -0
  29. package/src/monthly.js +23 -43
  30. package/src/pagefetch.js +125 -0
  31. package/src/panel/authorroutes.js +34 -16
  32. package/src/panel/authorsession.js +161 -80
  33. package/src/panel/index.html +553 -152
  34. package/src/panel/jobplan.js +34 -11
  35. package/src/panel/runroutes.js +3 -0
  36. package/src/panel/server.js +54 -18
  37. package/src/panel/settingsroutes.js +232 -173
  38. package/src/planrun.js +5 -4
  39. package/src/providerrows.js +226 -141
  40. package/src/providers.js +58 -332
  41. package/src/providershapes.js +18 -0
  42. package/src/replay.js +1 -1
  43. package/src/rubricstep.js +804 -0
  44. package/src/source.js +166 -1
  45. package/src/thinking.js +130 -0
  46. package/src/u-readout.js +1 -1
  47. package/src/userrun.js +64 -75
  48. package/types/authorflow.d.ts +272 -18
  49. package/types/authoring.d.ts +29 -1
  50. package/types/authorjob.d.ts +9 -7
  51. package/types/authorreadout.d.ts +27 -2
  52. package/types/authorscout.d.ts +72 -2
  53. package/types/config.d.ts +23 -6
  54. package/types/declaredclose.d.ts +4 -2
  55. package/types/draftspend.d.ts +8 -8
  56. package/types/facts.d.ts +71 -0
  57. package/types/factwords.d.ts +42 -0
  58. package/types/index.d.ts +1 -1
  59. package/types/inputlook.d.ts +238 -0
  60. package/types/job.d.ts +5 -5
  61. package/types/jobblock.d.ts +8 -1
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/labelscan.d.ts +38 -0
  65. package/types/modellist.d.ts +50 -0
  66. package/types/monthly.d.ts +4 -3
  67. package/types/pagefetch.d.ts +71 -0
  68. package/types/panel/authorsession.d.ts +20 -11
  69. package/types/panel/jobplan.d.ts +12 -4
  70. package/types/panel/server.d.ts +18 -6
  71. package/types/providerrows.d.ts +192 -99
  72. package/types/providers.d.ts +34 -159
  73. package/types/providershapes.d.ts +10 -0
  74. package/types/rubricstep.d.ts +286 -0
  75. package/types/source.d.ts +94 -0
  76. package/types/thinking.d.ts +90 -0
  77. package/types/userrun.d.ts +17 -1
@@ -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/draftspend.js CHANGED
@@ -24,10 +24,10 @@ export const DRAFT_LOG_FILE = 'draft-log.jsonl';
24
24
  * One line of the drafting log. `no` = the step's position in the session's progress list (a step id can repeat).
25
25
  * - `step-start` {no, id, label, at}
26
26
  * - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
27
- * - `step-label` {no, label, at} (the step reads `label` from now on, status and times untouched: the thinking row's "the model thought anyway")
28
- * - `call` {no, step, label, model, thinking, thinkingApplied?, costUsd (null stays null), unpricedRounds, at}: one METERED model call
29
- * (`thinking` = what was asked and sent: off | on | not-switchable | unverified; `thinkingApplied` = bare-agent's own verdict for the
30
- * call's run (true | false | null), present only when thinkingMode was sent)
27
+ * - `step-label` {no, label, at} (the step reads `label` from now on, status and times untouched: the thinking row's "the model thought anyway" / "can't change it" / "not confirmed")
28
+ * - `call` {no, step, label, model, thinking, thinkingSupport, thinkingStatus?, costUsd (null stays null), unpricedRounds, at}: one METERED model call
29
+ * (`thinking` = the tick's mode: off | on; `thinkingSupport` = bare-agent's lookup for the model: true | false | 'unverified';
30
+ * `thinkingStatus` = bare-agent's own verdict for the call's run: applied | unsupported | ignored | unknown, present only when thinkingMode was sent)
31
31
  * (no token counts: the call path does not carry them without reworking the library, so none are recorded)
32
32
  * @typedef {{kind: string, no: number, at: string, [k: string]: any}} DraftEvent
33
33
  */
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
+ }
@@ -0,0 +1,129 @@
1
+ // THE FOUND-ONCE CHECK (PART 1c, docs/product/ITEM33-BUILD.md "3. The look", PRD item 33 RF20): code proves, at $0, that every word a fact
2
+ // check depends on is really in the person's source - ONCE is enough - and that every label (anchor) is one PICKED from the list code
3
+ // made from that source (src/labelscan.js), never typed. A word that is not found is a red naming the word and the input. Nothing is
4
+ // graded here and no model is involved: the AI proposes the words, code proves they are there.
5
+ //
6
+ // Compared WHITE-SPACE BLIND: any run of white space (U+202F and U+00A0 included - Google writes U+202F before AM/PM, 259 times in one
7
+ // page) is one space on both sides.
8
+
9
+ import { labelOnList, normLabel, scanLabels } from './labelscan.js';
10
+
11
+ const WS = /[\s   ]+/g;
12
+ const escapeRe = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+
14
+ /** @param {string} t @returns {string} the source with every run of white space made one space */
15
+ export const squash = (t) => String(t ?? '').replace(WS, ' ');
16
+ /** @param {string} t @returns {string} lower case, markdown marks dropped, white space squashed - how a phrase or unit is compared */
17
+ const loose = (t) => squash(t).replace(/[*_`]/g, '').trim().toLowerCase();
18
+
19
+ /**
20
+ * How many times exact words occur in a source, white space blind. Zero for empty words.
21
+ * @param {string} source the raw source text @param {string} words
22
+ * @returns {number}
23
+ */
24
+ export function countExact(source, words) {
25
+ const w = squash(words).trim();
26
+ if (w === '') return 0;
27
+ const hay = squash(source);
28
+ let n = 0;
29
+ for (let i = hay.indexOf(w); i !== -1; i = hay.indexOf(w, i + w.length)) n += 1;
30
+ return n;
31
+ }
32
+
33
+ /**
34
+ * How many times a phrase or unit occurs as WHOLE words, case and spacing aside ("SFO" is not found inside "sfocus").
35
+ * @param {string} source @param {string} words @returns {number}
36
+ */
37
+ export function countPhrase(source, words) {
38
+ const w = loose(words);
39
+ if (w === '') return 0;
40
+ const re = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(w)}(?=$|[^\\p{L}\\p{N}])`, 'gu');
41
+ let n = 0;
42
+ for (const _m of loose(source).matchAll(re)) n += 1;
43
+ return n;
44
+ }
45
+
46
+ /** @param {string} shape @returns {'money'|'number'|'date'|'time'|'text'} the label list a fact's anchor is picked from */
47
+ const listOf = (shape) => /** @type {any} */ (['money', 'number', 'date', 'time'].includes(shape) ? shape : 'text');
48
+
49
+ /**
50
+ * The reds for ONE fact part against its frozen source text.
51
+ * @param {{part: any, text: string, line: number|string, scan?: import('./labelscan.js').LabelScan}} o
52
+ * @returns {{code: string, detail: string}[]}
53
+ */
54
+ export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
55
+ /** @type {{code: string, detail: string}[]} */
56
+ const reds = [];
57
+ const src = part.source;
58
+ const red = (/** @type {string} */ code, /** @type {string} */ msg) => reds.push({ code, detail: `line ${line}: ${msg} Nothing was signed.` });
59
+ for (const f of Array.isArray(part.facts) ? part.facts : []) {
60
+ const who = `fact "${f.name}"`;
61
+ if (f.anchor !== undefined && f.shape !== 'phrase') {
62
+ const list = listOf(f.shape);
63
+ const row = labelOnList(scan, list, f.anchor);
64
+ if (!row) {
65
+ const some = scan[list].slice(0, 12).map((r) => JSON.stringify(r.label)).join(', ');
66
+ red('rubric-fact-label-off-list', `${who}: the label ${JSON.stringify(normLabel(String(f.anchor)))} is not one of the labels the input "${src}" holds before a ${f.shape === 'text' ? 'text value' : f.shape}. A label is picked from that list, never typed${some ? `: ${some}` : ' (the list is empty - leave the label out)'}.`);
67
+ } else if (countExact(text, row.label) < 1) {
68
+ red('rubric-fact-word-missing', `${who}: the label ${JSON.stringify(row.label)} is not in the input "${src}".`);
69
+ } else if (Number.isInteger(part.count?.min) && row.count < part.count.min) {
70
+ // M1: a label that starts each result must repeat as often as results were asked for; one that sits once on the page
71
+ // (a sort tab, a switch) is in no result, so no one-result quote can hold it
72
+ const n = part.count.min;
73
+ red('rubric-fact-label-rare', `${who}: the label ${JSON.stringify(row.label)} is in the input "${src}" only ${row.count} time${row.count === 1 ? '' : 's'}, but this line asks for ${n} results, so a label that sits in each result must be there at least ${n} times. Pick a label from the list that is counted ${n} or more times.`);
74
+ }
75
+ }
76
+ if (f.until !== undefined && countExact(text, f.until) < 1) red('rubric-fact-word-missing', `${who}: ${JSON.stringify(f.until)} is not in the input "${src}". Copy the closing words from the input, or leave them out.`);
77
+ if (f.shape === 'phrase' && typeof f.phrase === 'string') {
78
+ const seen = countPhrase(text, f.phrase);
79
+ if (seen < 1) red('rubric-fact-word-missing', `${who}: ${JSON.stringify(f.phrase)} is not in the input "${src}". Copy the words the input really uses, or remove the check.`);
80
+ else if (Number.isInteger(part.count?.min) && seen < part.count.min) {
81
+ // M4: the words each result's quote must say must repeat as often as results were asked for; page-header words (counted
82
+ // once or twice) are in no result, so no one-result quote can say them
83
+ const n = part.count.min;
84
+ red('rubric-fact-phrase-rare', `${who}: the words ${JSON.stringify(f.phrase)} are in the input "${src}" only ${seen} time${seen === 1 ? '' : 's'}, but this line asks for ${n} results - each result's own quote must say them. Pick words every result says, or drop this check.`);
85
+ }
86
+ }
87
+ if (typeof f.unit === 'string' && countPhrase(text, f.unit) < 1) red('rubric-fact-word-missing', `${who}: the unit ${JSON.stringify(f.unit)} is not in the input "${src}". Copy the words the input really uses.`);
88
+ }
89
+ return reds;
90
+ }
91
+
92
+ /**
93
+ * The reds for every fact part of a declaration. A fact part whose input text is not at hand is a red: a check whose words were never
94
+ * proved against the source is never signed.
95
+ * @param {any} declaration @param {Record<string, string>|null} texts label -> frozen text
96
+ * @returns {{code: string, path: string, detail: string}[]}
97
+ */
98
+ export function checkDeclarationWords(declaration, texts) {
99
+ /** @type {{code: string, path: string, detail: string}[]} */
100
+ const reds = [];
101
+ if (!declaration || !Array.isArray(declaration.stages)) return reds;
102
+ /** @type {Map<string, import('./labelscan.js').LabelScan>} */
103
+ const scans = new Map();
104
+ declaration.stages.forEach((/** @type {any} */ st, /** @type {number} */ i) => {
105
+ const parts = st?.kind === 'rubric-step' && Array.isArray(st.params?.parts) ? st.params.parts : [];
106
+ parts.forEach((/** @type {any} */ p, /** @type {number} */ k) => {
107
+ if (p?.class !== 'fact') return;
108
+ const at = `stages[${i}].params.parts[${k}]`;
109
+ const text = texts ? texts[p.source] : undefined;
110
+ /** @param {string} code @param {string} detail */
111
+ const red = (code, detail) => reds.push({ code, path: at, detail });
112
+ if (typeof text !== 'string') {
113
+ red('rubric-fact-unread', `line ${st.params.step}: the input "${p.source}" could not be read here to prove the words against it. Nothing was signed.`);
114
+ return;
115
+ }
116
+ if (!scans.has(p.source)) scans.set(p.source, scanLabels(text));
117
+ for (const r of checkFactWords({ part: p, text, line: st.params.step, scan: scans.get(p.source) })) reds.push({ code: r.code, path: at, detail: r.detail });
118
+ });
119
+ });
120
+ return reds;
121
+ }
122
+
123
+ /**
124
+ * The look's own proof hook: how often a phrase the look built is in an input.
125
+ * @param {Record<string, string>} texts @returns {(source: string, phrase: string) => number}
126
+ */
127
+ export function proverOf(texts) {
128
+ return (source, phrase) => (typeof texts[source] === 'string' ? countPhrase(texts[source], phrase) : 0);
129
+ }
package/src/index.js CHANGED
@@ -148,16 +148,14 @@ export { answerReviewDoor } from './reviewdoor.js';
148
148
  // SOFTGREEN module 1 — the judged floor's core. The pieces an adopter (and an
149
149
  // integrating UI) genuinely needs: `resolveJudge` (PRD item 32.1 — WHICH model
150
150
  // grades a job: the spec's signed `judge` override, else the job's own worker
151
- // model, and no library fall-back), `JUDGE_MODEL` (the pre-item-32 pin, kept
152
- // exported for adopters and still the right default for an anthropic-api job,
153
- // but no longer read by any grading path), the rulebook a card SELECTS from,
151
+ // model, and no library fall-back; the old pinned-model constant is gone), the rulebook a card SELECTS from,
154
152
  // the card gate, and the two halves themselves so a calibration harness can
155
153
  // grade the whole pipe without running a close.
156
154
  // `defaultJudgeLoop` (src/judged.js) is the one spelling of how this repo drives a
157
155
  // judge; the runner reaches it through `runPlan`'s `judgeProvider`. It is internal:
158
156
  // the package root does not export it.
159
157
  export {
160
- JUDGE_MODEL, resolveJudge, JUDGE_RULES, JUDGE_RULE_IDS,
158
+ resolveJudge, JUDGE_RULES, JUDGE_RULE_IDS,
161
159
  runLocate, decide,
162
160
  // SOFTGREEN module 4 — what a legal SIGNED calibration set is. The SIZE is
163
161
  // hamr's own ruling and a size change is a spec-level threshold change;