bareloop 0.38.0 → 0.40.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 (56) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/bareloop.context.md +87 -24
  3. package/package.json +3 -3
  4. package/src/authorflow.js +335 -59
  5. package/src/authoring.js +125 -3
  6. package/src/authorjob.js +93 -32
  7. package/src/authorreadout.js +498 -3
  8. package/src/authorrun.js +26 -6
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/calibrate.js +85 -11
  12. package/src/cardauthor.js +8 -2
  13. package/src/declaredclose.js +12 -5
  14. package/src/draftspend.js +6 -1
  15. package/src/interviewrun.js +56 -27
  16. package/src/job.js +38 -5
  17. package/src/jobblock.js +140 -0
  18. package/src/judged.js +323 -29
  19. package/src/kinds.js +13 -1
  20. package/src/ledger.js +1 -1
  21. package/src/panel/authorroutes.js +17 -11
  22. package/src/panel/authorsession.js +202 -75
  23. package/src/panel/index.html +302 -312
  24. package/src/panel/jobplan.js +107 -0
  25. package/src/panel/server.js +67 -13
  26. package/src/planrun.js +6 -5
  27. package/src/promptregisters.js +1 -1
  28. package/src/providers.js +111 -2
  29. package/src/reuse.js +1 -1
  30. package/src/root.js +1 -1
  31. package/src/run.js +3 -3
  32. package/src/selection.js +1 -1
  33. package/src/source.js +64 -1
  34. package/src/text.js +13 -0
  35. package/src/transport.js +1 -1
  36. package/src/trend.js +1 -0
  37. package/src/userrun.js +3 -3
  38. package/types/authorflow.d.ts +258 -39
  39. package/types/authoring.d.ts +30 -1
  40. package/types/authorjob.d.ts +52 -13
  41. package/types/authorreadout.d.ts +123 -1
  42. package/types/authorscout.d.ts +5 -3
  43. package/types/calibrate.d.ts +4 -1
  44. package/types/declaredclose.d.ts +5 -1
  45. package/types/draftspend.d.ts +8 -2
  46. package/types/job.d.ts +1 -1
  47. package/types/jobblock.d.ts +102 -0
  48. package/types/judged.d.ts +21 -2
  49. package/types/panel/authorsession.d.ts +57 -7
  50. package/types/panel/jobplan.d.ts +58 -0
  51. package/types/panel/server.d.ts +20 -0
  52. package/types/planrun.d.ts +1 -1
  53. package/types/providers.d.ts +52 -1
  54. package/types/run.d.ts +1 -1
  55. package/types/source.d.ts +26 -0
  56. package/types/trend.d.ts +1 -0
@@ -26,6 +26,8 @@
26
26
  import { scrubRaw } from './text.js';
27
27
  import { redactSecrets } from './validate.js';
28
28
  import { judgedStages } from './kinds.js';
29
+ import { CALIBRATION_SIZE } from './judged.js';
30
+ import { INJECTION_LOCATE_BATTERY } from './calibrate.js';
29
31
 
30
32
  /**
31
33
  * @param {{goal?: string|null, closeDecl?: any}} spec the RESOLVED spec — the bytes
@@ -51,7 +53,7 @@ export function declarationLines({ goal, closeDecl }) {
51
53
  * F176'S FALLBACK, SAID OUT LOUD — the revise ladder kept the NEWEST SOUND
52
54
  * declaration rather than the last one it happened to try, because "kept the
53
55
  * last accepted revision" is not the same claim as "kept the best revision"
54
- * (docs/logs/FINDINGS.md F176). Absent on every run where the fallback never
56
+ * (docs/wiki/FINDINGS.md F176). Absent on every run where the fallback never
55
57
  * fired (the overwhelming majority), which is why this returns an EMPTY list
56
58
  * rather than a "no fallback" line — a green close has nothing to say here and
57
59
  * printing it every run would be noise, not information.
@@ -142,6 +144,36 @@ export function answeredQuestionLines(confirmed) {
142
144
  return lines;
143
145
  }
144
146
 
147
+ /** a quote for a human line: one line, cut at `max` with the cut VISIBLE
148
+ * @param {unknown} q @param {number} [max] @returns {string} */
149
+ function shortQuote(q, max = 80) {
150
+ const one = String(q ?? '').replace(/\s+/g, ' ').trim();
151
+ return one.length > max ? `${one.slice(0, max)}…[truncated, ${one.length} chars]` : one;
152
+ }
153
+
154
+ /**
155
+ * ONE plain line saying WHY a graded case reds, read off its own `diag` (F192):
156
+ * the first red's rule, function, `why` and quoted text — and how many more the
157
+ * record holds. A case with no reds says why it has none (no facts: each
158
+ * attempt's axis and cause). Null when the row predates `diag`.
159
+ * @param {any} row a `graded` or `injection.styles` row
160
+ * @returns {string|null}
161
+ */
162
+ function diagLine(row) {
163
+ const d = row?.diag;
164
+ if (!d) return null;
165
+ const first = d.reds?.[0];
166
+ if (first) {
167
+ const more = d.reds.length > 1 ? ` (+${d.reds.length - 1} more red(s) in signing.json)` : '';
168
+ return ` why: ${first.rule} · ${first.fn} — ${first.why}; quote: ${first.quote === null ? '(none)' : `"${shortQuote(first.quote)}"`}${more}`;
169
+ }
170
+ const failed = (d.attempts ?? []).filter((/** @type {any} */ a) => !a.ok);
171
+ if (failed.length) {
172
+ return ` why: no facts — ${failed.map((/** @type {any} */ a) => `attempt ${a.attempt} [${a.axis}] ${shortQuote(a.detail, 120)}`).join('; ')}`;
173
+ }
174
+ return d.reason ? ` why: ${shortQuote(d.reason, 120)}` : null;
175
+ }
176
+
145
177
  /**
146
178
  * THE CALIBRATION GATE'S OWN READOUT — the one gate that spends money, and the
147
179
  * one whose rows a signer has to read case by case.
@@ -170,14 +202,46 @@ export function calibrationLines(calibration) {
170
202
  if (calibration.casualty) {
171
203
  lines.push(` CASUALTY on ${calibration.casualty.kind} "${calibration.casualty.at}" [${calibration.casualty.axis}] `
172
204
  + '— a broken judge is no evidence about the set');
205
+ const cl = diagLine(calibration.casualty);
206
+ if (cl) lines.push(cl);
207
+ }
208
+ for (const g of graded.filter((/** @type {any} */ x) => !x.ok)) {
209
+ lines.push(` WRONG ${g.id}: ${g.detail}`);
210
+ const l = diagLine(g);
211
+ if (l) lines.push(l);
212
+ }
213
+ for (const s of styles.filter((/** @type {any} */ x) => !x.resisted)) {
214
+ lines.push(` LEAK ${s.style}: ${s.detail}`);
215
+ const l = diagLine(s);
216
+ if (l) lines.push(l);
173
217
  }
174
- for (const g of graded.filter((/** @type {any} */ x) => !x.ok)) lines.push(` WRONG ${g.id}: ${g.detail}`);
175
- for (const s of styles.filter((/** @type {any} */ x) => !x.resisted)) lines.push(` LEAK ${s.style}: ${s.detail}`);
176
218
  lines.push(` certified card ${String(calibration.cardHash ?? 'unknown').slice(0, 12)} cases `
177
219
  + `${String(calibration.casesHash ?? 'unknown').slice(0, 12)} set ${String(calibration.setHash ?? 'unknown').slice(0, 12)}`);
178
220
  return lines;
179
221
  }
180
222
 
223
+ /**
224
+ * The calibration gate's real result as one line a person reads next to the step (hamr, 2026-10-09: the panel
225
+ * showed `calibrating ✓` over a gate that graded 8 of 10 and let 2 attack styles leak). PASS IS THE GATE'S OWN:
226
+ * `ok` is `calibration.ok` as `runCalibration` decided it (every case right as a SET of reds, every style
227
+ * resisted), never recomputed here, so there is no second spelling of the floor. The counts are read from the
228
+ * record (`graded[].ok` is `compareExpectation`, which compares reds as sets); the denominators are the live
229
+ * constants (CALIBRATION_SIZE, the battery length), so a gate that stopped part-way reads "x of 10", not "x of x".
230
+ * @param {any} calibration `signing.gates.calibration`
231
+ * @returns {{ok: boolean, note: string}|null} null when the gate was not reached
232
+ */
233
+ export function calibrationSummary(calibration) {
234
+ if (!calibration || typeof calibration !== 'object') return null;
235
+ const graded = Array.isArray(calibration.graded) ? calibration.graded : [];
236
+ const styles = Array.isArray(calibration.injection?.styles) ? calibration.injection.styles : [];
237
+ const right = graded.filter((/** @type {any} */ g) => g?.ok === true).length;
238
+ const resisted = styles.filter((/** @type {any} */ x) => x?.resisted === true).length;
239
+ return {
240
+ ok: calibration.ok === true,
241
+ note: `${right} of ${CALIBRATION_SIZE} practice cases graded right · ${resisted} of ${INJECTION_LOCATE_BATTERY.length} attack tests resisted`,
242
+ };
243
+ }
244
+
181
245
  /**
182
246
  * THE AUTHORING CEILING, parsed from `--budget`. Housed here for the same reason
183
247
  * the readout above is: the runner is a script, and a rule no test can reach is a
@@ -321,3 +385,434 @@ export function crashRecord(err) {
321
385
  }),
322
386
  };
323
387
  }
388
+
389
+ // ── THE PLAIN REASON FOR A REFUSED RUBRIC PROPOSAL ──────────────────────────
390
+ //
391
+ // A rubric draft that stopped at `proposal-invalid` / `rubric-invalid` used to
392
+ // say only that code, and the technical reds behind it were lost to the person
393
+ // (live, panel session smv0j8bp2pbyn, 2026-10-09; hamr: "A user should get a
394
+ // reason that they can understand and do something about"). ONE owner: the
395
+ // panel session and the CLI both print `proposalStopText`, so there is one
396
+ // spelling and two doors. Every sentence is written HERE, by code — the model
397
+ // never words a reason — and the structured red travels beside it so the record
398
+ // loses nothing.
399
+
400
+ /** the longest case name / quoted piece a plain sentence will carry */
401
+ const PLAIN_QUOTE_MAX = 60;
402
+ /** the most bullets a message lists before it says how many more there are */
403
+ const PLAIN_MAX_BULLETS = 8;
404
+ /** the most reds one drafting-log record carries, and the longest string in any of them */
405
+ const RECORD_MAX_REDS = 40;
406
+ const RECORD_STR_MAX = 600;
407
+
408
+ /** model-derived text in a person-facing line: scrubbed, one line, bounded
409
+ * @param {unknown} s @returns {string} */
410
+ function safeText(s) {
411
+ const one = redactSecrets(String(s ?? '')).replace(/\s+/g, ' ').trim();
412
+ return one.length > PLAIN_QUOTE_MAX ? `${one.slice(0, PLAIN_QUOTE_MAX)}…` : one;
413
+ }
414
+
415
+ /**
416
+ * @typedef {{code: string, path: string, detail: string, [k: string]: any}} PlainRed
417
+ * @typedef {{kind: string, sentence: string, red: PlainRed}} PlainReason
418
+ */
419
+
420
+ /**
421
+ * The ONE table of plain kinds → sentence builders. A builder gets the red, the
422
+ * name of the case it lands on (when it lands on one) and the match of the
423
+ * regex that picked it. `unmapped` is the only kind the table does not list.
424
+ * Order matters: the first matching entry wins.
425
+ * @type {{kind: string, when: (r: PlainRed) => boolean, say: (r: PlainRed, c: string, m: RegExpMatchArray|null) => string}[]}
426
+ */
427
+ const PLAIN_TABLE = [
428
+ // ── the rubric card (validateCard, surfaced as code invalid-value at path "card") ──
429
+ { kind: 'card-not-object', when: (r) => r.code === 'invalid-value' && /^the card must be an object/.test(r.detail),
430
+ say: () => 'The model did not hand back a rubric in the expected shape.' },
431
+ { kind: 'card-empty', when: (r) => r.code === 'invalid-value' && /^the card must carry a non-empty/.test(r.detail),
432
+ say: () => 'The model\'s rubric has no rules in it.' },
433
+ { kind: 'card-line-bad', when: (r) => r.code === 'invalid-value' && /^card item \d+: must be an object/.test(r.detail),
434
+ say: (r) => `Rubric line ${cardLineNo(r)} is not a proper line.` },
435
+ { kind: 'card-line-no-rule', when: (r) => r.code === 'invalid-value' && /^card item \d+: needs a `rule`/.test(r.detail),
436
+ say: (r) => `Rubric line ${cardLineNo(r)} does not pick which rule it uses.` },
437
+ { kind: 'card-line-unknown-rule', when: (r) => r.code === 'invalid-value' && /^card item \d+: `.*` is not a rule this arbiter implements/.test(r.detail),
438
+ say: (r, _c, _m) => `Rubric line ${cardLineNo(r)} names a rule bareloop does not have (${safeText(/`(.*?)` is not a rule/.exec(r.detail)?.[1] ?? '')}).` },
439
+ { kind: 'card-line-repeat', when: (r) => r.code === 'invalid-value' && /^card item \d+: `.*` is named twice/.test(r.detail),
440
+ say: (r) => `Rubric line ${cardLineNo(r)} uses a rule that an earlier line already uses.` },
441
+ { kind: 'card-line-no-text', when: (r) => r.code === 'invalid-value' && /^card item \d+: needs `text`/.test(r.detail),
442
+ say: (r) => `Rubric line ${cardLineNo(r)} has no wording.` },
443
+ { kind: 'proposal-not-object', when: (r) => r.code === 'invalid-value' && r.path === 'proposal',
444
+ say: () => 'The model\'s answer was not a rubric plus practice cases.' },
445
+ // ── the practice-case set as a whole ──
446
+ { kind: 'set-no-rules', when: (r) => r.code === 'calibration-cardless',
447
+ say: () => 'The rubric has no rules, so the practice cases had nothing to be checked against.' },
448
+ { kind: 'set-not-list', when: (r) => r.code === 'calibration-size' && !('declared' in r),
449
+ say: () => `The practice cases did not come back as a list of ${CALIBRATION_SIZE}.` },
450
+ { kind: 'set-size', when: (r) => r.code === 'calibration-size',
451
+ say: (r) => `The model gave ${r.declared} practice case${r.declared === 1 ? '' : 's'}; exactly ${r.required ?? CALIBRATION_SIZE} are needed.` },
452
+ { kind: 'set-one-sided', when: (r) => r.code === 'calibration-polarity',
453
+ say: (r) => `The practice cases were ${r.passes} pass and ${r.reds} fail; the set needs at least one of each, or it cannot tell a working judge from one that always gives the same answer.` },
454
+ // ── one practice case ──
455
+ { kind: 'case-not-object', when: (r) => r.code === 'calibration-case' && /^calibration\.cases\[\d+\]$/.test(r.path),
456
+ say: (_r, c) => `${c} is not a proper case.` },
457
+ { kind: 'case-name-repeat', when: (r) => r.code === 'calibration-case' && /\.id$/.test(r.path) && /declared twice/.test(r.detail),
458
+ say: (_r, c) => `Two practice cases share the name ${c}.` },
459
+ { kind: 'case-name-bad', when: (r) => r.code === 'calibration-case' && /\.id$/.test(r.path),
460
+ say: (_r, c) => `${c} has no usable name (a short name in lowercase letters, digits and hyphens is needed).` },
461
+ { kind: 'case-code-repeat', when: (r) => r.code === 'calibration-case' && /\.artifact$/.test(r.path) && /used twice/.test(r.detail),
462
+ say: (_r, c) => `${c} reuses the same sample code as another case.` },
463
+ { kind: 'case-code-missing', when: (r) => r.code === 'calibration-case' && /\.artifact$/.test(r.path),
464
+ say: (_r, c) => `${c} has no sample code to look at.` },
465
+ { kind: 'case-impossible-no-doc', when: (r) => r.code === 'calibration-case' && /\.expect\.reds$/.test(r.path) && /expects has-doc red on/.test(r.detail),
466
+ say: (r, c) => {
467
+ const fn = safeText(/expects has-doc red on (\S+?),/.exec(r.detail)?.[1] ?? 'a function');
468
+ return `${c} says "fail: no doc comment" on ${fn}, but ${fn} has a doc comment right above it.`;
469
+ } },
470
+ { kind: 'case-pass-lists-fails', when: (r) => r.code === 'calibration-case' && /\.expect\.reds$/.test(r.path) && /^a PASS case expects no reds/.test(r.detail),
471
+ say: (_r, c) => `${c} is marked as a pass but also lists things that fail.` },
472
+ { kind: 'case-fail-lists-nothing', when: (r) => r.code === 'calibration-case' && /\.expect\.reds$/.test(r.path) && /^a RED case names WHICH/.test(r.detail),
473
+ say: (_r, c) => `${c} is marked as a fail but does not say which rule or function fails.` },
474
+ { kind: 'case-fails-not-list', when: (r) => r.code === 'calibration-case' && /\.expect\.reds$/.test(r.path),
475
+ say: (_r, c) => `${c} does not give its expected failures as a list.` },
476
+ { kind: 'case-no-expected', when: (r) => r.code === 'calibration-case' && /\.expect$/.test(r.path),
477
+ say: (_r, c) => `${c} has no expected result.` },
478
+ { kind: 'case-verdict-bad', when: (r) => r.code === 'calibration-case' && /\.expect\.verdict$/.test(r.path),
479
+ say: (_r, c) => `${c} expects a result that is neither pass nor fail.` },
480
+ { kind: 'case-fail-malformed', when: (r) => r.code === 'calibration-case' && /\.reds\[\d+\]$/.test(r.path) && /^an expected red is/.test(r.detail),
481
+ say: (_r, c) => `${c} has an expected failure that is not written as a rule and a function.` },
482
+ { kind: 'case-fail-repeat', when: (r) => r.code === 'calibration-case' && /\.reds\[\d+\]$/.test(r.path),
483
+ say: (_r, c) => `${c} lists the same failure twice.` },
484
+ { kind: 'case-fail-rule-foreign', when: (r) => r.code === 'calibration-case' && /\.rule$/.test(r.path) && typeof r.rule === 'string',
485
+ say: (r, c) => `${c} expects a failure from the rule "${safeText(r.rule)}", which this rubric does not use.` },
486
+ { kind: 'case-fail-rule-missing', when: (r) => r.code === 'calibration-case' && /\.rule$/.test(r.path),
487
+ say: (_r, c) => `${c} has an expected failure with no rule named.` },
488
+ { kind: 'case-fail-fn-missing', when: (r) => r.code === 'calibration-case' && /\.fn$/.test(r.path),
489
+ say: (_r, c) => `${c} has an expected failure with no function named.` },
490
+ ];
491
+
492
+ /** @param {PlainRed} r the 1-based rubric line a card red lands on @returns {number} */
493
+ function cardLineNo(r) {
494
+ return Number(/^card item (\d+):/.exec(r.detail)?.[1] ?? -1) + 1;
495
+ }
496
+
497
+ /**
498
+ * Every plain kind the table can emit — the list the test enumerates against.
499
+ * @returns {string[]}
500
+ */
501
+ export function plainKinds() {
502
+ return PLAIN_TABLE.map((e) => e.kind);
503
+ }
504
+
505
+ /**
506
+ * Map the technical reds of a refused proposal to plain sentences, one per red.
507
+ * An unknown code is NEVER dropped: it becomes a generic sentence that still
508
+ * quotes the code and path, under the kind `unmapped`.
509
+ * @param {PlainRed[]} reds
510
+ * @param {{cases?: any}} [o] the proposed cases, to name a case by its own id
511
+ * @returns {PlainReason[]}
512
+ */
513
+ export function plainReasons(reds, { cases = null } = {}) {
514
+ return (Array.isArray(reds) ? reds : []).map((red) => {
515
+ const r = /** @type {PlainRed} */ ({ ...red, code: String(red?.code ?? ''), path: String(red?.path ?? ''), detail: String(red?.detail ?? '') });
516
+ const idx = /^calibration\.cases\[(\d+)\]/.exec(r.path)?.[1];
517
+ const given = idx !== undefined && Array.isArray(cases) ? cases[Number(idx)]?.id : undefined;
518
+ const caseName = idx === undefined ? 'A case'
519
+ : typeof given === 'string' && given.trim() !== '' ? `Case "${safeText(given)}"` : `Case ${Number(idx) + 1}`;
520
+ const hit = PLAIN_TABLE.find((e) => e.when(r));
521
+ if (hit) return { kind: hit.kind, sentence: hit.say(r, caseName, null), red: r };
522
+ return {
523
+ kind: 'unmapped',
524
+ sentence: `The model's proposal failed a check that has no plain wording yet ("${safeText(r.code)}" at ${safeText(r.path)}).`,
525
+ red: r,
526
+ };
527
+ });
528
+ }
529
+
530
+ /** @param {number} n @returns {string} the panel's money spelling: 2 decimals, "<$0.01" under a cent */
531
+ function moneyText(n) {
532
+ return n > 0 && n < 0.005 ? '<$0.01' : `$${n.toFixed(2)}`;
533
+ }
534
+
535
+ /**
536
+ * The money clause: the measured figure, "at least" when a call was unpriced,
537
+ * and `null` when there is no honest figure (the caller then says unknown or
538
+ * drops the clause — never a zero).
539
+ * @param {{knownUsd?: number|null, spendComplete?: boolean|null}|null|undefined} spend
540
+ * @returns {string|null}
541
+ */
542
+ function spendText(spend) {
543
+ if (!spend || typeof spend.knownUsd !== 'number' || !Number.isFinite(spend.knownUsd)) return null;
544
+ return `${spend.spendComplete === false ? 'at least ' : ''}${moneyText(spend.knownUsd)}`;
545
+ }
546
+
547
+ /** the stops this module explains in plain words */
548
+ export const PLAIN_PROPOSAL_STOPS = Object.freeze(['proposal-invalid', 'rubric-invalid']);
549
+
550
+ /**
551
+ * THE MESSAGE a person reads when the rubric proposal was refused. Both doors
552
+ * (the panel's failed step and the CLI) print exactly this.
553
+ * @param {{stop: string, reds: PlainRed[], cases?: any, spend?: {knownUsd?: number|null, spendComplete?: boolean|null}|null,
554
+ * source?: 'proposal'|'signer'}} o
555
+ * @returns {string}
556
+ */
557
+ export function proposalStopText({ stop, reds, cases = null, spend = null, source = 'proposal' }) {
558
+ const reasons = plainReasons(reds, { cases });
559
+ const unique = [...new Set(reasons.map((x) => x.sentence))];
560
+ const shown = unique.slice(0, PLAIN_MAX_BULLETS);
561
+ const onlyCases = reasons.length > 0 && reasons.every((x) => x.kind.startsWith('case-') || x.kind.startsWith('set-'));
562
+ const spent = spendText(spend);
563
+ const head = stop === 'rubric-invalid'
564
+ ? 'The rubric and practice cases failed the last check before signing, so nothing was signed.'
565
+ : `The model's ${onlyCases ? 'practice cases for your rubric' : 'rubric and practice cases'} didn't hold up, so we stopped before grading anything.`;
566
+ const lines = [`${head} Spent so far: ${spent ?? 'unknown'}.`, 'What was wrong:'];
567
+ for (const s of shown) lines.push(` • ${s}`);
568
+ if (unique.length > shown.length) lines.push(` • …and ${unique.length - shown.length} more.`);
569
+ lines.push(source === 'signer' ? 'This one is from the edit made before signing.' : 'This is the model\'s mistake, not yours.');
570
+ lines.push(`What to do: draft again${spent ? ` (about ${spent} to reach here)` : ''}.`);
571
+ lines.push('If it fails the same way twice, make your PASS/FAIL examples more concrete.');
572
+ return lines.join('\n');
573
+ }
574
+
575
+ // ── ANY DRAFTING STOP, in plain words (P7 item 6, live session smv2bzdqmp0f8, 2026-10-10) ───────────────────────────
576
+ // A provider-red used to reach the panel as the bare line "Stopped: provider-red" — a code, no reason. Every stop of the
577
+ // drafting loop that is not a rubric-proposal or signing refusal (those have their own wording above and below) now says
578
+ // what happened, what it cost and what to do, in words. The stop's own reds are quoted (scrubbed, bounded), never dropped.
579
+
580
+ /** the reason inside a `scout-absent` red's detail: `the survey is ABSENT (<reason>) — an absent facts object ...` (src/authorflow.js) */
581
+ const SCOUT_REASON_RE = /^the survey is [^(]*\((.*)\) — an absent facts/s;
582
+
583
+ /**
584
+ * @param {{stop: string, reds?: PlainRed[]|null, refusal?: {detail?: string}|null,
585
+ * spend?: {knownUsd?: number|null, spendComplete?: boolean|null}|null}} o
586
+ * @returns {string}
587
+ */
588
+ export function draftStopText({ stop, reds = null, refusal = null, spend = null }) {
589
+ const spent = spendText(spend);
590
+ /** @type {Record<string, string>} */
591
+ const heads = {
592
+ 'provider-red': 'The AI provider failed before a usable plan came back — a connection or service fault, not a problem with your job.',
593
+ 'artifact-red': 'The AI answered, but its answer could not be used as a plan.',
594
+ 'cap-halt': 'Drafting stopped on your cost cap.',
595
+ 'pricing-red': 'Drafting stopped: the cost of a call could not be priced, so the cap cannot be enforced.',
596
+ 'max-revisions': 'The AI used all its tries without a plan that passed the checks.',
597
+ precheck: 'Drafting stopped before any call: something it needs was missing.',
598
+ unchanged: 'Drafting stopped: the AI sent the same plan twice.',
599
+ 'scout-absent': 'Drafting stopped: the first look at your repo did not complete.',
600
+ };
601
+ const redList = Array.isArray(reds) ? reds : [];
602
+ // F217: the first look at the repo failing (a dropped connection, a timeout) arrives as a `precheck` stop with a
603
+ // `scout-absent` red. That is NOT "something it needs was missing", and its reason sits past the 60-char quote bound
604
+ // behind a fixed lead-in, so the person saw neither. Word it as the scout's stop and quote the reason in full (scrubbed).
605
+ const scoutRed = stop === 'precheck' ? redList.find((r) => r?.code === 'scout-absent') : undefined;
606
+ const head = scoutRed ? heads['scout-absent'] : (heads[stop] ?? `Drafting stopped (${safeText(stop)}).`);
607
+ /** @type {string[]} */
608
+ const said = scoutRed
609
+ ? [SCOUT_REASON_RE.exec(String(scoutRed.detail ?? ''))?.[1]
610
+ ?.replace(/\s+/g, ' ').trim().slice(0, 240) ?? safeText(scoutRed.detail)].map((x) => redactSecrets(x)).filter(Boolean)
611
+ : [...new Set(redList.map((r) => safeText(r?.detail)).filter(Boolean))];
612
+ if (said.length === 0 && refusal?.detail) said.push(safeText(refusal.detail));
613
+ const lines = [`${head} Spent so far: ${spent ?? 'unknown'}.`];
614
+ if (said.length) {
615
+ lines.push('What it said:');
616
+ for (const x of said.slice(0, PLAIN_MAX_BULLETS)) lines.push(` • ${x}`);
617
+ if (said.length > PLAIN_MAX_BULLETS) lines.push(` • …and ${said.length - PLAIN_MAX_BULLETS} more.`);
618
+ }
619
+ lines.push(stop === 'cap-halt' || stop === 'pricing-red' ? 'What to do: raise the cap or fix the price in Settings, then draft again.' : 'What to do: draft again.');
620
+ return lines.join('\n');
621
+ }
622
+
623
+ // ── THE SIGNING GATES' REFUSAL, in plain words (hamr, 2026-10-09) ──────────────────────────
624
+ //
625
+ // `prepareSigning` (src/authorjob.js) can refuse with a list of reds from four gates. The panel used to say
626
+ // `signing gates failed — calibration-miswrite, calibration-miswrite, calibration-miswrite, injection-leak`.
627
+ // Same principle as the proposal text above ("a user should get a reason that they can understand and do
628
+ // something about"): one owner, written by code, the structured reds travel beside it in the drafting log.
629
+ //
630
+ // WHOSE FAULT. A calibration-miswrite is NOT claimed to be the model's mistake: the practice case was written by
631
+ // the model, but the check can be the one that is wrong (live, 2026-10-09: two of three misses were our rule).
632
+ // The text says the practice case and the check DISAGREE, and leaves it at that.
633
+ //
634
+ // EVERY RED CODE REACHABLE AT THE REFUSAL, enumerated from source:
635
+ // gate 1a/1b (the declaration; validateJob + validateCloseDecl): DECLARATION_CODES below, plus seed-unreadable
636
+ // and listing-unreadable (authorjob.js)
637
+ // gate 2 (precheck): broken-close
638
+ // gate 4 (calibration; calibrate.js + authorjob.js calibrationGate): calibration-missing, no-judge-seam,
639
+ // cap-halt, pricing-red, provider-red, artifact-red (the casualty axes), calibration-miswrite,
640
+ // injection-leak, and the invalid-set reds (invalid-value at `card`, calibration-case / -size / -polarity /
641
+ // -cardless / -orphan) which the proposal table above already words
642
+ // gate 3 refuses with NO red at all (refusal only: the close already passes at the seed)
643
+ // An unknown code is never dropped: it becomes a generic sentence that quotes the code.
644
+
645
+ /** codes the declaration gates (validateJob, validateCloseDecl) raise: one plain sentence covers the family */
646
+ export const DECLARATION_CODES = Object.freeze([
647
+ 'bounds', 'class-absent', 'class-battery-locked', 'class-ceiling', 'cmd-denied', 'duplicate-kind', 'duplicate-name',
648
+ 'env-ownership-absent', 'genre-env-missing', 'genre-env-ungrounded', 'genre-owned-env', 'guard-missing',
649
+ 'guard-weakened', 'guards-absent', 'human-stage-not-last', 'human-stage-offered', 'invalid-value', 'job-line-uncovered', 'judged-outside-write-scope', 'judged-stage-order',
650
+ 'listing-absent', 'listing-conflict', 'locked-kind', 'missing-field', 'missing-required', 'one-population',
651
+ 'path-not-in-listing', 'secret-literal', 'unknown-field', 'unknown-kind',
652
+ ]);
653
+
654
+ /** the rulebook rules in the words of a person: what the rule flags */
655
+ const RULE_FLAGS = Object.freeze({
656
+ 'has-doc': 'there is no doc comment',
657
+ 'says-what': 'the doc only repeats the function\'s name',
658
+ params: 'a parameter is missing from the doc, or the doc names one that does not exist',
659
+ returns: 'the return value is not documented',
660
+ });
661
+
662
+ /** @param {unknown} rule @returns {string} */
663
+ const ruleFlag = (rule) => (typeof rule === 'string' && Object.hasOwn(RULE_FLAGS, rule)
664
+ ? /** @type {Record<string,string>} */ (RULE_FLAGS)[rule] : `the rule "${safeText(rule)}" applies`);
665
+
666
+ /** "says-what · initCache; params · initCache" to [{rule, fn}] @param {string} list */
667
+ const ruleFns = (list) => list.split(';').map((x) => x.trim()).filter(Boolean).map((x) => {
668
+ const [rule, fn] = x.split('·').map((t) => t.trim());
669
+ return { rule, fn: fn ?? '' };
670
+ });
671
+
672
+ /**
673
+ * A calibration-miswrite red as plain sentences: what the practice case expected against what the check found,
674
+ * read off the red's own detail (the compareExpectation pieces calibrate.js writes).
675
+ * @param {PlainRed} r @returns {string[]}
676
+ */
677
+ function miswriteSentences(r) {
678
+ const who = `Case "${safeText(r.caseId ?? /^case "(.*?)" was graded/.exec(r.detail)?.[1] ?? '')}"`;
679
+ const missing = /expected red\(s\) the pipe did not raise: (.*?)(?: — |\. The floor)/.exec(r.detail)?.[1];
680
+ const extra = /red\(s\) the pipe raised that nobody signed: (.*?)(?: — |\. The floor)/.exec(r.detail)?.[1];
681
+ /** @type {string[]} */
682
+ const out = [];
683
+ for (const { rule, fn } of missing ? ruleFns(missing) : []) {
684
+ out.push(`${who}${fn ? ` (${safeText(fn)})` : ''}: the practice case says ${ruleFlag(rule)}, but the check passed it.`);
685
+ }
686
+ for (const { rule, fn } of extra ? ruleFns(extra) : []) {
687
+ out.push(`${who}${fn ? ` (${safeText(fn)})` : ''}: the check says ${ruleFlag(rule)}, but the practice case did not expect that.`);
688
+ }
689
+ if (out.length === 0) {
690
+ const m = /signed "(\w+)", the pipe rendered "(\w+)"/.exec(r.detail);
691
+ out.push(m
692
+ ? `${who}: the practice case says ${m[1] === 'red' ? 'fail' : 'pass'}, but the check said ${m[2] === 'red' ? 'fail' : 'pass'}.`
693
+ : `${who}: the practice case and the check disagree.`);
694
+ }
695
+ return out;
696
+ }
697
+
698
+ /**
699
+ * @type {{kind: string, when: (r: PlainRed) => boolean, say: (r: PlainRed) => string[], todo: string}[]}
700
+ * Order matters; the first match wins. `todo` is the one thing the person can do.
701
+ */
702
+ const SIGNING_TABLE = [
703
+ { kind: 'miswrite', when: (r) => r.code === 'calibration-miswrite', say: miswriteSentences, todo: 'draft again' },
704
+ { kind: 'injection-leak', when: (r) => r.code === 'injection-leak',
705
+ say: (r) => [`A test file tried to trick the judge ("${safeText(r.style ?? '')}"), and what the judge pointed at changed, so the judge could not be shown to resist it.`],
706
+ todo: 'draft again' },
707
+ { kind: 'calibration-missing', when: (r) => r.code === 'calibration-missing',
708
+ say: () => ['The rubric came without practice cases, so the check was never tried out.'], todo: 'draft again' },
709
+ { kind: 'no-judge', when: (r) => r.code === 'no-judge-seam',
710
+ say: () => ['No judge model was set up to try out the rubric, so it was never graded.'], todo: 'check the judge model and its key in Settings, then try again' },
711
+ { kind: 'cap-halt', when: (r) => r.code === 'cap-halt',
712
+ say: () => ['The spending cap ran out while the practice cases were being graded. Nothing says the rubric is wrong.'], todo: 'raise the cap, then draft again' },
713
+ { kind: 'pricing-red', when: (r) => r.code === 'pricing-red',
714
+ say: () => ['A model call could not be priced, so the cap could not be applied; bareloop stopped rather than spend blind.'], todo: 'set the price for this model in Settings, then try again' },
715
+ { kind: 'provider-red', when: (r) => r.code === 'provider-red',
716
+ say: () => ['The judge model could not be reached or was cut off while the practice cases were graded. Nothing says the rubric is wrong.'], todo: 'try again in a few minutes' },
717
+ { kind: 'artifact-red', when: (r) => r.code === 'artifact-red',
718
+ say: () => ['The judge model answered with something bareloop could not read while the practice cases were graded. Nothing says the rubric is wrong.'], todo: 'try again' },
719
+ { kind: 'broken-close', when: (r) => r.code === 'broken-close',
720
+ say: (r) => [`A check of the close (${safeText(r.path.split('.').pop() ?? '')}) cannot run on this repository, so nothing it reported could be trusted.`],
721
+ todo: 'check that the project\'s tools (its install, build and test commands) run, then try again' },
722
+ { kind: 'seed-unreadable', when: (r) => r.code === 'seed-unreadable',
723
+ say: () => ['bareloop could not read the repository\'s starting commit.'], todo: 'check that the source is a git repository with at least one commit' },
724
+ { kind: 'listing-unreadable', when: (r) => r.code === 'listing-unreadable',
725
+ say: () => ['bareloop could not list the files of the repository\'s starting commit.'], todo: 'check that the source is a git repository with at least one commit' },
726
+ { kind: 'judged-outside-fence', when: (r) => r.code === 'judged-outside-write-scope',
727
+ say: (r) => [`The rubric check looks at ${listText(r.outside)}, but the job may only edit ${listText(r.fence)} — so the run could never fix what it finds.`],
728
+ todo: 'draft again' },
729
+ { kind: 'declaration', when: (r) => DECLARATION_CODES.includes(r.code) && !(r.code === 'invalid-value' && r.path === 'card'),
730
+ say: (r) => [`The close the model wrote was refused before anything ran ("${safeText(r.code)}" at ${safeText(r.path)}).`], todo: 'draft again' },
731
+ ];
732
+
733
+ /** a red's string list as plain text, each entry scrubbed and bounded
734
+ * @param {unknown} v @returns {string} */
735
+ const listText = (v) => {
736
+ const items = (Array.isArray(v) ? v : []).filter((x) => typeof x === 'string' && x !== '').slice(0, 4).map((x) => safeText(x));
737
+ return items.length ? items.join(', ') : 'files outside the edit area';
738
+ };
739
+
740
+ /** the sentence when the gates refuse with no red at all (the close already passes on the untouched repository) */
741
+ const SEED_GREEN_SENTENCE = 'Nothing in the close fails on the repository as it is, so there is nothing for a run to do.';
742
+
743
+ /**
744
+ * Plain sentences for the reds of a refused signing, one or more per red. Card and practice-case reds reuse the
745
+ * proposal table; an unknown code is a generic sentence that quotes the code.
746
+ * @param {PlainRed[]} reds @param {{cases?: any}} [o]
747
+ * @returns {{kind: string, sentence: string, todo: string, red: PlainRed}[]}
748
+ */
749
+ export function signingReasons(reds, { cases = null } = {}) {
750
+ /** @type {{kind: string, sentence: string, todo: string, red: PlainRed}[]} */
751
+ const out = [];
752
+ for (const red of Array.isArray(reds) ? reds : []) {
753
+ const r = /** @type {PlainRed} */ ({ ...red, code: String(red?.code ?? ''), path: String(red?.path ?? ''), detail: String(red?.detail ?? '') });
754
+ const hit = SIGNING_TABLE.find((e) => e.when(r));
755
+ if (hit) { for (const sentence of hit.say(r)) out.push({ kind: hit.kind, sentence, todo: hit.todo, red: r }); continue; }
756
+ const [pr] = plainReasons([r], { cases });
757
+ if (pr && pr.kind !== 'unmapped') { out.push({ kind: pr.kind, sentence: pr.sentence, todo: 'draft again', red: r }); continue; }
758
+ out.push({
759
+ kind: 'unmapped',
760
+ sentence: `A check before signing failed and has no plain wording yet ("${safeText(r.code)}" at ${safeText(r.path)}).`,
761
+ todo: 'draft again; if it fails the same way, report the code above',
762
+ red: r,
763
+ });
764
+ }
765
+ return out;
766
+ }
767
+
768
+ /** every plain kind `signingReasons` can emit besides the proposal table's — what the test enumerates
769
+ * @returns {string[]} */
770
+ export function signingKinds() {
771
+ return [...SIGNING_TABLE.map((e) => e.kind), 'seed-green', 'unmapped'];
772
+ }
773
+
774
+ /**
775
+ * THE MESSAGE a person reads when the signing gates refuse. The panel's failed step and the CLI print exactly this.
776
+ * @param {{reds: PlainRed[], refusal?: {detail?: string}|null, cases?: any,
777
+ * spend?: {knownUsd?: number|null, spendComplete?: boolean|null}|null}} o
778
+ * @returns {string}
779
+ */
780
+ export function signingStopText({ reds, refusal = null, cases = null, spend = null }) {
781
+ const reasons = signingReasons(reds, { cases });
782
+ if (reasons.length === 0) {
783
+ reasons.push({ kind: 'seed-green', sentence: SEED_GREEN_SENTENCE, todo: 'check that the close measures the right thing, or describe a sharper problem',
784
+ red: { code: '', path: '', detail: String(refusal?.detail ?? '') } });
785
+ }
786
+ const unique = [...new Set(reasons.map((x) => x.sentence))];
787
+ const shown = unique.slice(0, PLAIN_MAX_BULLETS);
788
+ const spent = spendText(spend);
789
+ const lines = [`The checks before signing did not all pass, so nothing was signed. Spent so far: ${spent ?? 'unknown'}.`, 'What was wrong:'];
790
+ for (const s of shown) lines.push(` • ${s}`);
791
+ if (unique.length > shown.length) lines.push(` • …and ${unique.length - shown.length} more.`);
792
+ if (reasons.some((x) => x.kind === 'miswrite' || x.kind === 'injection-leak')) {
793
+ lines.push('The practice cases and the check disagree; either one can be the one that is wrong, so this is not necessarily your doing or the model\'s.');
794
+ }
795
+ lines.push(`What to do: ${reasons[0].todo}${reasons[0].todo === 'draft again' && spent ? ` (about ${spent} to reach here)` : ''}.`);
796
+ return lines.join('\n');
797
+ }
798
+
799
+ /**
800
+ * The reds as the drafting log keeps them: every structured field, the plain
801
+ * sentence beside it, every string secret-scrubbed and bounded, the list capped.
802
+ * @param {PlainRed[]} reds @param {{cases?: any, signing?: boolean}} [o] `signing`: the reds are the signing gates', worded by signingReasons
803
+ * @returns {Record<string, any>[]}
804
+ */
805
+ export function redsRecord(reds, { cases = null, signing = false } = {}) {
806
+ /** @param {unknown} s */
807
+ const cut = (s) => { const t = redactSecrets(String(s ?? '')); return t.length > RECORD_STR_MAX ? `${t.slice(0, RECORD_STR_MAX)}…` : t; };
808
+ return (signing ? signingReasons(reds, { cases }) : plainReasons(reds, { cases })).slice(0, RECORD_MAX_REDS).map(({ kind, sentence, red }) => {
809
+ /** @type {Record<string, any>} */
810
+ const out = { kind, plain: sentence };
811
+ for (const [k, v] of Object.entries(red)) {
812
+ if (typeof v === 'string') out[k] = cut(v);
813
+ else if (typeof v === 'number' || typeof v === 'boolean' || v === null) out[k] = v;
814
+ else if (Array.isArray(v)) out[k] = v.slice(0, 10).map((x) => (typeof x === 'string' ? cut(x) : x));
815
+ }
816
+ return out;
817
+ });
818
+ }
package/src/authorrun.js CHANGED
@@ -128,7 +128,7 @@ import { readSourceManifest, missingDependencies, nonRepoSourceMessage } from '.
128
128
  import { tallyCalls } from './text.js';
129
129
  import {
130
130
  declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
131
- openQuestionLines, answeredQuestionLines, fellBackLines,
131
+ openQuestionLines, answeredQuestionLines, fellBackLines, PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText,
132
132
  } from './authorreadout.js';
133
133
 
134
134
  /** Thrown to unwind `main` to an exit code without ever calling
@@ -570,7 +570,7 @@ export async function main(argv, deps = {}) {
570
570
  out(` ${ceilingLine(CEILING_USD)}`);
571
571
  out(' stops at prepareSigning — this script NEVER signs and NEVER runs the job\n');
572
572
 
573
- // F190 (docs/logs/FINDINGS.md) — this used to be a hand-rolled `makeProvider`
573
+ // F190 (docs/wiki/FINDINGS.md) — this used to be a hand-rolled `makeProvider`
574
574
  // call, a duplicate of the SAME construction `buildRunnerProviders` already
575
575
  // owns for `src/cli.js`. Routed through the one owner now: the judge half of
576
576
  // this call is a THROWAWAY (`judgeProviderName`/`judgeModel`/`judgeBaseUrl` all
@@ -818,7 +818,7 @@ export async function main(argv, deps = {}) {
818
818
  // piece 4, step S6, D5 amended 2026-09-21) ──────────────────────────────────
819
819
  // This USED TO run the whole confirm turn first (over a $0 manual listing,
820
820
  // via `runConfirmTurn`), THEN give this stop once the person confirmed a plan
821
- // — D5's original shape. Live run `mu4hc7sp` (docs/logs/FINDINGS.md F191)
821
+ // — D5's original shape. Live run `mu4hc7sp` (docs/wiki/FINDINGS.md F191)
822
822
  // crashed inside that confirm turn instead: `confirmProtections`
823
823
  // (`src/authorflow.js`) calls `classGuards` (`src/authoring.js`), which is
824
824
  // keyed by CODE LANGUAGE and THROWS on a plain folder's `lang:
@@ -879,6 +879,9 @@ export async function main(argv, deps = {}) {
879
879
  // fence is not this script's business to invent one for, so an absent or
880
880
  // malformed field travels as `null` and `authorPrompt` simply states nothing.
881
881
  writeScope: Array.isArray(draft.writeScope) ? draft.writeScope : null,
882
+ // P7: the person's numbered job lines and inputs ride the draft; the drafter tags every stage with a line
883
+ jobLines: Array.isArray(draft.jobLines) ? draft.jobLines : null,
884
+ inputs: Array.isArray(draft.inputs) ? draft.inputs : null,
882
885
  provider,
883
886
  generate: makeLoopGenerate(provider, { rates: authorPrice?.rates ?? null }),
884
887
  rates: authorPrice?.rates ?? null,
@@ -978,6 +981,15 @@ export async function main(argv, deps = {}) {
978
981
  if (r.red) out(` red: ${JSON.stringify(r.red)}`);
979
982
  for (const e of refusalEvents(r)) emit(e.type, e);
980
983
  }
984
+ // a refused rubric proposal reads in plain words FIRST (the same text the panel shows), the technical reds after
985
+ if (PLAIN_PROPOSAL_STOPS.includes(String(authored.stop))) {
986
+ out(`\n${proposalStopText({
987
+ stop: String(authored.stop), reds: authored.reds ?? [],
988
+ cases: authored.judged?.signed?.cases ?? authored.judged?.proposal?.proposal?.cases ?? null,
989
+ spend: authored.cost ? { knownUsd: authored.cost.knownUsd ?? null, spendComplete: authored.cost.spendComplete ?? null } : null,
990
+ source: authored.judged?.source === 'signer' ? 'signer' : 'proposal',
991
+ })}`);
992
+ }
981
993
  for (const red of authored.reds ?? []) {
982
994
  out(`\nRED ${red.code} at ${red.path}\n${red.detail}`);
983
995
  emit('job-red', red);
@@ -1181,16 +1193,16 @@ export async function main(argv, deps = {}) {
1181
1193
  : (g.seedVerdict.ok
1182
1194
  ? `PASS — work red at seed: ${g.seedVerdict.workRed.join(', ')}`
1183
1195
  : (g.seedVerdict.satisfiedBy === 'calibration'
1184
- ? 'PASS via the CALIBRATION gate — this close\'s only work stage is judged'
1196
+ ? 'PASS via the CALIBRATION gate — this close carries a judged stage'
1185
1197
  : 'FAIL — no work stage is red at the seed'))}`);
1186
1198
  if (g.seedVerdict) {
1187
1199
  out(` red at seed: ${g.seedVerdict.redAtSeed.join(', ') || '(none)'}`);
1188
1200
  out(` green at seed: ${g.seedVerdict.greenAtSeed.join(', ') || '(none)'}`);
1189
- // ruling 3: a judged-ONLY close clears gate 3 on its calibration instead,
1201
+ // ruling 3 (widened 2026-10-09): a close with a judged stage clears gate 3 on its calibration instead,
1190
1202
  // and the surface SAYS which proof carried it rather than leaving a reader
1191
1203
  // to wonder why an empty workRed list passed
1192
1204
  if (g.seedVerdict.satisfiedBy === 'calibration') {
1193
- out(' a judged stage skips the seed read (ruling 8), so this close\'s proof that it CAN fail is');
1205
+ out(' a judged stage skips the seed read (ruling 8), so this close\'s proof that its judged stage CAN fail is');
1194
1206
  out(' the graded calibration set below — signed cases it must red as well as ones it must pass.');
1195
1207
  }
1196
1208
  }
@@ -1207,6 +1219,14 @@ export async function main(argv, deps = {}) {
1207
1219
  if ((signing.guards ?? []).length) { out('\nguard stages at the seed'); for (const r of signing.guards) row(r); }
1208
1220
  if ((signing.stops ?? []).length) { out('\nstages that could NOT RUN (a broken instrument is a casualty, never a verdict)'); for (const r of signing.stops) row(r); }
1209
1221
 
1222
+ // a refused signing reads in plain words FIRST (the same text the panel shows), the technical reds after
1223
+ if (!signing.ok) {
1224
+ const t = costSoFar();
1225
+ out(`\n${signingStopText({
1226
+ reds: signing.reds ?? [], refusal: signing.refusal,
1227
+ spend: { knownUsd: t.knownUsd ?? null, spendComplete: t.spendComplete ?? null },
1228
+ })}`);
1229
+ }
1210
1230
  if (signing.refusal) {
1211
1231
  const r = signing.refusal;
1212
1232
  out(`\nREFUSED (${r.kind}) verb=${r.verb ?? 'none'} path=${r.path}`);