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
package/src/authoring.js CHANGED
@@ -516,6 +516,32 @@ export const TYPES_GENRE_TEMPLATE = `1. The STRICT form of the language's type c
516
516
 
517
517
  export const GENRE_LANGUAGES = Object.freeze(['js', 'python']);
518
518
 
519
+ /**
520
+ * The js `any` suppression pattern's regex, ONE spelling: the TYPES js battery
521
+ * below composes it into the signed guard, and `anySuppressionWarning` keys on
522
+ * it, so the warning and the guard cannot drift. The guard scans every ADDED
523
+ * line, comments included, so plain prose like ", any" trips it.
524
+ */
525
+ const JS_ANY_REGEX = '(?:[{<|(\\[:,]|\\bas)\\s*any\\b|\\bany\\[\\]';
526
+
527
+ /** The worker-facing line that rides with the js `any` guard (run mv13ery3). */
528
+ export const ANY_SUPPRESSION_WARNING = '\nThe no-suppressions check also reads comments: never write the word any right after a comma, colon, bracket, brace, < or | '
529
+ + "(e.g. ', any', ': any', '(any'), even in plain English — write 'every', start the clause differently, or put it in backticks.";
530
+
531
+ /**
532
+ * The warning line for a job whose ACTUAL stages carry the js `any` pattern, else
533
+ * ''. Derived from the stages' patterns (never goal prose), so a job without the
534
+ * guard is told nothing about it.
535
+ * @param {any[]|null|undefined} stages
536
+ * @returns {string}
537
+ */
538
+ export function anySuppressionWarning(stages) {
539
+ const has = Array.isArray(stages) && stages.some((s) => isObj(s) && s.kind === 'pattern-absent-in-diff'
540
+ && isObj(s.params) && Array.isArray(s.params.patterns)
541
+ && s.params.patterns.some((/** @type {any} */ q) => isObj(q) && q.regex === JS_ANY_REGEX));
542
+ return has ? ANY_SUPPRESSION_WARNING : '';
543
+ }
544
+
519
545
  /**
520
546
  * THE GENRE, as data. The suppression batteries are the hand-written closes'
521
547
  * own `SUPPRESSIONS` tables (`scripts/u-pulselog-close.mjs:35`,
@@ -541,7 +567,7 @@ export const TYPES_GENRE = Object.freeze({
541
567
  Object.freeze({ id: 'ts-expect-error', regex: '@ts-expect-error' }),
542
568
  Object.freeze({ id: 'ts-nocheck', regex: '@ts-nocheck' }),
543
569
  Object.freeze({ id: 'eslint-disable', regex: 'eslint-disable' }),
544
- Object.freeze({ id: 'any', regex: '(?:[{<|(\\[:,]|\\bas)\\s*any\\b|\\bany\\[\\]' }),
570
+ Object.freeze({ id: 'any', regex: JS_ANY_REGEX }),
545
571
  Object.freeze({ id: 'any-star', regex: '@\\w+\\s*\\{\\s*[*?]\\s*\\}' }),
546
572
  Object.freeze({ id: 'cast', regex: '@type\\s*\\{.*\\}\\s*\\*\\/\\s*\\(' }),
547
573
  ]),
@@ -1057,6 +1083,25 @@ function scopeOfJob(declaration, idx, guards) {
1057
1083
  return { scoped: false, via: null };
1058
1084
  }
1059
1085
 
1086
+ /**
1087
+ * Which of a judged stage's paths lie OUTSIDE the job's write fence? One owner of the rule "a check must be
1088
+ * free to edit every file it can report on", called from every door that validates a declaration. It uses
1089
+ * the shipped fence spelling (`globToPrefix`, the same containment reading plan.js's `insideFence` has) and
1090
+ * returns `[]` when there is no fence to compare against or the paths are not a string list (shape is the
1091
+ * schema walk's business).
1092
+ * @param {unknown} paths @param {string[]|null|undefined} writeScope
1093
+ * @returns {string[]}
1094
+ */
1095
+ export function judgedOutsideFence(paths, writeScope) {
1096
+ if (!Array.isArray(writeScope) || writeScope.length === 0 || !Array.isArray(paths)) return [];
1097
+ const fence = writeScope.filter(isNonEmptyString).map(globToPrefix);
1098
+ if (fence.length === 0) return [];
1099
+ return paths.filter(isNonEmptyString).filter((raw) => {
1100
+ const n = normPrefix(raw);
1101
+ return !fence.some((f) => f === '.' || n === f || n.startsWith(`${f}/`));
1102
+ });
1103
+ }
1104
+
1060
1105
  /**
1061
1106
  * Kinds a declaration may carry AT MOST ONCE, as data beside the catalogue.
1062
1107
  *
@@ -1154,7 +1199,12 @@ function readPath(p, dotted) {
1154
1199
  * @param {{catalogue?: Record<string, KindSpec>, listing?: string[]|null,
1155
1200
  * guards?: {name: string, kind: string, params: Record<string, any>, fill: string[]}[]|null,
1156
1201
  * envOwned?: string[]|null, envInjected?: Record<string, string>|null, deferListing?: boolean,
1157
- * verdictType?: string|null}} [opts]
1202
+ * verdictType?: string|null, writeScope?: string[]|null, jobLines?: {n: number}[]|null}} [opts]
1203
+ * `jobLines` — the person's numbered job lines (P7). Present, every stage must name the line it serves and every
1204
+ * line needs a stage or a refusal ({@link checkJobLines}); absent (every spec that predates the job block), the
1205
+ * rule is not run.
1206
+ * `writeScope` — the job's signed write fence. Every path a judged stage judges must sit inside it
1207
+ * ({@link judgedOutsideFence}); absent, the rule is not run (the plain-folder deferral has no fence).
1158
1208
  * `listing` — repo-relative paths at the seed (`git ls-tree -r --name-only`).
1159
1209
  * `guards` — the picked class's injected guards (`classGuards`).
1160
1210
  * `verdictType` — the class the USER picked (`VERDICT_CLASSES`).
@@ -1171,7 +1221,7 @@ function readPath(p, dotted) {
1171
1221
  export function validateDeclaration(declaration, opts = {}) {
1172
1222
  const {
1173
1223
  catalogue = KIND_CATALOGUE, listing = null, guards = null, envOwned = null, envInjected = null,
1174
- verdictType = null,
1224
+ verdictType = null, writeScope = null, jobLines = null,
1175
1225
  } = opts;
1176
1226
  const deferListing = opts.deferListing === true;
1177
1227
  /** @type {Red[]} */
@@ -1314,6 +1364,18 @@ export function validateDeclaration(declaration, opts = {}) {
1314
1364
  } else kindOwners.set(s.kind, label);
1315
1365
  }
1316
1366
 
1367
+ // A judged stage may only judge what the run can fix: a path outside the write fence is a
1368
+ // finding the worker can never act on (run mv13ery3: bin/pulselog.js, fence src/**).
1369
+ if (s.kind === JUDGED_FLOOR_KIND && isObj(s.params)) {
1370
+ const outside = judgedOutsideFence(s.params.paths, writeScope);
1371
+ if (outside.length > 0) {
1372
+ red('judged-outside-write-scope', `${at}.params.paths`, `stage "${label}" judges ${outside.join(', ')}, but the `
1373
+ + `job may only edit ${writeScope?.join(', ')}. A check that reports on a file the run is fenced out of can `
1374
+ + 'never be satisfied — every finding on it is one the worker cannot fix. Name files inside the write fence',
1375
+ { stage: label, outside, fence: (writeScope ?? []).map(globToPrefix) });
1376
+ }
1377
+ }
1378
+
1317
1379
  // ── RULING 5, AS LAW: judged and human stages are `offer: false`, and the
1318
1380
  // law is checked rather than applied. `checkMenu` filters `offer !== false`,
1319
1381
  // so a declaration that says nothing is silent about a rule the runner
@@ -1384,11 +1446,71 @@ export function validateDeclaration(declaration, opts = {}) {
1384
1446
  });
1385
1447
 
1386
1448
  if (haveGuards) checkGuards({ declaration, guards: /** @type {any[]} */ (guards), red });
1449
+ checkJobLines({ declaration, stages, jobLines, guards: haveGuards ? /** @type {any[]} */ (guards) : [], red });
1387
1450
 
1388
1451
  const ok = reds.length === 0;
1389
1452
  return { ok, reds, declaration: ok ? normalizeDeclaration(declaration) : null, grounded: haveListing, scoped, ceiling };
1390
1453
  }
1391
1454
 
1455
+ /**
1456
+ * P7 rules 1-3 of the job block, at $0, in the gate that can still revise: every `fromLine` is a non-empty, duplicate-free array of real job line numbers (one check may serve several lines);
1457
+ * every numbered job line has at least one stage OR a refusal reason in `refused` (a note in `notes` does NOT cover a
1458
+ * line); the mandatory guards serve no line and carry no `fromLine`. The SHAPE of `fromLine` / `refused` is checked
1459
+ * whenever they appear; the coverage half runs only when the person's `jobLines` were handed in.
1460
+ * @param {{declaration: any, stages: any[], jobLines: {n: number}[]|null|undefined,
1461
+ * guards: {name: string}[], red: (c: string, p: string, d: string, e?: object) => void}} o
1462
+ */
1463
+ function checkJobLines({ declaration, stages, jobLines, guards, red }) {
1464
+ const known = Array.isArray(jobLines) && jobLines.length > 0 ? jobLines.map((l) => l.n) : null;
1465
+ const guardNames = new Set(guards.map((g) => g.name));
1466
+ /** @type {Set<number>} */
1467
+ const served = new Set();
1468
+ stages.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
1469
+ if (!isObj(s)) return;
1470
+ const at = `stages[${i}].fromLine`;
1471
+ const label = isNonEmptyString(s.name) ? s.name : `stages[${i}]`;
1472
+ const guard = isNonEmptyString(s.name) && guardNames.has(s.name);
1473
+ if (Object.hasOwn(s, 'fromLine')) {
1474
+ if (guard) {
1475
+ red('invalid-value', at, `stage "${label}" is a mandatory guard and serves no job line — remove its fromLine`);
1476
+ } else if (!Array.isArray(s.fromLine) || s.fromLine.length === 0 || !s.fromLine.every((/** @type {any} */ x) => Number.isInteger(x) && x >= 1)) {
1477
+ red('invalid-value', at, `stage "${label}" has fromLine ${JSON.stringify(s.fromLine)} — a non-empty array of job line numbers, e.g. [1] or [1, 3]`);
1478
+ } else if (new Set(s.fromLine).size !== s.fromLine.length) {
1479
+ red('invalid-value', at, `stage "${label}" lists a job line twice in fromLine ${JSON.stringify(s.fromLine)}`);
1480
+ } else if (known !== null && s.fromLine.some((/** @type {number} */ x) => !known.includes(x))) {
1481
+ red('invalid-value', at, `stage "${label}" has fromLine ${JSON.stringify(s.fromLine)}, but the job has lines ${known.join(', ')} only`);
1482
+ } else for (const x of s.fromLine) served.add(x);
1483
+ } else if (known !== null && !guard) {
1484
+ red('missing-field', at, `stage "${label}" does not say which job lines it serves — give it fromLine: [N, ...] (${known.join(', ')}); `
1485
+ + 'only a mandatory guard carries none');
1486
+ }
1487
+ });
1488
+ /** @type {Set<number>} */
1489
+ const refused = new Set();
1490
+ if (declaration.refused !== undefined) {
1491
+ if (!Array.isArray(declaration.refused)) {
1492
+ red('invalid-value', 'refused', 'an array of {line, reason} — the job lines no stage can check, each with a reason');
1493
+ } else {
1494
+ declaration.refused.forEach((/** @type {any} */ r, /** @type {number} */ j) => {
1495
+ const at = `refused[${j}]`;
1496
+ if (!isObj(r) || !Number.isInteger(r.line) || r.line < 1 || !isNonEmptyString(r.reason)) {
1497
+ red('invalid-value', at, '{line, reason}: a job line number and a plain-words reason');
1498
+ } else if (known !== null && !known.includes(r.line)) {
1499
+ red('invalid-value', `${at}.line`, `refused line ${r.line}, but the job has lines ${known.join(', ')} only`);
1500
+ } else refused.add(r.line);
1501
+ });
1502
+ }
1503
+ }
1504
+ if (known !== null) {
1505
+ for (const n of known) {
1506
+ if (!served.has(n) && !refused.has(n)) {
1507
+ red('job-line-uncovered', 'stages', `job line ${n} has no stage and no refusal. Give it a stage whose fromLine lists ${n}, or list `
1508
+ + `it in "refused" as {"line": ${n}, "reason": "..."} — a note does not cover a line`, { line: n });
1509
+ }
1510
+ }
1511
+ }
1512
+ }
1513
+
1392
1514
  /**
1393
1515
  * COMPLETENESS — the deletion half of the owned-env rule, and the half a
1394
1516
  * by-VALUE comparison structurally cannot cover.
package/src/authorjob.js CHANGED
@@ -99,7 +99,7 @@
99
99
  // in the stored declaration (forward-compat point 3), and the suite pins it.
100
100
 
101
101
  import { jobSpecHash, validateJob } from './job.js';
102
- import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees, SEED_EXEMPT_KINDS, judgedStages, closeJudges } from './kinds.js';
102
+ import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees, judgedStages, closeJudges } from './kinds.js';
103
103
  // SOFTGREEN modules 4 and 5 — the compile the interview's Q6/Q7 answers land in,
104
104
  // and the gate that decides whether the ruler they describe is a ruler.
105
105
  import { proposeJudgedArtifacts, signJudgedArtifacts, foldJudgedArtifacts } from './cardauthor.js';
@@ -113,6 +113,7 @@ import {
113
113
  QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose, makeCostBook,
114
114
  runConfirmTurn, LANGUAGE_PICK_FIELD,
115
115
  } from './authorflow.js';
116
+ import { draftCallOptions, assertDraftThinking } from './providers.js';
116
117
  import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
117
118
  import {
118
119
  DECLARED_GAP_PREFIX, DECLARED_GENRES, guardNames, isDeclaredClose, validateCloseDecl,
@@ -416,16 +417,19 @@ function composerRefusal(reds) {
416
417
  * one-genre-at-a-time law is untouched.
417
418
  *
418
419
  * @param {{answers: Record<string|number, any>, repoPath?: string|null, lang: string,
420
+ * jobLines?: {n: number, text: string}[]|null, inputs?: {n: number, label: string, value: string}[]|null,
419
421
  * verdictType?: string|null,
420
422
  * questions?: Record<string|number, string>|null, generate?: Function, provider?: any,
421
423
  * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null, judgeModel?: string|null,
422
- * rates?: {in: number, out: number}|null,
424
+ * rates?: {in: number, out: number}|null, draftThinking?: 'on'|'off',
423
425
  * onPhase?: (phase: string, data?: any) => void,
424
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
426
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void,
425
427
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
426
428
  * authorFn?: Function, authorOpts?: object, writeScope?: string[]|null,
427
429
  * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object,
428
430
  * ask?: ((step: {kind: string, [k: string]: any}) => Promise<string|null>)|null,
431
+ * answerQuestions?: ((o: {questions: {line: number, question: string}[], dropped: {reason: string, raw: any}[], confirmed: any}) =>
432
+ * Promise<{jobLines: {n: number, text: string, rule?: string}[], answers: Record<string|number, any>}|null>)|null,
429
433
  * confirmGenerate?: Function|null, isRepo?: boolean,
430
434
  * langResult?: {kind: string, candidates?: string[], [k: string]: any}|null}} o
431
435
  * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
@@ -434,9 +438,15 @@ function composerRefusal(reds) {
434
438
  */
435
439
  export async function authorCloseForJob({
436
440
  answers, repoPath = null, lang, verdictType = null, questions = null,
441
+ // P7: the person's numbered job lines and numbered inputs (src/jobblock.js). Absent = every caller that predates the
442
+ // job block (no line tagging, no coverage rule).
443
+ jobLines = null, inputs = null,
437
444
  generate, provider = null, seedRef = null, scout = null, listing = null,
438
445
  ceilingUsd = null,
439
446
  rates = null,
447
+ // The drafter's THINKING knob ('on' | 'off', default 'off'; an operator param, never read from env here). It reaches the scout through
448
+ // `callOptions`; the `generate`/`confirmGenerate` the caller passes must be built with the SAME mode (`makeLoopGenerate`).
449
+ draftThinking = 'off',
440
450
  // Q2 IS GONE from the numbered interview (PRD item 33 M3 piece 3) — the
441
451
  // fence it used to describe in prose is now Destination's own proven
442
452
  // `writeScope`, and this is what carries it into the composer prompt
@@ -479,7 +489,14 @@ export async function authorCloseForJob({
479
489
  // shape matters here (a $0 language pick before the scout,
480
490
  // D7); every other kind leaves `lang` exactly as given.
481
491
  ask = null, confirmGenerate = null, isRepo = false, langResult = null,
492
+ // P7 item 5 (fwdloop am24/25): the seam that can really ASK the person. Present = the last drafting try, and only after
493
+ // the two before it both failed, may ask (`offerQuestions` below); the seam takes the machine-filtered questions, gets
494
+ // each answer (required, no skip), and hands back the job lines with the answers added as `~` rules plus the matching
495
+ // answers text. ABSENT (the CLI, every older caller) = nothing is ever offered and this file is byte-identical.
496
+ answerQuestions = null,
482
497
  }) {
498
+ // an operator-param guard: a typo never silently means "default" (throws before any work, even when the survey is supplied)
499
+ assertDraftThinking(draftThinking);
483
500
  /** @type {any} */
484
501
  const base = {
485
502
  ok: false, refusal: null, verdictType: null, closeDecl: null, seedRef: null,
@@ -580,7 +597,8 @@ export async function authorCloseForJob({
580
597
  // real tree. `attempts` is the ceiling the scout enforces on itself, so the
581
598
  // reader knows how many the line could repeat for.
582
599
  onPhase('scout', { attempts: SCOUT_ATTEMPTS });
583
- survey = await scoutFn({ workdir, provider, ceilingUsd, onCall, ...(rates ? { rates } : {}) });
600
+ const scoutCallOptions = draftCallOptions(provider, draftThinking);
601
+ survey = await scoutFn({ workdir, provider, ceilingUsd, onCall, ...(rates ? { rates } : {}), ...(Object.keys(scoutCallOptions).length ? { callOptions: scoutCallOptions } : {}) });
584
602
  // …and the survey's own VERDICT, said the moment it lands. `state` is what
585
603
  // every downstream refusal keys on (F59: absent is never "no facts needed"),
586
604
  // and the fact count is the one number that says whether the ladder came
@@ -627,7 +645,7 @@ export async function authorCloseForJob({
627
645
  onPhase('confirm', {});
628
646
  const confirm = await runConfirmTurn({
629
647
  verdictType: picked, answers: interview.answers, questions: questions ?? questionsFor(picked),
630
- facts: survey.facts, listing: null, writeScope, isRepo, lang, worseThanBefore,
648
+ facts: survey.facts, listing: null, writeScope, isRepo, lang, worseThanBefore, inputs,
631
649
  generate: confirmGenerate, book: confirmBook, ask, onPhase,
632
650
  });
633
651
  // DISTINCT from `runConfirmTurn`'s own per-round 'confirm-done' (fired once
@@ -671,13 +689,38 @@ export async function authorCloseForJob({
671
689
  // loop obeys — `author-call` carries the enforced cap instead, from where it
672
690
  // is computed.
673
691
  onPhase('author', {});
674
- const authored = await authorFn({
692
+ /** the answers text the later stages read; the try-3 redraft replaces it (the person's answers are new `~` rules) @type {Record<string|number, any>} */
693
+ let stageAnswers = interview.answers;
694
+ let authored = await authorFn({
675
695
  workdir, seedRef: seed, lang, verdictType: picked,
676
- answers: interview.answers, questions: questions ?? questionsFor(picked),
696
+ answers: stageAnswers, questions: questions ?? questionsFor(picked),
677
697
  scout: survey, listing: seeds, generate, ceilingUsd, onPhase, onCall, writeScope,
678
- priorCalls: confirmPriorCalls, priorRaws: confirmPriorRaws, confirmed,
698
+ priorCalls: confirmPriorCalls, priorRaws: confirmPriorRaws, confirmed, jobLines, inputs,
699
+ ...(answerQuestions ? { offerQuestions: true } : {}),
679
700
  ...authorOpts,
680
701
  });
702
+ // P7 item 5: the drafter, on the last try after two failed ones, asked. The person answers (required, no skip); each
703
+ // answer becomes a `~` rule on its job line; the draft then runs ONCE more over the same survey, listing and confirmed
704
+ // plan, and may not ask again. The first leg's calls are carried as prior spend so the one ceiling still binds.
705
+ if (!authored.ok && authored.stop === 'questions-open' && answerQuestions && Array.isArray(authored.questions) && authored.questions.length) {
706
+ const dropped = (authored.iterations ?? []).flatMap((/** @type {any} */ it) => it?.droppedQuestions ?? []);
707
+ const updated = await answerQuestions({ questions: authored.questions, dropped, confirmed });
708
+ if (updated === null) return { ...base, interview, authoring: authored, reds: [], stop: 'confirm-abandoned', cost: authored.cost };
709
+ const scoutCalls = (survey?.calls ?? []).length;
710
+ const scoutRaws = (survey?.raws ?? []).length;
711
+ jobLines = updated.jobLines;
712
+ stageAnswers = updated.answers;
713
+ onPhase('author', {});
714
+ authored = await authorFn({
715
+ workdir, seedRef: seed, lang, verdictType: picked,
716
+ answers: stageAnswers, questions: questions ?? questionsFor(picked),
717
+ scout: survey, listing: seeds, generate, ceilingUsd, onPhase, onCall, writeScope,
718
+ priorCalls: (authored.cost?.calls ?? []).slice(scoutCalls), priorRaws: (authored.raws ?? []).slice(scoutRaws),
719
+ confirmed, jobLines, inputs,
720
+ ...authorOpts,
721
+ offerQuestions: false,
722
+ });
723
+ }
681
724
 
682
725
  if (!authored.ok) {
683
726
  return {
@@ -724,6 +767,11 @@ export async function authorCloseForJob({
724
767
  ...(authored.declaration.notes?.length
725
768
  ? { notes: authored.declaration.notes.map((/** @type {any} */ n) => (typeof n === 'string' ? redactSecrets(n) : n)) }
726
769
  : {}),
770
+ // P7: the job lines the drafter could not turn into a stage, each with its reason — the Job tab shows them. Free
771
+ // text the model wrote, so it rides the same redactor `notes` does.
772
+ ...(authored.declaration.refused?.length
773
+ ? { refused: authored.declaration.refused.map((/** @type {any} */ r) => ({ line: r.line, reason: redactSecrets(String(r.reason)) })) }
774
+ : {}),
727
775
  };
728
776
 
729
777
  // ── THE TWO SIGNED ARTIFACTS (softgreen module 4, wired here by module 5) ──
@@ -750,7 +798,7 @@ export async function authorCloseForJob({
750
798
  book.absorb(authored.cost?.calls ?? []);
751
799
  onPhase('rubric', { size: CALIBRATION_SIZE });
752
800
  const proposed = await proposeFn({
753
- answers: interview.answers,
801
+ answers: stageAnswers,
754
802
  questions: questions ?? questionsFor(picked),
755
803
  generate,
756
804
  book,
@@ -950,6 +998,17 @@ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
950
998
  Object.entries(r).map(([k, v]) => [k, typeof v === 'string' ? redactSecrets(v) : v]),
951
999
  ));
952
1000
 
1001
+ /**
1002
+ * THE one spelling of "a passed calibration gate stands in for the seed red": the
1003
+ * close carries a judged stage AND the gate ran and passed. A failed or missing
1004
+ * calibration never satisfies it, and a close with no judged stage never reaches it.
1005
+ * @param {any[]} judged `judgedStages(closeDecl)` @param {any} calibration `gates.calibration`
1006
+ * @returns {boolean}
1007
+ */
1008
+ export function seedProofIsCalibration(judged, calibration) {
1009
+ return judged.length > 0 && calibration?.ok === true;
1010
+ }
1011
+
953
1012
  /**
954
1013
  * GATE 4 — the calibration gate, and the only gate here that spends money.
955
1014
  *
@@ -1033,7 +1092,10 @@ async function calibrationGate({ spec, judgedStages, judgeLoop, judgeModel, onJu
1033
1092
 
1034
1093
  /** what the signing evidence KEEPS about this gate. The per-case artifacts are
1035
1094
  * deliberately not in it — they are already in the signed spec, and a second
1036
- * copy in the evidence file is a second thing to keep in step. What IS kept is
1095
+ * copy in the evidence file is a second thing to keep in step. Each graded row's
1096
+ * `diag` (F192) keeps the judge's own facts and the decision's reds — scrubbed
1097
+ * and bounded quotes, never an artifact body — so a refusal reads from its own
1098
+ * record. What IS kept is
1037
1099
  * WHICH BYTES were certified (`cardHash`/`casesHash`/`setHash`) and BY WHICH
1038
1100
  * MODEL (`judgeModel`), which is what makes a judge-model bump detectable at
1039
1101
  * all. */
@@ -1135,16 +1197,15 @@ async function calibrationGate({ spec, judgedStages, judgeLoop, judgeModel, onJu
1135
1197
  * has already had its chance, and nobody pays to discover a broken
1136
1198
  * declaration.
1137
1199
  *
1138
- * D9.3 AND THE JUDGED-ONLY CLOSE (2026-08-18 second addendum, ruling 3; hamr:
1139
- * *"fix it now, we are delivering softgreen, isn't that the whole point?"*). Gate
1140
- * 3 protects against a close that cannot fail — one that scans nothing and reads
1141
- * green at seed against a true red. A judged stage SKIPS the seed read by ruling
1142
- * 8, so a close whose only work stage is judged has no seed red to show and used
1143
- * to be unsignable: the one job shape softgreen exists for. For that shape, and
1144
- * ONLY that shape, a PASSED calibration gate plays the seed-red role — it is that
1145
- * close's own proof of failability, held to the same all-or-nothing bar. A close
1146
- * with a mechanical work stage is UNCHANGED: it still needs a seed red, whatever
1147
- * its calibration says.
1200
+ * D9.3 AND THE JUDGED CLOSE (2026-08-18 second addendum, ruling 3; hamr: *"fix it now,
1201
+ * we are delivering softgreen, isn't that the whole point?"*; widened 2026-10-09 to mixed
1202
+ * closes, hamr: *"a rubric job should check if judge can judge (calibration) and judge
1203
+ * rules can apply properly to pass ... we can't close everything upfront"*). Gate 3
1204
+ * protects against a close that cannot fail. A judged stage SKIPS the seed read by ruling
1205
+ * 8, so it can never show red there. For ANY close carrying a judged stage (pure or
1206
+ * mixed with mechanical stages), a PASSED calibration gate plays the seed-red role
1207
+ * (`seedProofIsCalibration`). A close with NO judged stage is UNCHANGED: some work stage
1208
+ * must be red at the seed. A failed or missing calibration never satisfies it.
1148
1209
  *
1149
1210
  * Gates 2 and 3 share ONE execution, and that is stated rather than hidden: at
1150
1211
  * job creation the tree IS the seed, so "does every stage run against the real
@@ -1240,7 +1301,7 @@ export async function prepareSigning({
1240
1301
  }
1241
1302
  // the class rides from the SPEC (PRD v1.57 §2) — gate 1a already refused a spec
1242
1303
  // whose class is unknown or locked, so this is the class D5's battery hangs off
1243
- const dv = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: spec.verdictType });
1304
+ const dv = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null });
1244
1305
  // the grounded gate's reds quote BOTH untrusted sources — the declaration, and
1245
1306
  // the seed listing itself (the listing rule names what really sits beside an
1246
1307
  // invented path). The listing half is a channel gate 1a structurally cannot
@@ -1349,17 +1410,17 @@ export async function prepareSigning({
1349
1410
  // the close-authoring layer, and this gate is what refuses it BEFORE a
1350
1411
  // signature rather than after a paid run.
1351
1412
  //
1352
- // THE JUDGED-ONLY ROUTE (ruling 3). A close whose work stages are ALL
1353
- // seed-exempt has no seed verdict to be red — not because it measures nothing,
1354
- // but because ruling 8 exempts a judged stage from being measured at seed. Its
1355
- // proof of failability is the calibration gate, which has just run and passed
1356
- // (a failed one returned above), and which contains signed cases the pipe must
1357
- // RED as well as ones it must pass — the polarity law makes "this close can
1358
- // fail" a thing that was demonstrated rather than assumed. A close with any
1359
- // mechanical work stage takes the original path unchanged.
1360
- const mechanicalWork = work.filter((r) => !SEED_EXEMPT_KINDS.includes(r.kind));
1361
- const judgedOnly = judgedStages.length > 0 && mechanicalWork.length === 0;
1362
- if (judgedOnly && base.gates.calibration?.ok) {
1413
+ // THE JUDGED ROUTE (ruling 3, widened 2026-10-09 by hamr: "a rubric job should
1414
+ // check if judge can judge (calibration) and judge rules can apply properly to
1415
+ // pass ... we can't close everything upfront"). A close carrying ANY judged stage
1416
+ // has a work-bearing stage that ruling 8 exempts from the seed read, so the seed
1417
+ // cannot show it red — not because it measures nothing, but because it is not
1418
+ // measured there. Its proof of failability is the calibration gate, which has
1419
+ // just run and passed (a failed one returned above), and which contains signed
1420
+ // cases the pipe must RED as well as ones it must pass. That holds for a PURE
1421
+ // judged close and a MIXED one alike; a close with NO judged stage takes the
1422
+ // original path unchanged (some work stage must be red at the seed).
1423
+ if (workRed.length === 0 && seedProofIsCalibration(judged, base.gates.calibration)) {
1363
1424
  // said out loud in the record: this close passed gate 3 on a DIFFERENT proof,
1364
1425
  // and a reader must never have to infer which one from an empty workRed list
1365
1426
  base.gates.seedVerdict.satisfiedBy = 'calibration';