bareloop 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reuse.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — the D7 ENVELOPE and the REUSE RUNNER (design record
2
- // docs/plans/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
2
+ // docs/02-features/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
3
3
  //
4
4
  // hamr's sentence is the whole specification of this file, verbatim:
5
5
  //
@@ -52,6 +52,12 @@ import { closeStagesOf } from './plan.js';
52
52
  import { MIN_WALL_MS, jobSpecHash } from './job.js';
53
53
  import { isObj, isNonEmptyString } from './validate.js';
54
54
  import { readGrade } from './trend.js';
55
+
56
+ /** the paused-run TERMINAL and its spine record share one name (`runPlan` returns
57
+ * it and `runJob` writes it onto `job-end`), spelled once in `declaredclose.js`
58
+ * beside the close-verdict word it translates, so the emitters, the checkpoint
59
+ * list, and the TTL gate below cannot drift from each other */
60
+ import { HITL_PAUSE, HUMAN_CHECKPOINTS } from './declaredclose.js';
55
61
  import { extractArtifact, priceOf } from './text.js';
56
62
  import { runJob as shippedRunJob } from './run.js';
57
63
 
@@ -84,6 +90,34 @@ const { Loop } = require('bare-agent');
84
90
  */
85
91
  export const REUSE_GRADED_RED = Object.freeze(['escalated']);
86
92
 
93
+ /**
94
+ * WHICH TERMINALS ARE A CHECKPOINT — the list a runner hands `readResume` as
95
+ * `resumableOutcomes`, minted HERE rather than in whichever script happens to
96
+ * launch a run.
97
+ *
98
+ * `readResume` deliberately takes it as a parameter and defaults to empty, and
99
+ * that stays true: the reuse loop's own semantics depend on the empty default.
100
+ * What did not exist was a canonical ANSWER, so `scripts/run-u.mjs` kept its own
101
+ * copy — and the exported bundle (PRD v1.44 §2: a thin runner with bareloop as a
102
+ * dependency) would have had to keep a third. The same reasoning that put the
103
+ * pause TTL in the library (OPEN-2, hamr, 2026-08-13) puts this here.
104
+ *
105
+ * Every entry is a stop that left WORK ON DISK and an allowance unspent — money,
106
+ * time, a self-healed stall, and now a person who has not answered yet. A verdict
107
+ * already rendered is never on this list: `green`/`escalated` are graded rows, and
108
+ * re-buying one would pay twice for an answer already in hand. (The signer's own
109
+ * `hitl-cancel` terminal used to be the other example; the door was deleted on
110
+ * 2026-08-17 — a person who does not want to carry on pauses, and the checkpoint
111
+ * they keep is retired by the TTL rather than by a terminal.)
112
+ *
113
+ * The human half is SPLICED IN from `declaredclose.js` rather than spelled again,
114
+ * because the try loop below reads the two halves differently and a second copy is
115
+ * how they would come to disagree about which member is which: the first three are
116
+ * allowances the loop's remaining tries are meant to spend against, and the human
117
+ * ones are the stop no further try can change (see `hardStop`).
118
+ */
119
+ export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', ...HUMAN_CHECKPOINTS]);
120
+
87
121
  /**
88
122
  * v1's definition of "the same KIND of recipe" — and it is the LOAD GATE's own: the
89
123
  * close-stage names, in the same order (`bridges.loadGate` rule 2). One predicate,
@@ -418,13 +452,24 @@ function readTry(events) {
418
452
  };
419
453
  }
420
454
 
421
- /** the three-way verdict read of a runJob outcome, in ONE place: a green, the one
422
- * GRADED red that demotes (D6), or a casualty that keeps its own name. Both the live
423
- * try loop and the resume reader classify through this — two spellings of "what kind
424
- * of answer was that" is how a casualty ends up demoting a recipe.
425
- * @param {string} outcome @returns {'green'|'red'|'casualty'} */
455
+ /** the verdict read of a runJob outcome, in ONE place: a green, the one GRADED red
456
+ * that demotes (D6), a CHECKPOINT waiting on a person, or a casualty that keeps its
457
+ * own name. Both the live try loop and the resume reader classify through this — two
458
+ * spellings of "what kind of answer was that" is how a casualty ends up demoting a
459
+ * recipe.
460
+ *
461
+ * `checkpoint` is its own name rather than a fourth flavour of casualty (2026-08-17).
462
+ * A casualty is a run that DIED — a transport fault, a governance cut, a broken close
463
+ * — and a reader that sees one is being told the attempt is over. A human checkpoint
464
+ * is the opposite fact in every respect: nothing failed, nothing is discarded, the
465
+ * work and the money are exactly where they were, and the allowance still unspent is
466
+ * a person's answer. It behaves like neither a red nor a casualty everywhere it
467
+ * matters already (`hardStop` ends the loop on it, the box is left untouched), so the
468
+ * ROW saying "casualty" was the one place the machinery still mis-described itself.
469
+ * @param {string} outcome @returns {'green'|'red'|'checkpoint'|'casualty'} */
426
470
  function verdictClassOf(outcome) {
427
471
  if (outcome === 'green' || outcome === 'already-green') return 'green';
472
+ if (HUMAN_CHECKPOINTS.includes(outcome)) return 'checkpoint';
428
473
  return REUSE_GRADED_RED.includes(outcome) ? 'red' : 'casualty';
429
474
  }
430
475
 
@@ -1023,6 +1068,81 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
1023
1068
  };
1024
1069
  }
1025
1070
 
1071
+ /**
1072
+ * THE PAUSE TTL (2026-08-12 §2): a hitl checkpoint is kept for 60 days.
1073
+ *
1074
+ * OPEN-2, RULED (hamr, in-turn, 2026-08-13): **the TTL lives in the LIBRARY**, so
1075
+ * the exported bundle (a headless runner with bareloop as a dependency, PRD v1.44
1076
+ * §2) inherits it instead of every runner re-implementing it. A script-only TTL
1077
+ * would vanish on export — the one place the rule is most needed and least
1078
+ * likely to be re-typed.
1079
+ */
1080
+ export const PAUSE_TTL_MS = 60 * 24 * 60 * 60_000;
1081
+
1082
+ /**
1083
+ * The AGE gate for a paused checkpoint — the sibling of `resumeTreeGate`, and it
1084
+ * asks the other question a 60-day-old checkpoint raises. The tree gate asks
1085
+ * *is this still the tree the run left?*; this asks *is this still a decision
1086
+ * anyone is waiting on?*
1087
+ *
1088
+ * THREE things it deliberately does NOT do:
1089
+ *
1090
+ * - it does not widen any other resume rule. The 2026-08-12 ruling is about a
1091
+ * person deciding, so `applies: false` on every other terminal: a cap-halt
1092
+ * checkpoint is a different stop under a different allowance, and quietly
1093
+ * ageing it out would be a governance change nobody ruled.
1094
+ * - it does not treat an unreadable stamp as young (F6). A checkpoint that
1095
+ * cannot say WHEN it paused has an unknown age, and unknown is refused rather
1096
+ * than rounded down to fresh — the direction that fails safe.
1097
+ * - it does not skip silently. An expired checkpoint comes back as a refusal
1098
+ * that NAMES the age and the TTL, because the operator's next move (start
1099
+ * fresh) depends on knowing which of the two it hit.
1100
+ *
1101
+ * The clock is injected for the same reason `runPlan`'s is: a rule measured in
1102
+ * months cannot otherwise be tested at all.
1103
+ *
1104
+ * @param {any[]} events the paused run's own spine, parsed, in file order
1105
+ * @param {{now?: () => number, ttlMs?: number}} [opts]
1106
+ * @returns {{ok: boolean, applies: boolean, ageMs: number|null, ttlMs: number, pausedAt: string|null, detail: string|null}}
1107
+ */
1108
+ export function checkpointAgeGate(events, { now = Date.now, ttlMs = PAUSE_TTL_MS } = {}) {
1109
+ const list = Array.isArray(events) ? events.filter(isObj) : [];
1110
+ const end = list.filter((e) => e.type === 'job-end').at(-1) ?? null;
1111
+ const base = { applies: false, ageMs: null, ttlMs, pausedAt: null, detail: null };
1112
+ if (end?.outcome !== HITL_PAUSE) return { ...base, ok: true };
1113
+
1114
+ // the PAUSE RECORD's own timestamp, and its terminal's as the fallback: the
1115
+ // pause is what the human is answering, so its stamp is when the waiting began
1116
+ const pause = list.filter((e) => e.type === HITL_PAUSE).at(-1) ?? end;
1117
+ const pausedAt = typeof pause.ts === 'string' ? pause.ts : null;
1118
+ const atMs = pausedAt === null ? NaN : Date.parse(pausedAt);
1119
+ if (!Number.isFinite(atMs)) {
1120
+ return {
1121
+ ...base,
1122
+ ok: false,
1123
+ applies: true,
1124
+ detail: 'this checkpoint does not record when it paused, so its age is unknown — and an unknown age is not a '
1125
+ + `young one (F6). The TTL is ${Math.round(ttlMs / 86_400_000)} days; start a fresh run rather than resuming a `
1126
+ + 'checkpoint nothing can date.',
1127
+ };
1128
+ }
1129
+ const ageMs = Math.max(0, now() - atMs);
1130
+ const days = (/** @type {number} */ ms) => Math.floor(ms / 86_400_000);
1131
+ if (ageMs > ttlMs) {
1132
+ return {
1133
+ ...base,
1134
+ ok: false,
1135
+ applies: true,
1136
+ ageMs,
1137
+ pausedAt,
1138
+ detail: `this run paused ${days(ageMs)} days ago and the pause TTL is ${days(ttlMs)} days — the tree, the `
1139
+ + 'toolchain and the job itself have had two months to move, so the evidence the signer would be answering '
1140
+ + 'about is no longer the evidence the run produced. Start a fresh run.',
1141
+ };
1142
+ }
1143
+ return { ...base, ok: true, applies: true, ageMs, pausedAt };
1144
+ }
1145
+
1026
1146
  /**
1027
1147
  * The PATIENT gate for a resume, which is deliberately NOT the fresh-launch one.
1028
1148
  *
@@ -1375,7 +1495,30 @@ export async function runReuse(opts) {
1375
1495
  costUsd: read.spentUsd, spendComplete: read.spendComplete, wallMs, rounds,
1376
1496
  ...(trySpecHash ? { specHash: trySpecHash } : {}),
1377
1497
  };
1378
- if (verdictClass === 'green' && read.plan === null) {
1498
+ if (outcome === 'already-green') {
1499
+ // A GREEN THAT PREDATES THE RUN mints nothing, and the guard keys on the
1500
+ // TERMINAL rather than on the absence of a plan. `already-green` means the
1501
+ // close was satisfied at the precheck — before this leg wrote a byte — so
1502
+ // whatever plan is on the spine did not produce it, and minting a version
1503
+ // from one would be learning credit for work that did not happen.
1504
+ //
1505
+ // It used to be covered by accident: a cold already-green never drafts, so
1506
+ // `read.plan` was null and the branch below caught it. N4 broke that
1507
+ // accident — a hitl try that PAUSED and came back with the signer's
1508
+ // `accept` lands `already-green` with its predecessor's `plan-accepted`
1509
+ // sitting in the same try window. A rule with no wired detector is prose.
1510
+ bridgeWrites.push({
1511
+ name,
1512
+ action: 'none',
1513
+ file: null,
1514
+ reds: [{
1515
+ code: 'green-predates-run',
1516
+ path: 'outcome',
1517
+ detail: 'the close was already satisfied before this run did anything (already-green) — nothing here earned '
1518
+ + 'a version, and a plan on the spine did not produce this green',
1519
+ }],
1520
+ });
1521
+ } else if (verdictClass === 'green' && read.plan === null) {
1379
1522
  // a green with no plan on the spine cannot mint a version — the artifact that
1380
1523
  // inherits is the one that RAN, and there is nothing here to inherit. Reported,
1381
1524
  // never faked with a placeholder.
@@ -1389,6 +1532,26 @@ export async function runReuse(opts) {
1389
1532
  toolsUsed: [...new Set((read.plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
1390
1533
  };
1391
1534
  row.bridgeWrite = writeGreen(bridge, meta, { ...record, plan: read.plan });
1535
+ } else if (HUMAN_CHECKPOINTS.includes(outcome)) {
1536
+ // A PAUSE IS NOT A RESULT, so the box gets nothing — not a casualty row, not a red,
1537
+ // and the workflow is left exactly as it was. The recipe has not been judged: the
1538
+ // close is not FINISHED rendering its verdict, and the answer that would finish it
1539
+ // is a person's. Filing it as history would put a row on the entry that reads as a
1540
+ // run this workflow failed to carry, and the signer's `accept` moments later would
1541
+ // leave that row standing as evidence against a workflow that in fact greened.
1542
+ // Recorded as an explicit no-write for the already-green branch's reason: a reader
1543
+ // never has to branch on absence.
1544
+ bridgeWrites.push({
1545
+ name,
1546
+ action: 'none',
1547
+ file: null,
1548
+ reds: [{
1549
+ code: 'run-paused-for-decision',
1550
+ path: 'outcome',
1551
+ detail: `the run is waiting on a person (${outcome}) — nothing has been judged yet, so there is no result to `
1552
+ + 'file against this workflow. The row is written by whatever the resumed run finally earns.',
1553
+ }],
1554
+ });
1392
1555
  } else if (bridge) {
1393
1556
  // R1: the recipe is NOT edited by a red. `outcome` rides in verbatim — a casualty
1394
1557
  // keeps its own name, and only the literal 'red' demotes (D6).
@@ -1495,6 +1658,31 @@ export async function runReuse(opts) {
1495
1658
  * @param {any} row @returns {any|null} the terminal, or null to carry on
1496
1659
  */
1497
1660
  const hardStop = (row) => {
1661
+ // THE HUMAN HALF, read before the table and off the LIBRARY's own list: a run
1662
+ // waiting on a person is not one of the stops below, which are properties of the
1663
+ // run that would reproduce for $0. This one is the opposite — a try that did real
1664
+ // work and reached the one stage a machine cannot render. Buying the next try (and
1665
+ // then the cold leg) would spend the envelope re-asking a question already put to
1666
+ // someone who has not replied yet, and the second attempt could not be graded any
1667
+ // sooner than the first. The allowance still unspent is a person, so the loop stops
1668
+ // and hands the run back; the checkpoint on disk is untouched and resumable, which
1669
+ // is the runner script's half (N4 §1.6, PAUSE_TTL_MS).
1670
+ if (HUMAN_CHECKPOINTS.includes(row.runOutcome)) {
1671
+ return done(row.runOutcome, {
1672
+ category: row.runOutcome,
1673
+ decision: 'The run reached a stage only a person can render, and it is waiting on that answer. Every further '
1674
+ + 'try and the cold leg would run the same job to the same question, so the loop stops here rather than '
1675
+ + 'buying attempts nobody can grade yet. Nothing is lost: the run kept everything it did, and it resumes '
1676
+ + 'from this checkpoint once the decision is in.',
1677
+ options: [
1678
+ 'read the evidence package the pause recorded, then answer it and resume this run',
1679
+ 'leave it: an unanswered checkpoint expires on its own once the pause TTL is up',
1680
+ ],
1681
+ // NOT re-filed, for `interpreter-red`'s reason: the pause already escalated from
1682
+ // its own site inside the run, under this same name and with the evidence package
1683
+ // attached. A second copy here would count one person's pending decision twice.
1684
+ }, false);
1685
+ }
1498
1686
  const decisions = {
1499
1687
  'unapproved-spec': {
1500
1688
  decision: `No approval record matches the per-try spec (hash ${trySpecHash}). Tightening the caps with an envelope makes a NEW spec version, and a new version is signed, not inherited — nothing was run.`,
package/src/root.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer R — the root: the within-run ratchet (LAYERS.md item 2; design record
2
- // docs/plans/2026-07-19-layer-r-design.md). One state that survives attempts
2
+ // docs/02-features/2026-07-19-layer-r-design.md). One state that survives attempts
3
3
  // inside ONE run so a never-green run stops repeating itself (F21). Role is
4
4
  // CONTINUITY ONLY — F39 measured that hand-delivered state buys no conversion;
5
5
  // the semantic converter is Layer 2's job, not this module's.
package/src/run.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // smoke, the ONE budget ledger (cap-not-estimate; unpriced is never free, F6),
4
4
  // and the decision-ready stops. It composes the plan flow below it and
5
5
  // interprets nothing itself.
6
- // Design record: docs/plans/2026-07-12-n2-headless-loop-design.md.
6
+ // Design record: docs/02-features/2026-07-12-n2-headless-loop-design.md.
7
7
  //
8
8
  // The legacy operator-authored `steps[]` path was DELETED 2026-07-26 (PRD
9
9
  // v1.32): the drafting call, config-v1, the per-step interpret loops and the
@@ -140,12 +140,18 @@ async function primitiveSmoke(workdir) {
140
140
  * a manufactured-fixation probe). `true` is the ON/experimental arm; the
141
141
  * default is the OFF arm. (Shell-owned seam, same doctrine as the provider
142
142
  * binding.)
143
+ * @param {{decision: string, text?: string}|null} [opts.humanRuling] N4 — the SIGNER's
144
+ * answer at a hitl pause (accept | rerun <text> | pause), carried by the leg that
145
+ * RESUMES a paused run and forwarded verbatim to the plan flow. Absent on every
146
+ * ordinary run; never authored and never defaulted (a defaulted answer to "is this
147
+ * done?" is the rubber-stamp the class exists to prevent, 2026-08-12 §4).
143
148
  * @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
144
149
  * 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
145
150
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
146
- * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' | `step-red:<id>`
151
+ * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
152
+ * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
147
153
  */
148
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null }) {
154
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null }) {
149
155
  // 0. the ledger's counters, declared FIRST so that every job-end — including
150
156
  // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
151
157
  // not a zero: a consumer reads `undefined` and either crashes or launders it
@@ -283,7 +289,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
283
289
  // accounts it natively (F12) and the job-end money contract is unchanged.
284
290
  {
285
291
  const outcome = await runPlan(job, {
286
- workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch,
292
+ workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling,
287
293
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
288
294
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
289
295
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact
package/src/selection.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — D3's DISPLAY half: the workflow LISTING, and the prompt that asks for a
2
- // pick (design record docs/plans/2026-08-01-layer-3-reuse-design.md, D3).
2
+ // pick (design record docs/02-features/2026-08-01-layer-3-reuse-design.md, D3).
3
3
  //
4
4
  // hamr's ruling, verbatim: *"i think there should be a listing for workflows names that
5
5
  // user can choose or auto-choose, or do we want llm to always decide (we still need to
@@ -346,9 +346,25 @@ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }: {
346
346
  * run could not be read afterwards. A cost entry with no raw beside it is that
347
347
  * run again.
348
348
  */
349
- /** @param {{ceilingUsd?: number|null}} [o] the OPERATOR's ceiling; `null`/absent is UNBOUNDED */
350
- export function makeCostBook({ ceilingUsd }?: {
349
+ /**
350
+ * @param {{ceilingUsd?: number|null,
351
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void}} [o]
352
+ * `ceilingUsd` is the OPERATOR's ceiling; `null`/absent is UNBOUNDED.
353
+ * `onCall` is a REPORTING seam fired as each metered call lands — the shell's
354
+ * only chance to know what has been spent while the run is still alive. It is
355
+ * never consulted and never governs: `capStop` below is still the one
356
+ * predicate, still asked between calls, and still reads the same `entries`.
357
+ * `absorb` deliberately does NOT fire it: those calls were metered (and
358
+ * reported) by whoever made them, and reporting them twice would double the
359
+ * scout's spend in every reader downstream.
360
+ */
361
+ export function makeCostBook({ ceilingUsd, onCall }?: {
351
362
  ceilingUsd?: number | null;
363
+ onCall?: (call: {
364
+ label: string;
365
+ costUsd: number | null;
366
+ unpricedRounds: number;
367
+ }) => void;
352
368
  }): {
353
369
  /** @param {string} label @param {any} r a bare-agent run result @param {number} [attempt] */
354
370
  add(label: string, r: any, attempt?: number): {
@@ -450,10 +466,12 @@ export function makeLoopGenerate(provider: any, { system, maxTokens }?: {
450
466
  * listing?: {stop: string|null, files: string[]|null, block: string|null, meta: any}|null,
451
467
  * generate: Function, seedReadFn?: Function, closeCtx?: any,
452
468
  * ceilingUsd?: number|null,
469
+ * onPhase?: (phase: string, data?: any) => void,
470
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
453
471
  * maxRevisions?: number, structureRetries?: number,
454
472
  * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
455
473
  */
456
- export function authorClose({ workdir, seedRef, lang, verdictType, answers, questions, scout, listing, generate, seedReadFn, closeCtx, ceilingUsd, maxRevisions, structureRetries, structuredMode, catalogue, }: {
474
+ export function authorClose({ workdir, seedRef, lang, verdictType, answers, questions, scout, listing, generate, seedReadFn, closeCtx, ceilingUsd, onPhase, onCall, maxRevisions, structureRetries, structuredMode, catalogue, }: {
457
475
  workdir: string;
458
476
  seedRef: string;
459
477
  lang: string;
@@ -479,6 +497,12 @@ export function authorClose({ workdir, seedRef, lang, verdictType, answers, ques
479
497
  seedReadFn?: Function;
480
498
  closeCtx?: any;
481
499
  ceilingUsd?: number | null;
500
+ onPhase?: (phase: string, data?: any) => void;
501
+ onCall?: (call: {
502
+ label: string;
503
+ costUsd: number | null;
504
+ unpricedRounds: number;
505
+ }) => void;
482
506
  maxRevisions?: number;
483
507
  structureRetries?: number;
484
508
  structuredMode?: "tool" | "text";
@@ -529,21 +553,66 @@ export const STRUCTURE_INSTRUCTION_TEXT: string;
529
553
  * cannot emit anything else (D2/D3), so a genre it has never seen is a
530
554
  * composition problem, not a catalogue problem.
531
555
  *
532
- * The GREEN set is questions 1–6 of the frozen TYPES set (prereg §5) BYTE FOR
533
- * BYTE. What is gone is the seventh slot — D13's genre CONFIRM ("this looks like
534
- * a type-fixing job, correct?") — superseded in full: the interview never asks
535
- * about a genre again. The refusal it carried did not disappear; it MOVED to the
536
- * composer, on the same counted `request-red` path (see `authorCloseForJob`).
537
- * The remaining six are already genre-neutral, which is why generalising them
538
- * required deleting a slot rather than rewording one.
556
+ * The GREEN set descends from questions 1–6 of the frozen TYPES set (prereg §5).
557
+ * TWO slots have been deleted since, both because the interview was ASKING FOR
558
+ * SOMETHING IT ALREADY HAD or already knew:
559
+ *
560
+ * - D13's genre CONFIRM ("this looks like a type-fixing job, correct?") —
561
+ * superseded in full: the interview never asks about a genre again, and the
562
+ * refusal it carried MOVED to the composer, on the same counted
563
+ * `request-red` path (see `authorCloseForJob`);
564
+ * - "Is there a code repo I can look at? Where?" — DROPPED on hamr's ruling
565
+ * after he drove the terminal interview himself (2026-08-15, verbatim: *"drop
566
+ * Q6"*). The repository is a MANDATORY, STRUCTURED input already —
567
+ * `runInterview`'s own `repoPath`, which the runner takes from `--patient` and
568
+ * refuses to start without. Asking a person to re-type in prose a path the
569
+ * machine is holding in a variable is the SWE tax this product refuses, and
570
+ * it invites a second, drifting answer for the same fact (hamr's own answer
571
+ * on the live run was *"Yes — the patient itself."*). Nothing downstream lost
572
+ * anything: the scout reads `repoPath`, never the answer.
573
+ *
574
+ * Question 2 was WIDENED in the same ruling: it now asks for the read-or-draw-from
575
+ * files as well as the ones that change. The two halves belong in one answer
576
+ * because they are one decision for the person answering — what the work touches,
577
+ * and what it touches only to look at.
578
+ *
579
+ * The five that remain are genre-neutral, which is why every generalisation so far
580
+ * has deleted a slot rather than reworded one. They are NUMBERED CONTIGUOUSLY from
581
+ * 1: the number a person sees is the key their answer is filed under, and a gap
582
+ * would be a hole in a form nobody can explain.
539
583
  */
540
584
  export const GREEN_QUESTIONS: Readonly<{
541
585
  1: "What do you want done?";
542
- 2: "Which files or folders should change?";
586
+ 2: "Which files or folders should change? And which files should the work read or draw from (they stay untouched)?";
587
+ 3: "Is there anything that must not change?";
588
+ 4: "How do you check today whether it's working?";
589
+ 5: "What would make you say this came back worse than before?";
590
+ }>;
591
+ /**
592
+ * THE HITL SET (N4 slice 1) — the green questions BYTE FOR BYTE, plus one.
593
+ *
594
+ * The five are unchanged deliberately rather than economically: the
595
+ * mechanical-first composition law (2026-08-07) says every hitl close is
596
+ * *deterministic stages first, judge minimal, human last*, so a hitl job needs
597
+ * exactly the same mechanical facts a green one does — what changes, what must
598
+ * not, how it is checked today, what "worse" looks like. Rewording them would
599
+ * fork one interview into two that drift.
600
+ *
601
+ * The last one is the only thing the green set cannot supply, and it is not a
602
+ * flourish: `human-confirms` requires an `ask` — the question the signer answers
603
+ * at the end of the run — and nothing else in the interview names it. Without it
604
+ * the composer would have to invent what a person is deciding, which is the one
605
+ * thing a human stage must never have done for it. It is the SPREAD's next number
606
+ * rather than a fixed one, so a green-side deletion renumbers it instead of
607
+ * leaving a gap (it was 7 while the green set held six, and is 6 now it holds five).
608
+ */
609
+ export const HITL_QUESTIONS: Readonly<{
610
+ 6: "When you look at the finished result yourself, what are you deciding?";
611
+ 1: "What do you want done?";
612
+ 2: "Which files or folders should change? And which files should the work read or draw from (they stay untouched)?";
543
613
  3: "Is there anything that must not change?";
544
614
  4: "How do you check today whether it's working?";
545
615
  5: "What would make you say this came back worse than before?";
546
- 6: "Is there a code repo I can look at? Where?";
547
616
  }>;
548
617
  /**
549
618
  * The three sets. `questions: null` on a locked class is ABSENT, never `{}`:
@@ -754,6 +823,11 @@ export const PARAM_SCHEMAS: Readonly<{
754
823
  requireNonEmpty: {
755
824
  const: boolean;
756
825
  };
826
+ ask: {
827
+ type: string;
828
+ minLength: number;
829
+ description: string;
830
+ };
757
831
  }>;
758
832
  export type Red = {
759
833
  code: string;
@@ -290,9 +290,11 @@ export function normalizeDeclaration(declaration: any): any;
290
290
  /** @typedef {{required: readonly string[], optional: readonly string[], pathParams: readonly string[], locked?: true, verdictClass: string, shape: string, asserts: string}} KindSpec */
291
291
  /** the whole menu, in ascending order of what it takes to render the verdict */
292
292
  export const VERDICT_CLASSES: readonly string[];
293
- /** declared-but-locked: selecting one is COUNTED demand, never an admission */
293
+ /** declared-but-locked: selecting one is COUNTED demand, never an admission.
294
+ * `hitl` LEFT this list at N4 slice 1 (see `src/job.js`'s twin) — the class is
295
+ * built, and its battery is below. `soft-green` waits for the judged floor. */
294
296
  export const LOCKED_CLASSES: readonly string[];
295
- /** the classes v1 actually builds — exactly one */
297
+ /** the classes v1 actually builds */
296
298
  export const LIVE_CLASSES: readonly string[];
297
299
  /**
298
300
  * The whole vocabulary, as DATA. `shape` and `asserts` are the strings the
@@ -508,40 +510,52 @@ export const TYPES_GENRE: Readonly<{
508
510
  }>;
509
511
  }>;
510
512
  }>;
511
- /**
512
- * THE GUARD BATTERY, KEYED BY VERDICT CLASS (hamr's ruling, PRD v1.57 §2).
513
- *
514
- * D5's battery was genre-keyed data. Under class-keyed interviews it has no
515
- * genre to hang from, and the ruling gives it the same home the interview took:
516
- * **the battery attaches to the verdict class the user picked** — not to a
517
- * problem-genre label, and not per catalogue KIND.
518
- *
519
- * WHY THE CLASS. What a battery is FOR is the class of dishonesty the class of
520
- * VERDICT admits. A green job is a job whose *done* is claimed to be
521
- * machine-checkable, so it carries the green battery whatever problem it came
522
- * from — that is the anti-suppression obligation the F87 genre earned, and it
523
- * must not travel with a label the interview no longer asks for.
524
- *
525
- * ATTACHMENT POINT vs CONTENTS, and the split is the whole implementation:
526
- * - the battery's STRUCTURE and its un-removability key off the CLASS. That
527
- * is this table: which guards exist, what they assert, which slot is the
528
- * model's (`fill`), and which params are ours.
529
- * - the battery's CONTENTS fill in at COMPOSITION (`compose`), because a
530
- * suppression pattern, a file extension and a checker's escape hatch belong
531
- * to the TOOL the declaration names, and nothing before composition knows
532
- * that tool. `classGuards` resolves them from the language data below.
533
- *
534
- * A LOCKED class carries `guards: null`, not `[]`. Absent is never empty (F59):
535
- * `[]` would read as "this class needs no guards", which is exactly the sentence
536
- * D5 exists to make unsayable. Nothing ever reaches it — admission refuses a
537
- * locked class before its battery is asked for — and `classGuards` throws rather
538
- * than hand back nothing if anything ever does.
539
- * @type {Record<string, {locked: boolean, guards: readonly any[]|null}>}
540
- */
541
- export const CLASS_BATTERIES: Record<string, {
542
- locked: boolean;
543
- guards: readonly any[] | null;
513
+ export const CLASS_BATTERIES: Readonly<{
514
+ green: Readonly<{
515
+ locked: false;
516
+ guards: readonly (Readonly<{
517
+ name: "changed-from-seed";
518
+ kind: "files-changed";
519
+ params: Readonly<{
520
+ requireNonEmpty: true;
521
+ }>;
522
+ compose: readonly never[];
523
+ fill: readonly string[];
524
+ }> | Readonly<{
525
+ name: "no-suppressions";
526
+ kind: "pattern-absent-in-diff";
527
+ params: Readonly<{}>;
528
+ compose: readonly string[];
529
+ fill: readonly never[];
530
+ }>)[];
531
+ }>;
532
+ 'soft-green': Readonly<{
533
+ locked: true;
534
+ guards: null;
535
+ }>;
536
+ hitl: Readonly<{
537
+ locked: false;
538
+ guards: readonly (Readonly<{
539
+ name: "changed-from-seed";
540
+ kind: "files-changed";
541
+ params: Readonly<{
542
+ requireNonEmpty: true;
543
+ }>;
544
+ compose: readonly never[];
545
+ fill: readonly string[];
546
+ }> | Readonly<{
547
+ name: "no-suppressions";
548
+ kind: "pattern-absent-in-diff";
549
+ params: Readonly<{}>;
550
+ compose: readonly string[];
551
+ fill: readonly never[];
552
+ }>)[];
553
+ }>;
544
554
  }>;
555
+ /** the kinds NO ONE runs as an in-run ruler — ruling 5's law, as data. A
556
+ * declaration that offers one is a red rather than a normalization: the signer
557
+ * must not read one thing and have the runner store another. */
558
+ export const NEVER_OFFERED_KINDS: readonly string[];
545
559
  export type Red = {
546
560
  code: string;
547
561
  path: string;
@@ -82,13 +82,15 @@ export function refuseLockedKind(kind: string): Refusal;
82
82
  * verdictType?: string|null,
83
83
  * questions?: Record<string|number, string>|null, generate?: Function, provider?: any,
84
84
  * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null,
85
+ * onPhase?: (phase: string, data?: any) => void,
86
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
85
87
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
86
88
  * authorFn?: Function, authorOpts?: object}} o
87
89
  * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
88
90
  * closeDecl: any, seedRef: string|null, authoring: any, reds: Red[], stop: string|null,
89
91
  * cost: any, interview: any}>}
90
92
  */
91
- export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, generate, provider, seedRef, scout, listing, ceilingUsd, seedFn, scoutFn, listingFn, authorFn, authorOpts, }: {
93
+ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, generate, provider, seedRef, scout, listing, ceilingUsd, onPhase, onCall, seedFn, scoutFn, listingFn, authorFn, authorOpts, }: {
92
94
  answers: Record<string | number, any>;
93
95
  repoPath?: string | null;
94
96
  lang: string;
@@ -100,6 +102,12 @@ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questi
100
102
  scout?: any;
101
103
  listing?: any;
102
104
  ceilingUsd?: number | null;
105
+ onPhase?: (phase: string, data?: any) => void;
106
+ onCall?: (call: {
107
+ label: string;
108
+ costUsd: number | null;
109
+ unpricedRounds: number;
110
+ }) => void;
103
111
  seedFn?: Function;
104
112
  scoutFn?: Function;
105
113
  listingFn?: Function;
@@ -92,13 +92,14 @@ export function defaultSurveyor({ workdir, granted, auditPath, ctx }: {
92
92
  * @param {{workdir: string, provider?: any,
93
93
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
94
94
  * attempts?: number, ceilingUsd?: number|null,
95
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
95
96
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
96
97
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
97
98
  * @returns {Promise<Survey & {raw: string, raws: ReturnType<typeof scrubRaw>[],
98
99
  * calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
99
100
  * budgetStop: 'cap-halt'|'pricing-red'|null}>}
100
101
  */
101
- export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, ceilingUsd, createLoop, createSurveyor, }: {
102
+ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, ceilingUsd, onCall, createLoop, createSurveyor, }: {
102
103
  workdir: string;
103
104
  provider?: any;
104
105
  rounds?: number;
@@ -108,6 +109,11 @@ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, m
108
109
  ctx?: boolean;
109
110
  attempts?: number;
110
111
  ceilingUsd?: number | null;
112
+ onCall?: (call: {
113
+ label: string;
114
+ costUsd: number | null;
115
+ unpricedRounds: number;
116
+ }) => void;
111
117
  createLoop?: (o: {
112
118
  system: string;
113
119
  policy: any;
package/types/clock.d.ts CHANGED
@@ -53,8 +53,11 @@ export function isWallTimeout(err: any, clock: Clock): boolean;
53
53
  * run. `closeStages`: how many stages the job's close runs (W5) — each one gets
54
54
  * the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
55
55
  * decoration. Defaults to 1, the single-predicate close. `now`: injected for
56
- * tests — the default is the real clock. `priorElapsedMs`: time this attempt's
57
- * PREDECESSOR already consumed (see below).
56
+ * tests — the default is the real clock, and it stays injectable precisely so
57
+ * every test here can drive time deterministically without a sleep. That default
58
+ * is MONOTONIC (fact 4 above), not `Date.now`: a wall-clock jump — a suspend, an
59
+ * NTP step, a hand-set date — must not be able to reach a signed deadline.
60
+ * `priorElapsedMs`: time this attempt's PREDECESSOR already consumed (see below).
58
61
  * @returns {Clock}
59
62
  */
60
63
  export function createClock({ maxWallMs, closeStages, now, priorElapsedMs }?: {