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
@@ -375,9 +375,9 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider,
375
375
  * @param {{workdir: string, provider?: any,
376
376
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
377
377
  * attempts?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
378
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
378
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void,
379
379
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
380
- * rates?: {in: number, out: number}|null,
380
+ * rates?: {in: number, out: number}|null, callOptions?: Record<string, any>,
381
381
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
382
382
  * @returns {Promise<Survey & {raw: string, raws: ReturnType<typeof scrubRaw>[],
383
383
  * calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
@@ -400,6 +400,8 @@ export async function runAuthorScout({
400
400
  onCall = () => {},
401
401
  createLoop = defaultLoop,
402
402
  rates = null,
403
+ // the drafter's per-call model options (`draftCallOptions`, src/providers.js; e.g. thinking off) — {} = nothing extra, byte-identical
404
+ callOptions = {},
403
405
  createSurveyor = defaultSurveyor,
404
406
  }) {
405
407
  const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
@@ -434,7 +436,8 @@ export async function runAuthorScout({
434
436
  // metered without the report going out: exactly the pairing invariant the
435
437
  // raw already rides. It REPORTS and decides nothing; the ceiling still binds
436
438
  // through `capStop` between calls, untouched.
437
- onCall({ ...call });
439
+ // bare-agent's per-run `thinkingApplied` rides the reporting seam only, never the stored entry (see makeCostBook.add)
440
+ onCall({ ...call, ...(r?.thinkingApplied === undefined ? {} : { thinkingApplied: r.thinkingApplied }) });
438
441
  return raws.push(scrubRaw({ label, attempt, text: r?.text })) - 1;
439
442
  };
440
443
  // TIGHTEN-ONLY: an operator may lower the ceiling, never raise it. A garbage
@@ -526,7 +529,7 @@ export async function runAuthorScout({
526
529
  loop = createLoop({ system, policy: surveyor.policy, onLlmResult: metered, provider, ...priced });
527
530
 
528
531
  const r = await settled(loop.run([{ role: 'user', content: scoutPrompt(workdir) }], surveyor.tools,
529
- { cacheMessages: true, maxTokens, timeoutMs: callTimeout }));
532
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions }));
530
533
  verdictAt = record(label, attempt, r);
531
534
  blob = redactSecrets(String(r?.text ?? '')).slice(0, blobMax);
532
535
  lastMsgs = Array.isArray(r?.msgs) && r.msgs.length ? r.msgs : null;
@@ -572,7 +575,7 @@ export async function runAuthorScout({
572
575
  // backends 400 on it. Strip the leading one before re-running.
573
576
  const rMsgs = r.msgs[0]?.role === 'system' ? r.msgs.slice(1) : r.msgs;
574
577
  s2 = await settled(recovery.run([...rMsgs, { role: 'user', content: SCOUT_RECOVERY_PROMPT }], [],
575
- { cacheMessages: true, maxTokens, timeoutMs: callTimeout }));
578
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions }));
576
579
  const recoveryAt = record('author-scout-recovery', attempt, s2);
577
580
  const t = redactSecrets(String(s2?.text ?? '')).slice(0, blobMax);
578
581
  // the verdict follows the blob that WON, never the call that came last
@@ -603,7 +606,7 @@ export async function runAuthorScout({
603
606
  const askMsgs = lastMsgs[0]?.role === 'system' ? lastMsgs.slice(1) : lastMsgs;
604
607
  const rr = await settled(reask.run(
605
608
  [...askMsgs, { role: 'user', content: scoutReaskTurn(/** @type {Survey} */ (survey).reason ?? '') }], [],
606
- { cacheMessages: true, maxTokens, timeoutMs: callTimeout },
609
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions },
607
610
  ));
608
611
  verdictAt = record(label, attempt, rr);
609
612
  blob = redactSecrets(String(rr?.text ?? '')).slice(0, blobMax);
package/src/bridges.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — the REUSE registry (design record:
2
- // docs/product/2026-08-01-layer-3-reuse-design.md, D1/D2/D6, frozen 2026-08-01).
2
+ // docs/logs/2026-08-01-layer-3-reuse-design.md, D1/D2/D6, frozen 2026-08-01).
3
3
  //
4
4
  // A BRIDGE is the plan a green actually executed, kept so the next run of the same
5
5
  // SHAPE starts from it instead of cold. This module is the box and the label on it:
package/src/calibrate.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // THE CALIBRATION GATE — softgreen module 5 (design record
2
- // docs/product/2026-08-17-softgreen-review-door-design.md §4.4, the
2
+ // docs/logs/2026-08-17-softgreen-review-door-design.md §4.4, the
3
3
  // 2026-08-18 threshold addendum rulings 1–3, and the second same-day addendum's
4
4
  // rulings 1 and 3).
5
5
  //
@@ -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
@@ -1,5 +1,5 @@
1
1
  // THE TWO SIGNED ARTIFACTS — softgreen module 4 (design record
2
- // docs/product/2026-08-17-softgreen-review-door-design.md §4.3 and §4.4,
2
+ // docs/logs/2026-08-17-softgreen-review-door-design.md §4.3 and §4.4,
3
3
  // plus the 2026-08-18 threshold addendum).
4
4
  //
5
5
  // Module 1 built the judged floor's core, module 2 wired the stage into a close,
@@ -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
 
@@ -240,6 +240,9 @@ THEIR ANSWER — verbatim, and the only input you have
240
240
  Q${qKey}. ${q}
241
241
  A${qKey}. ${answers?.[qKey] ?? '(no answer given)'}
242
242
 
243
+ Each example is kept with the number of the job line the person wrote it under. Never attach an example to a different
244
+ line than the one it names (run mv1j01sl guessed which example belonged to which part of the goal).
245
+
243
246
  ---
244
247
 
245
248
  THE RULEBOOK — the only rules that exist. You SELECT from it; a rule that is not here cannot be asked for.
@@ -266,6 +269,9 @@ at — plus what the arbiter must decide about it.
266
269
 
267
270
  AT LEAST ONE PASS AND AT LEAST ONE RED. A set that can only fail one way measures nothing.
268
271
  Every rule you name in a case must be a rule your card carries.
272
+ 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
273
+ function with any doc block above it never breaks has-doc, and "the doc only restates the function name" is a
274
+ says-what red, never a has-doc one.
269
275
 
270
276
  HOW TO ANSWER
271
277
 
@@ -102,7 +102,7 @@ export const DECLARED_GENRES = Object.freeze([TYPES_GENRE.name]);
102
102
  * constrained to the names the genre OWNS, so it can never become an environment
103
103
  * channel of its own.
104
104
  */
105
- export const CLOSE_DECL_FIELDS = Object.freeze(['genre', 'lang', 'stages', 'notes', 'genreEnv', 'calibration']);
105
+ export const CLOSE_DECL_FIELDS = Object.freeze(['genre', 'lang', 'stages', 'notes', 'refused', 'genreEnv', 'calibration']);
106
106
 
107
107
  /** what a stored calibration set carries, ENUMERATED: the frozen cases and the judge
108
108
  * tier that graded them. Both are inside the spec, so `jobSpecHash` covers both — which
@@ -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, jobLines?: {n: number}[]|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, jobLines = null } = opts;
282
282
  const deferListing = opts.deferListing === true;
283
283
  /** @type {Red[]} */
284
284
  const reds = [];
@@ -401,6 +401,8 @@ 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,
405
+ jobLines,
404
406
  envOwned: owned,
405
407
  // POST-injection: an owned name is accepted only when its value is EXACTLY
406
408
  // the recorded one. `{}` (nothing recorded) keeps the pre-injection rule, so
@@ -678,14 +680,19 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
678
680
  * recommend a top-up on a dying run — the one direction trend.js names as
679
681
  * dangerous. F6's answer applies: unknown, never a number that means the
680
682
  * opposite of what it says.
681
- * - a stage with no measurement at all donates nothing, for the same reason.
683
+ * - a JUDGED stage (no measurement) donates its distinct red COUNT, always 'down';
684
+ * - any other stage with no measurement at all donates nothing, for the same reason.
682
685
  * The stage POSITION still travels either way, so "the run got further than it
683
686
  * ever had" is readable on every declared close.
684
687
  * @param {any} stage @param {any} r the M1 StageResult
685
688
  * @returns {number|null}
686
689
  */
687
690
  function trendValueOf(stage, r) {
688
- if (typeof r?.value !== 'number' || !Number.isFinite(r.value)) return null;
691
+ if (typeof r?.value !== 'number' || !Number.isFinite(r.value)) {
692
+ // a JUDGED stage has no measurement: its number is how many DISTINCT reds it itemizes
693
+ // (F192 e), lower-is-better, compared to its best-so-far like any deterministic count
694
+ return Number.isInteger(r?.detail?.redCount) ? r.detail.redCount : null;
695
+ }
689
696
  return stage?.params?.direction === 'lower-is-better' ? r.value : null;
690
697
  }
691
698
 
package/src/draftspend.js CHANGED
@@ -24,7 +24,10 @@ export const DRAFT_LOG_FILE = 'draft-log.jsonl';
24
24
  * One line of the drafting log. `no` = the step's position in the session's progress list (a step id can repeat).
25
25
  * - `step-start` {no, id, label, at}
26
26
  * - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
27
- * - `call` {no, step, label, model, costUsd (null stays null), unpricedRounds, at}: one METERED model call
27
+ * - `step-label` {no, label, at} (the step reads `label` from now on, status and times untouched: the thinking row's "the model thought anyway")
28
+ * - `call` {no, step, label, model, thinking, thinkingApplied?, costUsd (null stays null), unpricedRounds, at}: one METERED model call
29
+ * (`thinking` = what was asked and sent: off | on | not-switchable | unverified; `thinkingApplied` = bare-agent's own verdict for the
30
+ * call's run (true | false | null), present only when thinkingMode was sent)
28
31
  * (no token counts: the call path does not carry them without reworking the library, so none are recorded)
29
32
  * @typedef {{kind: string, no: number, at: string, [k: string]: any}} DraftEvent
30
33
  */
@@ -148,6 +151,8 @@ export function readDraftSteps(dir) {
148
151
  x.status = ev.status === 'failed' ? 'failed' : 'done';
149
152
  x.endedAt = ev.at;
150
153
  if (typeof ev.label === 'string') x.label = ev.label;
154
+ } else if (ev.kind === 'step-label' && byNo.has(ev.no)) {
155
+ if (typeof ev.label === 'string') byNo.get(ev.no).label = ev.label;
151
156
  } else if (ev.kind === 'step-reopen' && byNo.has(ev.no)) {
152
157
  const x = byNo.get(ev.no);
153
158
  x.status = 'running';
@@ -31,7 +31,7 @@
31
31
  // form (PRD item 33 M3 piece 3): Goal / Success / Guardrails, soft-green
32
32
  // adding Judge Examples. Source and Destination are MECHANICAL fields —
33
33
  // proven against the machine rather than typed — and their wording is
34
- // ALSO the library's (`SOURCE_FIELD`, `destinationFieldFor`,
34
+ // ALSO the library's (`INPUTS_FIELD`, `destinationFieldFor`,
35
35
  // `src/authorflow.js`), so this module prints what the library hands it
36
36
  // for all six fields, never re-worded here, never re-ordered, never stored;
37
37
  // - the REFUSALS are the library's (`runInterview`): a locked class refuses at
@@ -89,7 +89,7 @@
89
89
  import { keysForDoor } from './keysfile.js';
90
90
  import { applyConfiguredKey, rowsForHome } from './providerrows.js';
91
91
  import {
92
- writeFileSync, mkdirSync, existsSync, readFileSync, statSync,
92
+ writeFileSync, mkdirSync, readFileSync, statSync,
93
93
  } from 'node:fs';
94
94
  import { createInterface } from 'node:readline';
95
95
  import { spawnSync as realSpawnSync } from 'node:child_process';
@@ -101,13 +101,18 @@ import {
101
101
  PLAIN_FOLDER_DEFERRED_FIELDS,
102
102
  } from './authorjob.js';
103
103
  import {
104
- SOURCE_FIELD, destinationFieldFor, labelsFor,
104
+ INPUTS_FIELD, JOB_BLOCK_HELP, destinationFieldFor, labelsFor, SOFTGREEN_JUDGE_EXAMPLES_KEY,
105
105
  } from './authorflow.js';
106
+ import {
107
+ parseJobBlock, parseInputs, renderJobLines, renderExamples, jobBlockFitsCheck, signedJobLines,
108
+ } from './jobblock.js';
106
109
  import { validateJob, validateBaseUrl, PROVIDERS } from './job.js';
107
110
  import { resolveProvider, probeWarningLines, apiKeyProblem } from './providers.js';
108
111
  import { scanSecrets, redactSecrets } from './validate.js';
109
112
  import { detectLanguage } from './detectlang.js';
110
- import { prepareSource, proveDestination, looksLikeRepoSource, missingDependencies } from './source.js';
113
+ import {
114
+ prepareSource, proveDestination, looksLikeRepoSource, missingDependencies, proveInputFiles,
115
+ } from './source.js';
111
116
  import { parseCeiling, ceilingLine } from './authorreadout.js';
112
117
  import { commandFor } from './invoke.js';
113
118
 
@@ -353,18 +358,26 @@ export async function main(argv, deps = {}) {
353
358
  throw new ExitSignal(1);
354
359
  }
355
360
 
356
- // ── 1. SOURCE — the form's first field (PRD item 33 M3, ruling 2) ────────────
361
+ // ── 1. INPUTS — the form's first field (P7, docs/product/PANEL-BUILD.md; replaces Source) ───────────────
362
+ // Line 1 is the repo (the Source this door has always taken); lines 2+ are tracked files inside it, proven at $0
363
+ // by `proveInputFiles` — the same function the panel calls. A bad line re-asks, naming the line.
357
364
  say('');
358
- say('── SOURCE ' + '─'.repeat(58));
359
- // LIBRARY WORDING (PRD item 33 M3 piece 3, ruling 1) — this script prints what
360
- // `src/authorflow.js` hands it and writes none of its own.
361
- say(SOURCE_FIELD.prompt);
362
- const sourceRaw = await readAnswer('the source',
363
- 'Source is required — this is what the job reads. Again:');
364
- const isUrl = /^https?:\/\//i.test(sourceRaw);
365
- const SOURCE = isUrl ? sourceRaw : resolve(sourceRaw);
366
- if (!isUrl && !existsSync(SOURCE)) {
367
- die(`${SOURCE} does not exist — Source is a folder, subfolder, file, or URL bareloop can actually read.`);
365
+ say('── INPUTS ' + '─'.repeat(58));
366
+ // LIBRARY WORDING — this script prints what `src/authorflow.js` hands it and writes none of its own.
367
+ say(INPUTS_FIELD.prompt);
368
+ /** @type {import('./jobblock.js').JobInput[]} */
369
+ let INPUTS = [];
370
+ /** @type {string} */
371
+ let SOURCE = '';
372
+ for (;;) {
373
+ const raw = await readAnswer('the inputs', 'Inputs is required — line 1 is the repo this job reads. Again:');
374
+ const parsed = parseInputs(raw);
375
+ if (!parsed.ok) { say(` ${parsed.error}`); continue; }
376
+ const proof = await proveInputFiles(parsed.inputs);
377
+ if (!proof.ok) { say(` ${proof.error}`); continue; }
378
+ INPUTS = parsed.inputs;
379
+ SOURCE = proof.repo;
380
+ break;
368
381
  }
369
382
  // the SAME rule `prepareSource` uses to route its own destination check
370
383
  // (`looksLikeRepoSource`, `src/source.js`) — never a second, hand-typed copy
@@ -401,7 +414,7 @@ export async function main(argv, deps = {}) {
401
414
  // ruling 7): a plain-folder job still gets the form; it
402
415
  // only gets an honest "no checks yet" stop later (M4).
403
416
  let LANG = 'none-detected';
404
- if (!isUrl && statSync(SOURCE).isDirectory()) {
417
+ if (statSync(SOURCE).isDirectory()) {
405
418
  const langResult = detectLanguage(SOURCE);
406
419
  if (langResult.kind === 'ambiguous') {
407
420
  // NO LONGER DIES (PRD item 33 M3 piece 4, step S5): `LANG` takes the
@@ -560,25 +573,38 @@ export async function main(argv, deps = {}) {
560
573
  say(` asks ${REQUIRED.length} frozen question(s) for this class, then the numbers and names the job spec needs`);
561
574
 
562
575
  // ── 3. the class's own frozen questions, one at a time ───────────────────────
576
+ // P7: "The job" is ONE multi-line answer — numbered lines with their `~` rules, parsed by `parseJobBlock` (the same
577
+ // parser the panel uses) and filed under key 1 as the numbered text the drafter reads. A rubric's second answer
578
+ // (the judge examples) is DERIVED from the `~ PASS:`/`~ FAIL:` lines of that block, never asked a second time.
563
579
  /** @type {Record<string, string>} */
564
580
  const answers = {};
581
+ /** @type {import('./jobblock.js').JobLine[]} */
582
+ let JOB_LINES = [];
583
+ const ASKED = REQUIRED.filter((n) => !(VERDICT === 'soft-green' && n === SOFTGREEN_JUDGE_EXAMPLES_KEY));
565
584
  let asked = 0;
566
- for (const n of REQUIRED) {
585
+ for (const n of ASKED) {
567
586
  asked += 1;
568
587
  say('');
569
- say(`── ${asked} of ${REQUIRED.length} ${'─'.repeat(Math.max(0, 56 - String(asked).length))}`);
570
- // the FIELD LABEL, from the signed table (PRD item 33 M3 piece 3's wording
571
- // fix) — printed on its own line so the literal `${n}. ${QUESTIONS[n]}` below
572
- // stays byte-identical to what the library holds, which is what the wizard
573
- // test suite (`tests/run-interview.test.js`) asserts verbatim and in order.
574
- // A key with no signed-table row prints no label.
588
+ say(`── ${asked} of ${ASKED.length} ${'─'.repeat(Math.max(0, 56 - String(asked).length))}`);
589
+ // the FIELD LABEL, from the signed table — printed on its own line so the literal `${n}. ${QUESTIONS[n]}` below
590
+ // stays byte-identical to what the library holds. A key with no signed-table row prints no label.
575
591
  if (LABELS[n]) say(LABELS[n]);
576
- // the frozen wording, printed as the library holds it. Numbered by the library's
577
- // own key, so the number a person sees is the number their answer is filed under.
592
+ // the frozen wording, printed as the library holds it. Numbered by the library's own key.
578
593
  say(`${n}. ${QUESTIONS[n]}`);
579
- answers[n] = await readAnswer(`question ${n}`,
580
- 'that one is required — every question in this class\'s set has to be answered before a close can be authored from it. Again:');
594
+ for (const h of JOB_BLOCK_HELP) say(` ${h}`);
595
+ for (;;) {
596
+ const raw = await readAnswer(`question ${n}`,
597
+ 'that one is required — every question in this class\'s set has to be answered before a close can be authored from it. Again:');
598
+ const parsed = parseJobBlock(raw);
599
+ if (!parsed.ok) { say(` ${parsed.error}`); continue; }
600
+ const misfit = jobBlockFitsCheck(parsed.lines, VERDICT === 'soft-green');
601
+ if (misfit) { say(` ${misfit}`); continue; }
602
+ JOB_LINES = parsed.lines;
603
+ answers[n] = renderJobLines(parsed.lines);
604
+ break;
605
+ }
581
606
  }
607
+ if (VERDICT === 'soft-green') answers[SOFTGREEN_JUDGE_EXAMPLES_KEY] = renderExamples(JOB_LINES);
582
608
 
583
609
  // ── 4. the OPERATOR's half of the spec — the part nothing authors for you ────
584
610
  say('');
@@ -667,6 +693,9 @@ export async function main(argv, deps = {}) {
667
693
  // meaningless fence derived from an output directory.
668
694
  ...(writeScope === null ? {} : { writeScope }),
669
695
  escalation: { mode: 'decision-ready' },
696
+ // P7: the person's own words, signed beside the model's goal sentence — what the Job tab and Reuse show back
697
+ jobLines: signedJobLines(JOB_LINES),
698
+ inputs: INPUTS,
670
699
  // `tools` is deliberately OMITTED: an omitted menu hashes as the concrete current
671
700
  // TOOL_MENU (MED-1), which pins WHICH menu was signed and makes a widening flip the
672
701
  // hash. Naming one here would freeze today's list into the operator's own half.
package/src/job.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Job-spec validator — the OPERATOR-owned half of the config story (N1,
2
- // design record: docs/product/2026-07-12-n1-job-close-schema-design.md).
2
+ // design record: docs/logs/2026-07-12-n1-job-close-schema-design.md).
3
3
  // A job spec is the arbiter's rulebook: the close chain, the budget, the
4
4
  // outer write fence, the escalation route. It is pure declarative data —
5
5
  // no freeform code is expressible anywhere in it — and it validates
@@ -167,7 +167,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
167
167
  // `steps` stays in the field list ONLY so a retired spec reds by name
168
168
  // (`shape-retired`) instead of falling through to a generic unknown-field —
169
169
  // the operator gets told what happened, not just that something is wrong.
170
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
170
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
171
171
  /** the exact fields of a signed `judge` override — nested objects red unknown
172
172
  * keys too, because no smuggling level exists in a signed spec */
173
173
  const JUDGE_FIELDS = ['provider', 'model'];
@@ -717,6 +717,39 @@ function validateStagedClose(stages, red) {
717
717
  * @param {Red[]} reds direct access for structured request-reds
718
718
  */
719
719
  function validatePlanShape(spec, red, reds) {
720
+ // P7: the person's own words, signed beside the model's goal sentence. Exact fields (no smuggling level), numbered 1..n
721
+ // in order. Optional: a spec that predates the job block has neither and keeps its hash.
722
+ if (spec.jobLines !== undefined) {
723
+ if (!Array.isArray(spec.jobLines) || spec.jobLines.length === 0) red('invalid-value', 'jobLines', 'a non-empty array of {n, text, rule}');
724
+ else {
725
+ spec.jobLines.forEach((/** @type {any} */ l, /** @type {number} */ i) => {
726
+ const at = `jobLines[${i}]`;
727
+ if (!isObj(l)) { red('invalid-value', at, '{n, text, rule}'); return; }
728
+ for (const key of Object.keys(l)) if (!['n', 'text', 'rule', 'rules'].includes(key)) red('unknown-field', `${at}.${key}`, 'job line fields: n, text, rule, rules');
729
+ if (l.n !== i + 1) red('invalid-value', `${at}.n`, `lines are numbered 1, 2, 3 in order — expected ${i + 1}`);
730
+ if (!isNonEmptyString(l.text)) red('invalid-value', `${at}.text`, 'non-empty text');
731
+ if (typeof l.rule !== 'string') red('invalid-value', `${at}.rule`, 'a string ("" when the line has no ~ rule)');
732
+ // optional, beside `rule`: the same `~` lines one by one; it must join back to `rule` exactly
733
+ if (l.rules !== undefined && (!Array.isArray(l.rules) || l.rules.some((/** @type {any} */ r) => !isNonEmptyString(r)) || l.rules.join('; ') !== l.rule)) {
734
+ red('invalid-value', `${at}.rules`, 'an array of the ~ lines that joins with "; " to exactly `rule`');
735
+ }
736
+ });
737
+ }
738
+ }
739
+ if (spec.inputs !== undefined) {
740
+ if (!Array.isArray(spec.inputs) || spec.inputs.length === 0) red('invalid-value', 'inputs', 'a non-empty array of {n, label, value}');
741
+ else {
742
+ spec.inputs.forEach((/** @type {any} */ x, /** @type {number} */ i) => {
743
+ const at = `inputs[${i}]`;
744
+ if (!isObj(x)) { red('invalid-value', at, '{n, label, value}'); return; }
745
+ for (const key of Object.keys(x)) if (!['n', 'label', 'value'].includes(key)) red('unknown-field', `${at}.${key}`, 'input fields: n, label, value');
746
+ if (x.n !== i + 1) red('invalid-value', `${at}.n`, `inputs are numbered 1, 2, 3 in order — expected ${i + 1}`);
747
+ if (!isNonEmptyString(x.label)) red('invalid-value', `${at}.label`, 'non-empty text');
748
+ if (!isNonEmptyString(x.value)) red('invalid-value', `${at}.value`, 'non-empty text');
749
+ });
750
+ }
751
+ }
752
+
720
753
  if (spec.goal === undefined) red('missing-required', 'goal');
721
754
  else if (!isNonEmptyString(spec.goal)) red('invalid-value', 'goal', 'non-empty text — the goal is what the agent plans against');
722
755
 
@@ -757,7 +790,7 @@ function validatePlanShape(spec, red, reds) {
757
790
  // class the user picked (PRD v1.57 §2), so the gate cannot check D5 without
758
791
  // it. An unknown or locked class is refused there, on its own axis, beside
759
792
  // the counted `request-red` above.
760
- const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType });
793
+ const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null });
761
794
  for (const r of cd.reds) reds.push(r);
762
795
  if (demanded !== undefined && !CLASS_BY_CLOSE.declared.includes(demanded)) {
763
796
  // the same laundering guard as the close-type hierarchy, one level up: a
@@ -886,11 +919,11 @@ export function jobSpecHash(job) {
886
919
 
887
920
  /**
888
921
  * The fields a "Reuse workflow" run may change: where the work lands (the write fence, `writeScope`, and a
889
- * `destination`/`source` where a spec ever carries them) and the two ceilings. Everything else in a signed spec is
922
+ * `destination`/`source` where a spec ever carries them), the `inputs` the person pointed at (P7: Inputs is open on a reuse) and the two ceilings. Everything else in a signed spec is
890
923
  * the WORKFLOW — goal, checks, guardrails, close (the worker model is the third kind, {@link WORKFLOW_KEY_IDENTITY_FIELDS}). ONE list, shared by {@link workflowKey} and by the panel's
891
924
  * reuse (so what the key ignores is exactly what the person may edit, never two spellings).
892
925
  */
893
- export const REUSE_OPEN_SPEC_FIELDS = Object.freeze(['source', 'destination', 'writeScope', 'budgetUsd', 'maxWallMs']);
926
+ export const REUSE_OPEN_SPEC_FIELDS = Object.freeze(['source', 'destination', 'writeScope', 'budgetUsd', 'maxWallMs', 'inputs']);
894
927
 
895
928
  /**
896
929
  * The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name