bareloop 0.41.0 → 0.43.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 (75) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +97 -34
  4. package/package.json +4 -3
  5. package/src/authorflow.js +337 -29
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +84 -16
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +14 -5
  10. package/src/authorscout.js +108 -0
  11. package/src/declaredclose.js +25 -14
  12. package/src/facts.js +317 -0
  13. package/src/factwords.js +129 -0
  14. package/src/inputlook.js +301 -0
  15. package/src/interviewrun.js +0 -1
  16. package/src/job.js +40 -12
  17. package/src/jobblock.js +7 -2
  18. package/src/kinds.js +84 -3
  19. package/src/labelscan.js +170 -0
  20. package/src/pagefetch.js +125 -0
  21. package/src/panel/authorroutes.js +121 -34
  22. package/src/panel/authorsession.js +154 -45
  23. package/src/panel/index.html +845 -490
  24. package/src/panel/jobplan.js +34 -11
  25. package/src/panel/runroutes.js +163 -5
  26. package/src/panel/scheduler.js +156 -0
  27. package/src/panel/schedules.js +667 -0
  28. package/src/panel/schedulestart.js +243 -0
  29. package/src/panel/server.js +467 -48
  30. package/src/planrun.js +42 -9
  31. package/src/providerrows.js +3 -3
  32. package/src/reviewdoor.js +37 -0
  33. package/src/rubricstep.js +804 -0
  34. package/src/runlist.js +35 -0
  35. package/src/schedulewords.js +296 -0
  36. package/src/source.js +166 -1
  37. package/src/trigger.js +48 -0
  38. package/src/triggerclock.js +127 -0
  39. package/src/triggerfiles.js +215 -0
  40. package/src/triggerledger.js +79 -0
  41. package/src/u-readout.js +1 -1
  42. package/src/userrun.js +20 -9
  43. package/types/authorflow.d.ts +267 -13
  44. package/types/authoring.d.ts +29 -1
  45. package/types/authorjob.d.ts +7 -5
  46. package/types/authorreadout.d.ts +27 -2
  47. package/types/authorscout.d.ts +70 -0
  48. package/types/declaredclose.d.ts +5 -3
  49. package/types/facts.d.ts +71 -0
  50. package/types/factwords.d.ts +42 -0
  51. package/types/inputlook.d.ts +238 -0
  52. package/types/job.d.ts +8 -1
  53. package/types/jobblock.d.ts +8 -1
  54. package/types/labelscan.d.ts +38 -0
  55. package/types/pagefetch.d.ts +71 -0
  56. package/types/panel/authorroutes.d.ts +34 -1
  57. package/types/panel/authorsession.d.ts +55 -14
  58. package/types/panel/jobplan.d.ts +12 -4
  59. package/types/panel/runroutes.d.ts +32 -1
  60. package/types/panel/scheduler.d.ts +17 -0
  61. package/types/panel/schedules.d.ts +412 -0
  62. package/types/panel/schedulestart.d.ts +106 -0
  63. package/types/panel/server.d.ts +145 -14
  64. package/types/planrun.d.ts +2 -0
  65. package/types/providerrows.d.ts +2 -2
  66. package/types/reviewdoor.d.ts +27 -0
  67. package/types/rubricstep.d.ts +286 -0
  68. package/types/runlist.d.ts +20 -0
  69. package/types/schedulewords.d.ts +135 -0
  70. package/types/source.d.ts +94 -0
  71. package/types/trigger.d.ts +37 -0
  72. package/types/triggerclock.d.ts +52 -0
  73. package/types/triggerfiles.d.ts +93 -0
  74. package/types/triggerledger.d.ts +35 -0
  75. package/types/userrun.d.ts +11 -0
@@ -27,7 +27,7 @@ import {
27
27
  } from 'node:fs';
28
28
  import { join, resolve, dirname } from 'node:path';
29
29
  import {
30
- prepareSource, proveDestination, missingDependencies, looksLikeRepoSource, nearestGitAncestor, nonRepoSourceMessage,
30
+ prepareSource, proveDestination, proveRubricInputs, looksLikeRubricInput, missingDependencies, looksLikeRepoSource, nearestGitAncestor, nonRepoSourceMessage,
31
31
  } from '../source.js';
32
32
  import {
33
33
  worktreePath, uncommittedCount, shortHead, removeWorktree,
@@ -50,10 +50,12 @@ import {
50
50
  import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
51
51
  import { assertThinkingMode, thinkingPlanFor, thinkingRowText } from '../thinking.js';
52
52
  import { closeJudges } from '../kinds.js';
53
+ import { RUBRIC_LANG } from '../authoring.js';
54
+ import { rubricGoal, reconcileRubricPlan } from '../rubricstep.js';
53
55
  import { redactSecrets } from '../validate.js';
54
56
  import { tallyCalls } from '../text.js';
55
57
  import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
56
- import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, calibrationSummary } from '../authorreadout.js';
58
+ import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, declarationRecord, calibrationSummary, tryRefusedText } from '../authorreadout.js';
57
59
  import { runNpmCi, NPM_CI_LOCKS } from '../npminstall.js';
58
60
  import {
59
61
  parseJobBlock, parseInputs, jobBlockFitsCheck, renderJobLines, renderExamples, signedJobLines,
@@ -75,6 +77,7 @@ export const STEP_LABELS = Object.freeze({
75
77
  reuse: 'reusing signed workflow',
76
78
  read: 'reading repo',
77
79
  scout: 'scouting repo',
80
+ look: 'looking at your input',
78
81
  listing: 'listing files',
79
82
  confirm: 'confirming plan',
80
83
  draft: 'drafting',
@@ -84,6 +87,16 @@ export const STEP_LABELS = Object.freeze({
84
87
  signed: 'signed hash',
85
88
  });
86
89
 
90
+ /**
91
+ * The same steps, worded for a job on the person's own local FILES (Part 1a): there is no worktree and no repo to read.
92
+ * Code-owned, one table beside STEP_LABELS; a step with no entry keeps its STEP_LABELS word.
93
+ * @type {Readonly<Record<string, string>>}
94
+ */
95
+ export const FILE_JOB_STEP_LABELS = Object.freeze({
96
+ copy: 'copying input files',
97
+ read: 'reading input files',
98
+ });
99
+
87
100
  /**
88
101
  * The library's own `onPhase(name, ...)` calls (`src/authorjob.js`/`src/authorflow.js`) -> the step each one advances.
89
102
  * A phase with no entry leaves the current step as it was.
@@ -101,6 +114,8 @@ export const PHASE_STEP = Object.freeze({
101
114
  seed: 'read',
102
115
  scout: 'scout',
103
116
  'scout-done': 'scout',
117
+ look: 'look',
118
+ 'look-done': 'look',
104
119
  confirm: 'confirm',
105
120
  'confirm-turn-done': 'confirm',
106
121
  listing: 'listing',
@@ -127,12 +142,13 @@ export function latestStep(steps, id) {
127
142
  * earlier line keeps its status and detail). Existing lines are never moved. Re-starting the step that is still the
128
143
  * LAST line just resumes that line.
129
144
  * @param {StepLine[]} steps @param {string} id @param {string} [detail] (absent = keep what the line has)
145
+ * @param {Readonly<Record<string, string>>} [labels] the label table (a file job passes its own)
130
146
  */
131
- export function advanceSteps(steps, id, detail) {
147
+ export function advanceSteps(steps, id, detail, labels = STEP_LABELS) {
132
148
  for (const x of steps) if (x.status === 'running' && x.id !== id) x.status = 'done';
133
149
  const last = steps.at(-1);
134
150
  if (last && last.id === id) { last.status = 'running'; if (detail !== undefined) last.detail = detail; return; }
135
- steps.push({ id, label: STEP_LABELS[id] ?? id, status: 'running', detail: detail ?? '' });
151
+ steps.push({ id, label: labels[id] ?? id, status: 'running', detail: detail ?? '' });
136
152
  }
137
153
 
138
154
  /** source-door refusals that come from CHECKING the copied source (the scan/freeze), not from reaching it */
@@ -182,6 +198,25 @@ export function jobNameTaken(name, opts = {}) {
182
198
  } catch { return false; }
183
199
  }
184
200
 
201
+ /**
202
+ * The next free name for a NEW workflow made from `origin` (RULED 2026-10-15, v1.101): `<base>-N`, N the next free number from 2.
203
+ * Base: when the origin ends in `-<digits>` AND the part before it is itself a job (in the run list or jobs/), that part (so `x-2`
204
+ * suggests `x-3`, never `x-2-2`); else the origin's own name. Free = not a run-list job and not {@link jobNameTaken}.
205
+ * @param {string} origin
206
+ * @param {Iterable<string>} runJobs job names in the panel's run list
207
+ * @param {{jobsDir?: string}} [opts] test seam for {@link jobNameTaken}
208
+ * @returns {string}
209
+ */
210
+ export function nextFreeJobName(origin, runJobs, opts = {}) {
211
+ const used = new Set(runJobs);
212
+ const m = /^(.+)-\d+$/.exec(origin);
213
+ const base = m && (used.has(m[1]) || jobNameTaken(m[1], opts)) ? m[1] : origin;
214
+ for (let n = 2; ; n += 1) {
215
+ const name = `${base}-${n}`;
216
+ if (!used.has(name) && !jobNameTaken(name, opts)) return name;
217
+ }
218
+ }
219
+
185
220
  /** the form's fields, in card order — what `card.json` stores, verbatim (P5 item 3) */
186
221
  export const CARD_FIELDS = Object.freeze(['jobName', 'checkType', 'provider', 'model', 'thinking', 'jobText', 'inputs', 'destination', 'capUsd', 'maxWallMs']);
187
222
 
@@ -209,30 +244,25 @@ export function cardFields(card) {
209
244
  }
210
245
 
211
246
  /**
212
- * REUSE WORKFLOW (hamr 2026-10-03, replaces P5 item 3's same-job rule; Model opened 2026-10-04): a reuse card has exactly
213
- * FIVE open boxes — Inputs, Destination, Model, $ cap, Time cap — and every other box is the signed workflow, shown greyed and never
214
- * editable. The page greys them; THIS is what refuses (the server never trusts the page). Changing any locked box
215
- * is a different job: Clear the card, which drafts.
247
+ * REUSE WORKFLOW, ONE DOOR (RULED 2026-10-15, v1.101; replaces v1.88's five open boxes). Every box on the card is open. What decides
248
+ * the path is the CARD compared with the origin run's card, never the drafted spec: the JOB boxes are the job text (numbered lines,
249
+ * `~` rules, `~ PASS`/`~ FAIL`), the check type and the thinking tick. The RUN boxes (Inputs, Destination, Model, $ cap, Time cap)
250
+ * never decide it. A green origin with no job box changed reuses the signed plan verbatim (no drafting); anything else drafts.
216
251
  */
217
- export const REUSE_OPEN_FIELDS = Object.freeze(['inputs', 'destination', 'model', 'capUsd', 'maxWallMs']);
218
- /** every card field that is NOT open on a reuse card, in card order */
219
- export const REUSE_LOCKED_FIELDS = Object.freeze(CARD_FIELDS.filter((f) => !REUSE_OPEN_FIELDS.includes(f)));
220
- const REUSE_LABELS = Object.freeze(/** @type {Record<string, string>} */ ({
221
- jobName: 'Job name', checkType: 'Check type', jobText: 'The job',
222
- }));
252
+ export const JOB_BOXES = Object.freeze(['jobText', 'checkType', 'thinking']);
223
253
 
224
254
  /**
225
- * The first LOCKED box on `card` that differs from what the signed workflow prefilled (`origin`), or `null` when
226
- * every locked box is untouched. Blank and absent read alike; whitespace is not compared.
255
+ * Did any JOB box on `card` change from what the origin run prefilled (`origin`)? Whitespace is not compared (a one-line input drops
256
+ * a text's newlines); an absent thinking tick reads as off.
227
257
  * @param {Record<string, any>} card the form as it was submitted
228
258
  * @param {Record<string, any>} origin the form as the server prefilled it
229
- * @returns {string|null} the offending field's label
259
+ * @returns {boolean}
230
260
  */
231
- export function lockedFieldChanged(card, origin) {
232
- // whitespace is ignored: a one-line <input> drops a goal's newlines, and the signed copy never takes text from the card anyway
233
- const norm = (/** @type {any} */ v) => (v === undefined || v === null ? '' : (typeof v === 'string' ? v.replace(/\s+/g, '') : v));
234
- const bad = REUSE_LOCKED_FIELDS.find((f) => norm(card?.[f]) !== norm(origin?.[f]));
235
- return bad === undefined ? null : (REUSE_LABELS[bad] ?? bad);
261
+ export function jobBoxesChanged(card, origin) {
262
+ const text = (/** @type {any} */ v) => (typeof v === 'string' ? v.replace(/\s+/g, '') : '');
263
+ return text(card?.jobText) !== text(origin?.jobText)
264
+ || (card?.checkType === 'rubric') !== (origin?.checkType === 'rubric')
265
+ || (card?.thinking === true) !== (origin?.thinking === true);
236
266
  }
237
267
 
238
268
  /**
@@ -251,7 +281,9 @@ export function lockedFieldChanged(card, origin) {
251
281
  */
252
282
  export function buildReuseSpec(originSpec, card, rows) {
253
283
  const spec = JSON.parse(JSON.stringify(originSpec));
254
- spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
284
+ // an ABSOLUTE Destination is a per-run output folder (a plain-files job): it is never part of the signed spec, and the spec's own
285
+ // fence (inside the run's tree) stays as signed. A repo job's Destination is the fence and is relative (an absolute one is refused).
286
+ if (!isAbsoluteDestination(String(card.destination ?? '').trim())) spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
255
287
  const ip = parseInputs(card.inputs);
256
288
  if (ip.ok) spec.inputs = ip.inputs; else delete spec.inputs;
257
289
  spec.budgetUsd = card.capUsd;
@@ -294,6 +326,27 @@ const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(desti
294
326
 
295
327
  export { nonRepoSourceMessage };
296
328
 
329
+ /** the refusal for a plain-files rubric job whose Destination is not an absolute folder (ends with the words the session start shows) */
330
+ export const FILES_DESTINATION_WORDS = 'Destination must be the full path of a folder, like /home/me/out — the finished files are delivered there. Nothing spent.';
331
+
332
+ /**
333
+ * THE ONE Destination check of a session start, at $0, before any session, copy or model call. The session's own pipeline, the HTTP
334
+ * start (`startSession`), the schedule's two clicks and a scheduled fire's `wouldAccept` all call THIS (never a copy of it), so a start
335
+ * that would be refused for its Destination is refused before anything is made.
336
+ * @param {{inputs?: unknown, destination?: unknown, checkType?: unknown}} card the Inputs text (line 1 = the source), the Destination and the check type
337
+ * @returns {string|null} the plain refusal, or null when the Destination is fine
338
+ */
339
+ export function startDestinationProblem(card) {
340
+ const ip = parseInputs(card?.inputs);
341
+ const source = ip.ok ? (ip.inputs[0]?.value ?? '') : '';
342
+ const destination = typeof card?.destination === 'string' ? card.destination : '';
343
+ const isRepoLike = looksLikeRepoSource(source);
344
+ const repo = repoDestinationProblem(destination, isRepoLike);
345
+ if (repo !== null) return repo;
346
+ if (card?.checkType === 'rubric' && !isRepoLike && !isAbsoluteDestination(destination)) return FILES_DESTINATION_WORDS;
347
+ return null;
348
+ }
349
+
297
350
  /**
298
351
  * The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
299
352
  * relative to the repo, so an absolute path is refused — at $0, before any copy or model call. A FOLDER source (or a
@@ -311,14 +364,14 @@ export function repoDestinationProblem(destination, isRepoLike) {
311
364
  * creates a session or spends anything — never inside the async pipeline,
312
365
  * so a bad card refuses synchronously, in the same HTTP response.
313
366
  * @param {any} card
314
- * @param {{jobsDir?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card
367
+ * @param {{jobsDir?: string, allowName?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card; `allowName` = the origin job's own name on an unchanged red Reuse (it is the same job, not a collision)
315
368
  * @returns {{ok: true}|{ok: false, error: string}}
316
369
  */
317
370
  export function validateJobCard(card, opts = {}) {
318
371
  if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
319
372
  const { jobName, checkType, model, destination, capUsd } = card;
320
373
  if (!isSlug(jobName)) return { ok: false, error: 'Job name must be a kebab-case slug (letters, digits, dashes)' };
321
- if (jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
374
+ if (jobName !== opts.allowName && jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
322
375
  return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
323
376
  }
324
377
  if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
@@ -327,10 +380,13 @@ export function validateJobCard(card, opts = {}) {
327
380
  if (card.thinking !== undefined && typeof card.thinking !== 'boolean') return { ok: false, error: 'Thinking on must be on or off' };
328
381
  const jb = parseJobBlock(card.jobText);
329
382
  if (!jb.ok) return { ok: false, error: jb.error };
330
- const misfit = jobBlockFitsCheck(jb.lines, checkType === 'rubric');
331
- if (misfit) return { ok: false, error: misfit };
332
383
  const ib = parseInputs(card.inputs);
333
384
  if (!ib.ok) return { ok: false, error: ib.error };
385
+ // P1=B: PASS/FAIL examples are required of a rubric job only when it holds a judged part. A rubric on the person's own
386
+ // local files (first input not a repo) holds none in this build; a rubric on a repo composes the judged floor.
387
+ const rubricFiles = checkType === 'rubric' && looksLikeRubricInput(ib.inputs[0].value);
388
+ const misfit = jobBlockFitsCheck(jb.lines, checkType === 'rubric', { judged: !rubricFiles });
389
+ if (misfit) return { ok: false, error: misfit };
334
390
  if (typeof destination !== 'string' || destination.trim() === '') return { ok: false, error: 'Destination is required' };
335
391
  if (typeof capUsd !== 'number' || !Number.isFinite(capUsd) || capUsd <= 0) {
336
392
  return { ok: false, error: '$ cap is required and must be a positive number' };
@@ -346,7 +402,7 @@ export function validateJobCard(card, opts = {}) {
346
402
  * @param {any} card the validated job card (see {@link validateJobCard})
347
403
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
348
404
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
349
- * prepareSigningFn?: Function, npmCiFn?: Function, draftThinking?: "on"|"off", reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
405
+ * prepareSigningFn?: Function, npmCiFn?: Function, lookFn?: Function|null, fetchUrl?: (url: string) => Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>, draftThinking?: "on"|"off", reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
350
406
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
351
407
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
352
408
  * @returns {any} the session object
@@ -432,10 +488,13 @@ export function createSession(card, deps = {}) {
432
488
  });
433
489
  };
434
490
 
491
+ /** a rubric job on local files words its copy/read steps for files (set once the card is known) */
492
+ let fileJob = card.checkType === 'rubric' && looksLikeRubricInput(parseInputs(card.inputs).ok ? (/** @type {any} */ (parseInputs(card.inputs))).inputs[0].value : '');
435
493
  /** @param {string} id @param {string} [detail] (absent = keep what the step has) start (or restart) a step; whatever step was running is finished */
436
494
  const stepStart = (id, detail) => {
437
- advanceSteps(state.steps, id, detail);
438
- state.progressLabel = STEP_LABELS[id] ?? id;
495
+ const labels = fileJob ? { ...STEP_LABELS, ...FILE_JOB_STEP_LABELS } : STEP_LABELS;
496
+ advanceSteps(state.steps, id, detail, labels);
497
+ state.progressLabel = labels[id] ?? id;
439
498
  logSteps();
440
499
  };
441
500
  /** @param {string} id @param {string} detail set a step's latest line's detail without changing its status */
@@ -500,6 +559,7 @@ export function createSession(card, deps = {}) {
500
559
  return true;
501
560
  };
502
561
 
562
+ let lastTryRefused = false;
503
563
  const onPhase = (name, data = {}) => {
504
564
  // COLLAPSED INTO THE PROGRESS INDICATOR ONLY (build item 3) — this used
505
565
  // to also `say('system', `… ${name}`)`, one jargon chat bubble per
@@ -510,10 +570,24 @@ export function createSession(card, deps = {}) {
510
570
  // person needs to read; the step-by-step detail lives in one line.
511
571
  // ONE owner of the drafting line's detail (model + cap): the drafting line is started here, by the real author phase
512
572
  if (PHASE_STEP[name]) stepStart(PHASE_STEP[name], PHASE_STEP[name] === 'draft' ? `${card.model}, $${card.capUsd.toFixed(2)} cap` : undefined);
513
- // a drafting call after the first (`author-call` i >= 1, of up to `of + 1`) is a RETRY: the line says so in plain words, so a
514
- // long silence on call 2 of 3 does not read as a hang. The same one line, relabelled — never a second line.
573
+ // WHY a try was refused: the drafting log keeps it beside the call that made it (it used to say only "retry 3 of 3"), and the
574
+ // progress line shows it briefly, in code's words
575
+ if (name === 'author-try-failed') {
576
+ lastTryRefused = true;
577
+ const no = state.steps.findLastIndex((/** @type {any} */ x) => x.id === 'draft');
578
+ const reason = tryRefusedText(data);
579
+ appendDraftLog(outDir, {
580
+ kind: 'try-refused', no: no === -1 ? state.steps.length - 1 : no, call: String(data.call ?? ''), reason,
581
+ ...(Array.isArray(data.reds) && data.reds.length ? { reds: redsRecord(data.reds) } : {}), ...declarationRecord(data.declaration), at: new Date().toISOString(),
582
+ });
583
+ stepDetail('draft', `${data.call}: refused — ${reason.length > 160 ? `${reason.slice(0, 160)}…` : reason}`);
584
+ }
585
+ // a drafting call after the first (`author-call` i >= 1, of up to `of + 1`) is relabelled in plain words, so a long silence on call 2
586
+ // of 3 does not read as a hang. The loop drafts again after a try that PASSED too (the drafter sees its own seed-read), so
587
+ // "retry" is said only when the previous try was actually refused; otherwise it is "look". The same one line, never a second one.
515
588
  if (name === 'author-call' && Number.isInteger(data.i) && data.i >= 1 && Number.isInteger(data.of)) {
516
- const label = `${STEP_LABELS.draft} (retry ${data.i + 1} of ${data.of + 1})`;
589
+ const label = `${STEP_LABELS.draft} (${lastTryRefused ? 'retry' : 'look'} ${data.i + 1} of ${data.of + 1})`;
590
+ lastTryRefused = false;
517
591
  stepLabel('draft', label);
518
592
  state.progressLabel = label;
519
593
  }
@@ -644,8 +718,11 @@ export function createSession(card, deps = {}) {
644
718
  const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
645
719
  const into = join(outDir, 'source-seed');
646
720
  const isRepoLike = looksLikeRepoSource(SOURCE);
721
+ // PART 1a: a RUBRIC job whose first input is not a repo works on the person's own local FILES — every numbered input
722
+ // line is one file, frozen by `proveRubricInputs`; there is no repo, no language and no scout (docs/product/ITEM33-BUILD.md)
723
+ const RUBRIC_FILES = card.checkType === 'rubric' && !isRepoLike;
647
724
  // hamr's ruling B (2026-10-05): ONE rule for both a fresh card and a reuse card, at $0, before the copy and any model call
648
- const destProblem = repoDestinationProblem(card.destination, isRepoLike);
725
+ const destProblem = startDestinationProblem(card);
649
726
  if (destProblem !== null) { refuse(destProblem); return; }
650
727
 
651
728
  state.phase = 'preparing-source';
@@ -667,10 +744,17 @@ export function createSession(card, deps = {}) {
667
744
  // fence read as a directory and refused as "not absolute" — hiding the real problem with the Source. A scope-shaped
668
745
  // (relative) Destination is therefore handed to the door only when the source is repo-like.
669
746
  const destIsDir = isAbsoluteDestination(card.destination);
670
- const prep = await prepareSource({
671
- source: SOURCE, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
672
- ...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
673
- });
747
+ /** @type {any} */
748
+ let prep;
749
+ if (RUBRIC_FILES) {
750
+ const proved = await proveRubricInputs({ inputs: INPUTS, into, destination: card.destination, ...(deps.fetchUrl ? { fetchUrl: deps.fetchUrl } : {}) });
751
+ prep = proved.ok ? proved.prep : { stop: proved.error, code: proved.code };
752
+ } else {
753
+ prep = await prepareSource({
754
+ source: SOURCE, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
755
+ ...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
756
+ });
757
+ }
674
758
  if (repoRoot !== null && prep.stop === null && prep.manifest?.worktree) madeWorktree = { repo: repoRoot, dir: prep.manifest.worktree };
675
759
  if (abandoned) dropWorktree();
676
760
  if (prep.stop !== null) {
@@ -682,7 +766,7 @@ export function createSession(card, deps = {}) {
682
766
  stepStart('check');
683
767
 
684
768
  const IS_REPO_SOURCE = prep.manifest.kind === 'repo';
685
- let LANG = 'none-detected';
769
+ let LANG = RUBRIC_FILES ? RUBRIC_LANG : 'none-detected';
686
770
  /** @type {any} */
687
771
  let langResult = null;
688
772
  if (IS_REPO_SOURCE) {
@@ -741,7 +825,7 @@ export function createSession(card, deps = {}) {
741
825
  state.phase = 'drafting';
742
826
  }
743
827
  }
744
- if (!IS_REPO_SOURCE) {
828
+ if (!IS_REPO_SOURCE && !RUBRIC_FILES) {
745
829
  refuse(nonRepoSourceMessage(prep.manifest.kind));
746
830
  return;
747
831
  }
@@ -874,7 +958,9 @@ export function createSession(card, deps = {}) {
874
958
  return;
875
959
  }
876
960
 
877
- const writeScope = card.destination.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
961
+ // a rubric job on files writes only inside the hidden tree's own `output/`; its Destination is a drop-off folder
962
+ // (proven above, per run), never a fence
963
+ const writeScope = RUBRIC_FILES ? ['output/'] : card.destination.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
878
964
  /** @type {any} the confirm turn (below) fills `goal` in once accepted —
879
965
  * declared loosely rather than typed field-by-field, since this object's
880
966
  * own shape is a `job-v1` draft `validateJob` itself is the real
@@ -892,7 +978,6 @@ export function createSession(card, deps = {}) {
892
978
  model: modelChoice.name,
893
979
  // the "Thinking on" tick is signed ONLY when ticked (unticked writes no field: the hash stays what it would have been)
894
980
  ...(card.thinking === true && draftThinkingPlan?.support !== false ? { thinking: true } : {}),
895
- cadence: { unit: 'day', every: 1 },
896
981
  budgetUsd: card.capUsd,
897
982
  ...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
898
983
  writeScope,
@@ -909,7 +994,8 @@ export function createSession(card, deps = {}) {
909
994
  /** @type {Record<string, string>} */
910
995
  const answers = { 1: renderJobLines(jobLines) };
911
996
  if (verdictType === 'soft-green') answers[SOFTGREEN_JUDGE_EXAMPLES_KEY] = renderExamples(jobLines);
912
- const required = requiredAnswersFor(verdictType);
997
+ // a rubric job on files holds no judged part in this build, so the PASS/FAIL examples are not required of it
998
+ const required = RUBRIC_FILES ? [1] : requiredAnswersFor(verdictType);
913
999
  for (const n of required) {
914
1000
  if (!answers[n]) { refuse(`the ${verdictType} check type needs ${required.length} answers and one is missing (key ${n})`); return; }
915
1001
  }
@@ -973,8 +1059,10 @@ export function createSession(card, deps = {}) {
973
1059
  rates: draftPrice?.rates ?? null,
974
1060
  draftThinking,
975
1061
  onPhase, onCall,
976
- ask, answerQuestions, confirmGenerate, isRepo: true, langResult,
1062
+ ask, answerQuestions, confirmGenerate, isRepo: !RUBRIC_FILES, langResult,
977
1063
  ...(deps.scout ? { scout: deps.scout } : {}),
1064
+ // PART 1c: a test that replaces the model boundary (`generate`) must not reach the real paid look; `deps.lookFn` brings its own
1065
+ ...(deps.lookFn !== undefined ? { lookFn: deps.lookFn } : (deps.generate ? { lookFn: null } : {})),
978
1066
  ...(authorFnOverride ? { authorFn: authorFnOverride } : {}),
979
1067
  });
980
1068
 
@@ -1008,13 +1096,34 @@ export function createSession(card, deps = {}) {
1008
1096
  : draftStopText({
1009
1097
  stop: String(authored.stop ?? 'authoring-failed'), reds: stopReds, refusal: authored.refusal,
1010
1098
  spend: { knownUsd: state.draftSpentUsd, spendComplete: state.draftSpendComplete },
1099
+ wholeJob: RUBRIC_FILES,
1011
1100
  }), authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
1012
1101
  return;
1013
1102
  }
1014
1103
 
1015
- if (authored.confirmed?.goal) draft.goal = redactSecrets(String(authored.confirmed.goal));
1104
+ if (RUBRIC_FILES) draft.goal = redactSecrets(rubricGoal({ jobLines, files: prep.manifest.inputs, confirmedGoal: authored.confirmed?.goal ?? null, closeDecl: authored.closeDecl }));
1105
+ else if (authored.confirmed?.goal) draft.goal = redactSecrets(String(authored.confirmed.goal));
1016
1106
  const spec = assembleSpec(draft, { ...authored, verdictType: /** @type {string} */ (authored.verdictType) });
1107
+ // PART 1a (run mv5bk400): the plan the person confirmed named three checks for line 1; the composer wrote ONE stage and put
1108
+ // "lists 3 releases" in its notes, so the card promised a check the close never ran. The card AFTER drafting is now built
1109
+ // from the close itself, and every confirmed check no part carries out is moved to Not checked, in code, before signing.
1110
+ if (RUBRIC_FILES) {
1111
+ const rec = reconcileRubricPlan({
1112
+ confirmed: authored.confirmed?.checkItems ?? [], notChecked: authored.confirmed?.notChecked ?? [], closeDecl: spec.closeDecl, jobLines,
1113
+ });
1114
+ state.finalPlan = { goal: String(draft.goal ?? ''), checkItems: rec.checkItems, notChecked: rec.notChecked, protections: authored.confirmed?.protections ?? [], moved: rec.moved };
1115
+ }
1017
1116
  await finish(spec, authored.seedRef);
1117
+ if (state.finalPlan && state.phase === 'prepared') {
1118
+ // The confirm turn's plan bubble (and a try-3 "answers are in" one) is the DRAFTER's wording of the checks, written before
1119
+ // any close existed. Once the close is signed-ready its card is the only plan the person sees: the earlier ones are
1120
+ // dropped, so drafter prose can never sit above (and be read instead of) the code's sentences (run mv5e1ktb).
1121
+ state.messages = state.messages.filter((/** @type {any} */ m) => m.kind !== 'plan');
1122
+ state.messages.push({
1123
+ ...planMessage({ checkItems: state.finalPlan.checkItems, checks: state.finalPlan.checkItems.map((/** @type {any} */ c) => c.text), notChecked: state.finalPlan.notChecked, protections: state.finalPlan.protections, questions: [] }, jobLines),
1124
+ text: 'These are the checks the signed close will run, and what it will not check. Sign & run to accept them, or Revise to edit the boxes and draft again.',
1125
+ });
1126
+ }
1018
1127
  }
1019
1128
 
1020
1129
  // fire-and-forget — the caller (src/panel/server.js) never awaits this; it