bareloop 0.37.0 → 0.39.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.
package/src/calibrate.js CHANGED
@@ -61,10 +61,10 @@
61
61
  import { createHash } from 'node:crypto';
62
62
  import {
63
63
  LOCATE_LABEL, LOCATE_AXES, CALIBRATION_SIZE,
64
- runLocate, decide, expectedOf, validateCard, validateCalibrationSet,
64
+ runLocate, decide, expectedOf, validateCard, validateCalibrationSet, docQuoteInBlock,
65
65
  } from './judged.js';
66
66
  import { JUDGE_ATTEMPTS } from './kinds.js';
67
- import { tallyCalls } from './text.js';
67
+ import { tallyCalls, scrubRaw } from './text.js';
68
68
  import { PROVIDER_TIMEOUT_MS } from './clock.js';
69
69
 
70
70
  /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
@@ -280,20 +280,87 @@ export function factsResist(facts, gold, artifactText) {
280
280
  if (gotNames.join(',') !== wantNames.join(',')) {
281
281
  return { ok: false, why: `the located function list moved: expected ${wantNames.join(', ')}, got ${gotNames.join(', ') || '(none)'}` };
282
282
  }
283
- const lines = new Set(artifactText.split('\n').map((l) => l.trim()).filter(Boolean));
284
283
  for (const w of want) {
285
284
  const f = got.find((/** @type {any} */ x) => String(x?.name ?? '') === w.name);
286
285
  const q = typeof f?.docQuote === 'string' && f.docQuote.trim() !== '' ? f.docQuote : null;
287
286
  if (!w.hasDoc && q !== null) {
288
287
  return { ok: false, why: `${w.name} has no JSDoc block and the extraction reported one` };
289
288
  }
290
- if (w.hasDoc && (q === null || !q.includes('/**') || !lines.has(q.trim()))) {
289
+ // ONE OWNER of "this quote points at the real doc block" (docQuoteInBlock, the same call
290
+ // `has-doc`'s decide() makes). The judge routinely quotes a worded line inside the block
291
+ // rather than the `/**` opener, and since ruling A (0f27db5) that is a good pointer; the
292
+ // declaration it names must be this function's own, so a block above another one still leaks.
293
+ const decl = typeof f?.declarationQuote === 'string' ? f.declarationQuote : null;
294
+ if (w.hasDoc && (q === null || decl === null || !decl.includes(w.name) || !docQuoteInBlock(artifactText, decl, q))) {
291
295
  return { ok: false, why: `${w.name} IS documented and the extraction did not report its real JSDoc opener` };
292
296
  }
293
297
  }
294
298
  return { ok: true, why: null };
295
299
  }
296
300
 
301
+ // ── THE DIAGNOSTIC RECORD (F192) ────────────────────────────────────────────
302
+ //
303
+ // A refusal must be diagnosable from its OWN record. `expectedOf` drops `why` and
304
+ // `quote` on purpose (a signer cannot predict the judge's wording, so grading must
305
+ // not pin it), and the judge's raw locate facts used to be written nowhere — so a
306
+ // `has-doc` red could not be told apart as "no docQuote", "not a /** opener" or
307
+ // "a quoted line is not in the artifact". `diag` is a NEW field beside the graded
308
+ // ones: nothing here is read by grading, the compare, the hashes or any verdict.
309
+ // Every string passes through `scrubRaw` — the ONE secret inventory plus the one
310
+ // announced bound — so a quote that carries a key is masked and a long one says
311
+ // how much was withheld rather than silently clipping.
312
+
313
+ /** the per-string cap on anything the diagnostic keeps (bytes, scrubbed) */
314
+ export const DIAG_STRING_CAP = 400;
315
+ /** the most elements of one array the diagnostic keeps before it says it stopped */
316
+ const DIAG_ARRAY_CAP = 32;
317
+
318
+ /** @param {unknown} v @returns {string} */
319
+ const diagText = (v) => scrubRaw({ label: 'diag', attempt: 0, text: v, cap: DIAG_STRING_CAP }).text;
320
+
321
+ /**
322
+ * A facts tree, deep-copied with every string scrubbed and bounded. Depth is
323
+ * bounded too: facts are a shallow list of functions, and anything deeper is not
324
+ * a shape this pipe reads.
325
+ * @param {any} v @param {number} [depth] @returns {any}
326
+ */
327
+ function diagFacts(v, depth = 0) {
328
+ if (typeof v === 'string') return diagText(v);
329
+ if (v === null || typeof v !== 'object') return v;
330
+ if (depth >= 4) return diagText(JSON.stringify(v));
331
+ if (Array.isArray(v)) {
332
+ const kept = v.slice(0, DIAG_ARRAY_CAP).map((x) => diagFacts(x, depth + 1));
333
+ if (v.length > DIAG_ARRAY_CAP) kept.push(`[${v.length - DIAG_ARRAY_CAP} more element(s) withheld — the cap is ${DIAG_ARRAY_CAP} per array]`);
334
+ return kept;
335
+ }
336
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, diagFacts(x, depth + 1)]));
337
+ }
338
+
339
+ /**
340
+ * What one graded artifact's record KEEPS about how it was judged: every locate
341
+ * attempt's axis and cause, the judge's facts as returned, and the decision's
342
+ * full reds per rule.
343
+ * @param {{diagTries: any[], facts: any, decision: any}} r
344
+ */
345
+ function diagOf({ diagTries, facts, decision }) {
346
+ /** @type {{rule: string, fn: string, why: string, quote: string|null}[]} */
347
+ const reds = [];
348
+ for (const item of decision?.items ?? []) {
349
+ for (const x of item?.reds ?? []) {
350
+ reds.push({
351
+ rule: String(item?.rule ?? ''), fn: String(x?.fn ?? ''), why: diagText(x?.why),
352
+ quote: typeof x?.quote === 'string' ? diagText(x.quote) : null,
353
+ });
354
+ }
355
+ }
356
+ return {
357
+ attempts: diagTries,
358
+ facts: facts === null || facts === undefined ? null : diagFacts(facts),
359
+ reds,
360
+ reason: typeof decision?.reason === 'string' ? diagText(decision.reason) : null,
361
+ };
362
+ }
363
+
297
364
  // ── THE GATE ────────────────────────────────────────────────────────────────
298
365
 
299
366
  /**
@@ -320,6 +387,10 @@ async function pipeOnce({ artifactText, card, judgeLoop, judgeModel, attempts, m
320
387
  let last = null;
321
388
  /** @type {any[]} */
322
389
  const tries = [];
390
+ /** the diagnostic twin of `tries` (F192): the same attempts, with the cause text
391
+ * `tries` never carried. A separate array so `casualty.attempts` is unchanged.
392
+ * @type {any[]} */
393
+ const diagTries = [];
323
394
  // TIGHTEN-ONLY, FLOOR 1 — the same clamp shape every bound in this pipeline
324
395
  // carries. A caller may buy fewer retries than the ladder allows and never
325
396
  // more; ZERO is not a cheaper gate, it is a gate that grades nothing and has
@@ -327,7 +398,7 @@ async function pipeOnce({ artifactText, card, judgeLoop, judgeModel, attempts, m
327
398
  const bound = Math.max(1, Math.min(Math.trunc(Number(attempts)) || 0, JUDGE_ATTEMPTS));
328
399
  for (let attempt = 1; attempt <= bound; attempt += 1) {
329
400
  const halt = capStop();
330
- if (halt) return { ok: false, budget: halt, tries, red: null, facts: null, decision: null };
401
+ if (halt) return { ok: false, budget: halt, tries, diagTries, red: null, facts: null, decision: null };
331
402
  last = await runLocate({
332
403
  artifactText,
333
404
  card,
@@ -348,11 +419,12 @@ async function pipeOnce({ artifactText, card, judgeLoop, judgeModel, attempts, m
348
419
  }),
349
420
  });
350
421
  tries.push({ attempt, ok: last.ok, axis: last.red?.axis ?? null, costUsd: last.costUsd });
422
+ diagTries.push({ attempt, ok: last.ok, axis: last.red?.axis ?? null, detail: last.red ? diagText(last.red.detail) : null });
351
423
  if (last.ok) break;
352
424
  if (last.red.axis === LOCATE_AXES.PRICING) break;
353
425
  }
354
- if (!last.ok) return { ok: false, budget: null, tries, red: last.red, facts: null, decision: null };
355
- return { ok: true, budget: null, tries, red: null, facts: last.facts, decision: decide(last.facts, card, { artifactText }) };
426
+ if (!last.ok) return { ok: false, budget: null, tries, diagTries, red: last.red, facts: null, decision: null };
427
+ return { ok: true, budget: null, tries, diagTries, red: null, facts: last.facts, decision: decide(last.facts, card, { artifactText }) };
356
428
  }
357
429
 
358
430
  /**
@@ -393,7 +465,7 @@ async function pipeOnce({ artifactText, card, judgeLoop, judgeModel, attempts, m
393
465
  * onCost?: (c: any) => void, attempts?: number, battery?: any[], batteryCard?: any,
394
466
  * capStop?: () => 'cap-halt'|'pricing-red'|null}} o
395
467
  * @returns {Promise<{ok: boolean, stop: string|null, casualty: any,
396
- * graded: {id: string, ok: boolean, got: any, want: any, detail: string|null}[],
468
+ * graded: {id: string, ok: boolean, got: any, want: any, detail: string|null, diag: any}[],
397
469
  * injection: {styles: any[], allResisted: boolean, leaks: string[]},
398
470
  * failures: string[], reds: Red[], judgeModel: string,
399
471
  * cardHash: string|null, casesHash: string|null, setHash: string|null,
@@ -517,7 +589,7 @@ export async function runCalibration({
517
589
  ...stamped,
518
590
  stop: r.red.axis,
519
591
  graded,
520
- casualty: { at: String(c.id), kind: 'case', axis: r.red.axis, detail: r.red.detail, attempts: r.tries },
592
+ casualty: { at: String(c.id), kind: 'case', axis: r.red.axis, detail: r.red.detail, attempts: r.tries, diag: diagOf(r) },
521
593
  reds: [{
522
594
  code: r.red.axis,
523
595
  path: `calibration.cases.${c.id}`,
@@ -535,6 +607,7 @@ export async function runCalibration({
535
607
  got,
536
608
  want: { verdict: c.expect.verdict, reds: (c.expect.reds ?? []).map((/** @type {any} */ x) => ({ rule: x.rule, fn: x.fn })) },
537
609
  attempts: r.tries,
610
+ diag: diagOf(r),
538
611
  detail: cmp.ok ? null : [
539
612
  cmp.verdictOk ? null : `verdict: signed "${c.expect.verdict}", the pipe rendered "${got.verdict}"`,
540
613
  cmp.missing.length ? `expected red(s) the pipe did not raise: ${cmp.missing.join('; ')}` : null,
@@ -562,7 +635,7 @@ export async function runCalibration({
562
635
  stop: r.red.axis,
563
636
  graded,
564
637
  injection: { styles, allResisted: false, leaks: [] },
565
- casualty: { at: b.id, kind: 'injection', axis: r.red.axis, detail: r.red.detail, attempts: r.tries },
638
+ casualty: { at: b.id, kind: 'injection', axis: r.red.axis, detail: r.red.detail, attempts: r.tries, diag: diagOf(r) },
566
639
  reds: [{
567
640
  code: r.red.axis,
568
641
  path: `injection.${b.id}`,
@@ -581,6 +654,7 @@ export async function runCalibration({
581
654
  resisted: facts.ok && cmp.ok,
582
655
  factsOk: facts.ok,
583
656
  decisionOk: cmp.ok,
657
+ diag: diagOf(r),
584
658
  got,
585
659
  want: { verdict: b.expect.verdict, reds: b.expect.reds.map((/** @type {any} */ x) => ({ rule: x.rule, fn: x.fn })) },
586
660
  detail: facts.ok && cmp.ok ? null : (facts.why ?? `the decision moved: ${[
package/src/cardauthor.js CHANGED
@@ -228,7 +228,7 @@ export const COMPILE_SYSTEM = 'You compile a person\'s own words into a rubric a
228
228
  export function cardCasesPrompt({ answers, questions = {} }) {
229
229
  const qKey = SOFTGREEN_JUDGE_EXAMPLES_KEY;
230
230
  const q = questions[qKey] ?? JUDGE_EXAMPLES_QUESTION;
231
- const rules = JUDGE_RULE_IDS.map((id) => `- ${id}\n the facts it reads: ${JUDGE_RULES[id].ask.trim().split('\n')[0].trim()}`);
231
+ const rules = JUDGE_RULE_IDS.map((id) => `- ${id}\n what it can check: ${JUDGE_RULES[id].means}\n the facts it reads: ${JUDGE_RULES[id].ask.trim().split('\n')[0].trim()}`);
232
232
 
233
233
  return `You are compiling ONE person's answers into the two artifacts a judged close is signed with.
234
234
 
@@ -266,6 +266,9 @@ at — plus what the arbiter must decide about it.
266
266
 
267
267
  AT LEAST ONE PASS AND AT LEAST ONE RED. A set that can only fail one way measures nothing.
268
268
  Every rule you name in a case must be a rule your card carries.
269
+ A case may expect a red only where the rule's "what it can check" line says it can raise one on that artifact: a
270
+ function with any doc block above it never breaks has-doc, and "the doc only restates the function name" is a
271
+ says-what red, never a has-doc one.
269
272
 
270
273
  HOW TO ANSWER
271
274
 
@@ -272,13 +272,13 @@ 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}} [opts]
275
+ * catalogue?: Record<string, any>, verdictType?: string|null, writeScope?: string[]|null}} [opts]
276
276
  * @returns {{ok: boolean, reds: Red[], closeDecl: any, grounded: boolean,
277
277
  * scoped: {scoped: boolean, via: string|null},
278
278
  * ceiling: {class: string, kind: string, stage: string|null, locked: boolean}|null}}
279
279
  */
280
280
  export function validateCloseDecl(closeDecl, opts = {}) {
281
- const { at = 'closeDecl', listing = null, catalogue, verdictType = null } = opts;
281
+ const { at = 'closeDecl', listing = null, catalogue, verdictType = null, writeScope = null } = opts;
282
282
  const deferListing = opts.deferListing === true;
283
283
  /** @type {Red[]} */
284
284
  const reds = [];
@@ -401,6 +401,7 @@ export function validateCloseDecl(closeDecl, opts = {}) {
401
401
  ...(deferListing ? { deferListing: true } : {}),
402
402
  guards: classGuards({ verdictType: picked, lang: closeDecl.lang }),
403
403
  verdictType: picked,
404
+ writeScope,
404
405
  envOwned: owned,
405
406
  // POST-injection: an owned name is accepted only when its value is EXACTLY
406
407
  // the recorded one. `{}` (nothing recorded) keeps the pre-injection rule, so
@@ -678,14 +679,19 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
678
679
  * recommend a top-up on a dying run — the one direction trend.js names as
679
680
  * dangerous. F6's answer applies: unknown, never a number that means the
680
681
  * opposite of what it says.
681
- * - a stage with no measurement at all donates nothing, for the same reason.
682
+ * - a JUDGED stage (no measurement) donates its distinct red COUNT, always 'down';
683
+ * - any other stage with no measurement at all donates nothing, for the same reason.
682
684
  * The stage POSITION still travels either way, so "the run got further than it
683
685
  * ever had" is readable on every declared close.
684
686
  * @param {any} stage @param {any} r the M1 StageResult
685
687
  * @returns {number|null}
686
688
  */
687
689
  function trendValueOf(stage, r) {
688
- if (typeof r?.value !== 'number' || !Number.isFinite(r.value)) return null;
690
+ if (typeof r?.value !== 'number' || !Number.isFinite(r.value)) {
691
+ // a JUDGED stage has no measurement: its number is how many DISTINCT reds it itemizes
692
+ // (F192 e), lower-is-better, compared to its best-so-far like any deterministic count
693
+ return Number.isInteger(r?.detail?.redCount) ? r.detail.redCount : null;
694
+ }
689
695
  return stage?.params?.direction === 'lower-is-better' ? r.value : null;
690
696
  }
691
697
 
package/src/job.js CHANGED
@@ -757,7 +757,7 @@ function validatePlanShape(spec, red, reds) {
757
757
  // class the user picked (PRD v1.57 §2), so the gate cannot check D5 without
758
758
  // it. An unknown or locked class is refused there, on its own axis, beside
759
759
  // the counted `request-red` above.
760
- const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType });
760
+ const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope });
761
761
  for (const r of cd.reds) reds.push(r);
762
762
  if (demanded !== undefined && !CLASS_BY_CLOSE.declared.includes(demanded)) {
763
763
  // the same laundering guard as the close-type hierarchy, one level up: a