bareloop 0.9.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.
@@ -50,11 +50,11 @@ import { createRequire } from 'node:module';
50
50
  import { join } from 'node:path';
51
51
  import { Gate } from 'bareguard';
52
52
  import { LiteCtx } from 'litectx';
53
- import { seedListing } from './kinds.js';
53
+ import { seedListing, GATE_AUDIT_FILE, ARBITER_BOOK_STORES } from './kinds.js';
54
54
  import { TOOL_MENU, WRITE_VERBS, STORE_VERBS } from './job.js';
55
55
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, strategyFor } from './tools.js';
56
56
  import { redactSecrets, SECRET_PATTERNS } from './validate.js';
57
- import { extractArtifact, priceOf, scrubRaw, stampRaw } from './text.js';
57
+ import { extractArtifact, priceOf, scrubRaw, stampRaw, tallyCalls, capStop } from './text.js';
58
58
 
59
59
  const require = createRequire(import.meta.url);
60
60
  const { Loop, wireGate } = require('bare-agent');
@@ -115,6 +115,16 @@ export const SURVEY_CAUSES = Object.freeze({
115
115
  NON_OBJECT: 'non-object',
116
116
  /** valid JSON, no content — F59's whole point, and a SEMANTIC failure */
117
117
  EMPTY_OBJECT: 'empty-object',
118
+ /**
119
+ * THE SURVEY WAS NEVER ASKED FOR: the operator's money ceiling was already
120
+ * spent when the first attempt came due, so no call was made. Named as its own
121
+ * cause for the reason this enum exists at all — a new route to ABSENT has to
122
+ * be named, and naming it is the moment somebody decides whether it retries.
123
+ * This one does not, and cannot: a retry is precisely what the cap forbade.
124
+ * It is deliberately ABSENT of the model's own doing, and the prose says so —
125
+ * "the scout did not complete" would blame a model that never spoke.
126
+ */
127
+ NOT_FUNDED: 'not-funded',
118
128
  });
119
129
 
120
130
  /**
@@ -280,9 +290,9 @@ export function classifySurvey(blob, meta) {
280
290
  * `runAuthorScout` where they can be proven.
281
291
  * @param {{workdir: string, granted: readonly string[], auditPath?: string, ctx?: boolean}} o
282
292
  */
283
- export async function defaultSurveyor({ workdir, granted, auditPath = join(workdir, 'gate-audit.jsonl'), ctx = true }) {
293
+ export async function defaultSurveyor({ workdir, granted, auditPath = join(workdir, GATE_AUDIT_FILE), ctx = true }) {
284
294
  const gate = new Gate({
285
- fs: { writeScope: [], readScope: [workdir], deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')] },
295
+ fs: { writeScope: [], readScope: [workdir], deny: [auditPath, ...ARBITER_BOOK_STORES.map((s) => join(workdir, s))] },
286
296
  limits: { maxTurns: AUTHOR_SCOUT_ROUNDS + 2 },
287
297
  audit: { path: auditPath },
288
298
  secrets: { patterns: SECRET_PATTERNS },
@@ -338,11 +348,13 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider
338
348
  *
339
349
  * @param {{workdir: string, provider?: any,
340
350
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
341
- * attempts?: number,
351
+ * attempts?: number, ceilingUsd?: number|null,
352
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
342
353
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
343
354
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
344
355
  * @returns {Promise<Survey & {raw: string, raws: ReturnType<typeof scrubRaw>[],
345
- * calls: {label: string, costUsd: number|null, unpricedRounds: number}[]}>}
356
+ * calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
357
+ * budgetStop: 'cap-halt'|'pricing-red'|null}>}
346
358
  */
347
359
  export async function runAuthorScout({
348
360
  workdir, provider = null,
@@ -352,6 +364,8 @@ export async function runAuthorScout({
352
364
  maxTokens = AUTHOR_SCOUT_MAX_TOKENS,
353
365
  ctx = true,
354
366
  attempts = SCOUT_ATTEMPTS,
367
+ ceilingUsd = null,
368
+ onCall = () => {},
355
369
  createLoop = defaultLoop,
356
370
  createSurveyor = defaultSurveyor,
357
371
  }) {
@@ -372,7 +386,17 @@ export async function runAuthorScout({
372
386
  * @param {string} label @param {number} attempt @param {any} r @returns {number}
373
387
  */
374
388
  const record = (label, attempt, r) => {
375
- calls.push({ label, ...priceOf(r) });
389
+ const call = { label, ...priceOf(r) };
390
+ calls.push(call);
391
+ // …and the SHELL is told, at the same instant and off the same entry. A run
392
+ // killed from outside used to lose 100% of its spend record (F6/F12: a
393
+ // halted attempt's spend was invisible by 300×) — the totals only ever
394
+ // existed in this array, which dies with the process. Fired from inside the
395
+ // one recorder rather than at the call sites, so a paid call cannot be
396
+ // metered without the report going out: exactly the pairing invariant the
397
+ // raw already rides. It REPORTS and decides nothing; the ceiling still binds
398
+ // through `capStop` between calls, untouched.
399
+ onCall({ ...call });
376
400
  return raws.push(scrubRaw({ label, attempt, text: r?.text })) - 1;
377
401
  };
378
402
  // TIGHTEN-ONLY: an operator may lower the ceiling, never raise it. A garbage
@@ -395,8 +419,21 @@ export async function runAuthorScout({
395
419
  * judged. An instrument that misaddresses its own reading is the blind
396
420
  * -instrument class, one field wide. */
397
421
  let verdictAt = -1;
422
+ /** the operator's money ceiling, when it is what ended the ladder. `null` on
423
+ * every other path INCLUDING the unbounded one, so a reader never has to
424
+ * infer a governance stop from an absence.
425
+ * @type {'cap-halt'|'pricing-red'|null} */
426
+ let budgetStop = null;
398
427
 
399
428
  while (attempt < allowed) {
429
+ // THE CEILING, between attempts. The attempt cap bounds how many times the
430
+ // survey may ask; it says nothing about what those asks cost, and this is
431
+ // the pipeline's FIRST paid stage — a ladder that spends the whole wallet
432
+ // before the declaration is ever authored is the failure this closes.
433
+ // Checked before the counter moves, so `attempt` stays the number of
434
+ // attempts that actually happened.
435
+ const halt = capStop({ ceilingUsd, ...tallyCalls(calls) });
436
+ if (halt) { budgetStop = halt; break; }
400
437
  attempt += 1;
401
438
  const label = attempt === 1 ? 'author-scout' : `author-scout#${attempt}`;
402
439
 
@@ -432,7 +469,29 @@ export async function runAuthorScout({
432
469
  // F59's reserved TOOLLESS final round. It fires on the ONE population it
433
470
  // was sized for — a survey that was CUT OFF and came back short — never
434
471
  // on a survey that simply finished with little to say.
435
- if (bounded && Buffer.byteLength(blob) < minBytes && Array.isArray(r?.msgs) && r.msgs.length) {
472
+ // …and the reserved round is a PAID CALL like any other, so it sits behind
473
+ // the same ceiling. Left outside it, the one call the cap most needs to
474
+ // stop is the one that escapes: the recovery fires exactly when the first
475
+ // call was cut off, which is exactly when the wallet is thinnest.
476
+ //
477
+ // THE CEILING IS ASKED ABOUT A CALL THAT WOULD ACTUALLY BE MADE, and the
478
+ // ORDER is what makes that true: the recovery predicate is settled FIRST,
479
+ // and `capStop` is consulted only when a recovery was genuinely pending.
480
+ // Asked unconditionally it answers a question nobody put — the wallet is
481
+ // over, but nothing was refused — and `budgetStop` means the ceiling is
482
+ // WHAT ENDED THE LADDER, which is then a lie on every other attempt-1
483
+ // ending: a dead socket on an unbounded first call, or a survey that came
484
+ // back PRESENT and needed no second call at all. Both then render as
485
+ // `cap-halt` at `path: budgetUsd`, steering the operator to raise a number
486
+ // that cannot move the outcome. Nothing about the REFUSAL moves here — the
487
+ // same predicate, the same tally, the same call not made (the top-of-loop
488
+ // latch still names the ceiling whenever a retry was due). This is which
489
+ // ending gets the label, and nothing else.
490
+ const recoveryWanted = bounded && Buffer.byteLength(blob) < minBytes
491
+ && Array.isArray(r?.msgs) && r.msgs.length > 0;
492
+ const recoveryHalt = recoveryWanted ? capStop({ ceilingUsd, ...tallyCalls(calls) }) : null;
493
+ if (recoveryHalt) budgetStop = recoveryHalt;
494
+ if (recoveryWanted && !recoveryHalt) {
436
495
  const recovery = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider });
437
496
  s2 = await recovery.run([...r.msgs, { role: 'user', content: SCOUT_RECOVERY_PROMPT }], [],
438
497
  { cacheMessages: true, maxTokens });
@@ -484,12 +543,26 @@ export async function runAuthorScout({
484
543
  if (!SCOUT_RETRY_CAUSES.includes(/** @type {string} */ (survey.cause))) break;
485
544
  }
486
545
 
487
- const final = /** @type {Survey} */ (survey);
546
+ // THE SURVEY THAT WAS NEVER ASKED FOR. The ceiling was already spent when the
547
+ // first attempt came due, so no call was made and there is no reply to
548
+ // classify. It is spelled out rather than run through `classifySurvey`, which
549
+ // would read the empty blob and report that the model came back EMPTY — a
550
+ // model that never spoke cannot have said nothing, and blaming it would send
551
+ // the operator to the scout when the fact is the wallet.
552
+ const final = /** @type {Survey} */ (survey ?? {
553
+ state: 'ABSENT',
554
+ facts: null,
555
+ cause: SURVEY_CAUSES.NOT_FUNDED,
556
+ reason: budgetStop === 'pricing-red'
557
+ ? 'the survey was never asked for: spend could not be priced, so the ceiling could not be enforced (F6)'
558
+ : `the survey was never asked for: the $${ceilingUsd} ceiling was already spent`,
559
+ meta: { bytes: 0, rounds: 0, bounded: false, recovered: false, error: null, attempts: 0, attemptsAllowed: allowed },
560
+ });
488
561
  // Exhaustion is still the same honest ABSENT — it now says how many times we
489
562
  // paid to find out. One attempt says nothing about retries, because there
490
563
  // were none.
491
564
  const reason = final.reason !== null && attempt > 1 ? `${final.reason} — after ${attempt} attempts` : final.reason;
492
- return { ...final, reason, raw: blob, raws, calls };
565
+ return { ...final, reason, raw: blob, raws, calls, budgetStop };
493
566
  } finally {
494
567
  // a leaked surveyor leaks a gate handle and a litectx index — always, even
495
568
  // when the loop threw
package/src/bridges.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — the REUSE registry (design record:
2
- // docs/plans/2026-08-01-layer-3-reuse-design.md, D1/D2/D6, frozen 2026-08-01).
2
+ // docs/02-features/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:
@@ -34,7 +34,7 @@
34
34
 
35
35
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
36
36
  import { join } from 'node:path';
37
- import { isObj, isNonEmptyString } from './validate.js';
37
+ import { isObj, isNonEmptyString, sweepNestedQuantifiers } from './validate.js';
38
38
  import { TOOL_MENU, LOCKED_TOOLS } from './job.js';
39
39
  import { closeStagesOf } from './plan.js';
40
40
 
@@ -183,6 +183,19 @@ export function validateBridge(input) {
183
183
  if (!Array.isArray(e.history)) red('invalid-value', 'history', 'array of per-run rows');
184
184
  else e.history.forEach((/** @type {unknown} */ h, /** @type {number} */ i) => validateHistoryRow(h, `history.${i}.`, red));
185
185
 
186
+ // PRD v1.60 §1 — the operator-regex admissibility sweep, whose population was
187
+ // `jobs/*.json` and is now every signed spec wherever it lives. A bridge ships
188
+ // its plans VERBATIM, and a stored `artifact-written.pattern` reaches the same
189
+ // untimed exit evaluator on the same main event loop that F49 measured. The
190
+ // reject is UNCHANGED (`hasNestedQuantifier`, one inventory) — only the
191
+ // population is wider, so a clean bridge is byte-identically clean.
192
+ //
193
+ // Whole-entry rather than per-field, because a bridge's regexes sit inside a
194
+ // plan stored as opaque data (`isObj(o.plan)` is all this validator asserts
195
+ // about it): enumerating a path into a document this one deliberately does not
196
+ // parse is how a sweep goes blind the next time the plan schema moves.
197
+ sweepNestedQuantifiers(e, red);
198
+
186
199
  // R1's coherence, checked only once everything it reads is itself valid (a
187
200
  // second red on an already-broken field would bury the real defect): every green
188
201
  // mints exactly one version, and a red mints none.
package/src/clock.js CHANGED
@@ -43,6 +43,21 @@
43
43
  // it from the remaining budget is what stops the LAST round outliving the run.
44
44
  // 3. ~90% accuracy is the bar, by hamr's ruling (addendum 3). This module does
45
45
  // not chase precision; it reports honestly at the precision it has.
46
+ // 4. SUSPENDED MINUTES NEVER CHARGE A SIGNED WALL (hamr's ruling, 2026-08-15:
47
+ // "if computer suspended, time shouldn't count"). The default source is
48
+ // therefore MONOTONIC — `performance.now()`, which reads libuv's `uv_hrtime`
49
+ // and so CLOCK_MONOTONIC, which per clock_gettime(2) "does not count time
50
+ // that the system is suspended" on Linux (CLOCK_BOOTTIME is the one that
51
+ // would). `Date.now()` is CLOCK_REALTIME and counts every suspended minute:
52
+ // that was not a hypothetical, it charged 45 phantom minutes against a
53
+ // 45-minute wall when an s2idle suspend landed mid-run, and it is the same
54
+ // exposure for an NTP step or a hand-set date. Nothing here corrects for a
55
+ // suspend after the fact; the reading simply never includes one.
56
+ // The epoch change is safe by construction: every use of `now()` below is
57
+ // RELATIVE (`now() - startedAt`), and no value derived from this clock is
58
+ // ever published as a calendar timestamp — `report()` carries durations only,
59
+ // and the `at:`/`ts:` stamps a run writes come from their own `Date.now`
60
+ // seams (src/reuse.js), which stay calendar-correct on purpose.
46
61
  //
47
62
  // Unbounded is a REAL state, not a large number: hamr ruled `maxWallMs` has no
48
63
  // default, so a run without one is time-unbounded by explicit operator choice and
@@ -135,11 +150,14 @@ export function isWallTimeout(err, clock) {
135
150
  * run. `closeStages`: how many stages the job's close runs (W5) — each one gets
136
151
  * the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
137
152
  * decoration. Defaults to 1, the single-predicate close. `now`: injected for
138
- * tests — the default is the real clock. `priorElapsedMs`: time this attempt's
139
- * PREDECESSOR already consumed (see below).
153
+ * tests — the default is the real clock, and it stays injectable precisely so
154
+ * every test here can drive time deterministically without a sleep. That default
155
+ * is MONOTONIC (fact 4 above), not `Date.now`: a wall-clock jump — a suspend, an
156
+ * NTP step, a hand-set date — must not be able to reach a signed deadline.
157
+ * `priorElapsedMs`: time this attempt's PREDECESSOR already consumed (see below).
140
158
  * @returns {Clock}
141
159
  */
142
- export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now(), priorElapsedMs = 0 } = {}) {
160
+ export function createClock({ maxWallMs = null, closeStages = 1, now = () => performance.now(), priorElapsedMs = 0 } = {}) {
143
161
  // RESUME (module C) — hamr's checkpoint ruling in a time coat: a run killed
144
162
  // mid-try restarts THAT try under the REMAINDER of its signed per-try numbers,
145
163
  // because "a budget ceiling folds in prior spend so re-invoking cannot silently
@@ -71,7 +71,7 @@
71
71
  import { runStage, makeSeedTrees, STOP_FAULTS, EXIT_RED } from './kinds.js';
72
72
  import {
73
73
  validateDeclaration, classGuards, genreOwnedEnvNames, ungroundedGenreEnv, GENRE_LANGUAGES, TYPES_GENRE,
74
- VERDICT_CLASSES, LIVE_CLASSES,
74
+ VERDICT_CLASSES, LIVE_CLASSES, NEVER_OFFERED_KINDS,
75
75
  } from './authoring.js';
76
76
  import { stageGap } from './ralph.js';
77
77
  import { isObj, isNonEmptyString } from './validate.js';
@@ -112,12 +112,68 @@ export const DECLARED_GAP_PREFIX = 'close: ';
112
112
  * whole judged output rather than a hand-picked slice of it. */
113
113
  export const DECLARED_GAP_KEEP = `^${DECLARED_GAP_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`;
114
114
 
115
- /** The verdict class a DECLARED close may claim. Hard, and by CONSTRUCTION
116
- * rather than by assertion: every live kind is a mechanical command whose
117
- * measurement is the truth, and the two judgment kinds are locked out at the
118
- * catalogue (M2) and absent from the executor (M1). Named as data so the job
119
- * validator reads one hierarchy table rather than hard-coding a class. */
120
- export const DECLARED_CLOSE_CLASSES = Object.freeze(['hard']);
115
+ /**
116
+ * The close verdict word for a run WAITING on a person (N4 §1.3/§1.4). A
117
+ * literal, exported, because three readers key on it — the runner's pause
118
+ * branch, the ledger's excluded set, and the trend reader's blind rule — and
119
+ * three hand-spelled copies of one terminal is how they come to disagree.
120
+ *
121
+ * It is not in `CLOSE_FAULTS` and must never be added: a fault says the close
122
+ * could not render a verdict, and this says the close is not FINISHED rendering
123
+ * one. The first escalates; the second is a checkpoint the operator returns to.
124
+ */
125
+ export const HUMAN_PAUSE = 'human-pause';
126
+
127
+ /**
128
+ * The RUN-level terminal names the hitl class mints one layer up from
129
+ * `HUMAN_PAUSE` (N4 §1.3–§1.5): the checkpoint, and the honest refusal of the
130
+ * operator's rerun input. Spelled once here, beside the close-verdict word they
131
+ * translate, for the same reason `HUMAN_PAUSE` is — the emitters (`planrun.js`),
132
+ * the ledger's excluded set, the resume reader (`reuse.js`), and the runner
133
+ * script all key on them, and hand-spelled copies of one terminal is how they
134
+ * come to disagree.
135
+ *
136
+ * There were three (2026-08-17): `hitl-cancel` was the signer's own terminal and
137
+ * is DELETED with the door it belonged to. A person who does not want to carry on
138
+ * now PAUSES — the same checkpoint, resumable, retired by the TTL if nobody comes
139
+ * back — so nothing in this tree mints that name any more. The ledger keeps the
140
+ * bare string in its excluded set so a spine written before this change still
141
+ * reads as governance rather than as a capability gap; that is a READER's
142
+ * backward compatibility, and deliberately not a constant a writer could reach.
143
+ */
144
+ export const HITL_PAUSE = 'hitl-pause';
145
+ export const HITL_DECISION_RED = 'hitl-decision-red';
146
+
147
+ /**
148
+ * The terminals above that leave the run WAITING ON A PERSON — as a list, minted
149
+ * beside the names it holds so a reader keys on membership rather than on a
150
+ * spelling, and a rename here travels to every reader for free.
151
+ *
152
+ * It is deliberately NOT `reuse.js`'s `CHECKPOINT_OUTCOMES`, which is the wider
153
+ * RESUME list: every stop that left work on disk and an allowance unspent, money
154
+ * and time included. Those are governance stops the reuse loop is meant to try
155
+ * again under its remaining tries; this is the half no further attempt can
156
+ * change, because the allowance still unspent is a human answer. `CHECKPOINT_OUTCOMES`
157
+ * is composed FROM this list so the two can never disagree about which is which.
158
+ *
159
+ * `HITL_DECISION_RED` is not here: it is a verdict already rendered on the operator's
160
+ * own input, not a question still open.
161
+ */
162
+ export const HUMAN_CHECKPOINTS = Object.freeze([HITL_PAUSE]);
163
+
164
+ /** The verdict classes a DECLARED close may claim. Named as data so the job
165
+ * validator reads one hierarchy table rather than hard-coding a class.
166
+ *
167
+ * `hard` is by CONSTRUCTION: every mechanical kind is a command whose measurement
168
+ * is the truth. `hitl` joined it at N4 slice 1, and only because `human-confirms`
169
+ * is genuinely LIVE — the widening and the kind go in one commit, or this table
170
+ * admits a class the executor cannot render. The `soft` class is still absent:
171
+ * `judged-floor` is locked, so no declaration can reach above hitl.
172
+ *
173
+ * The direction that matters is unchanged. This says which class a declaration
174
+ * MAY claim; `closeCeiling` says which class its stages actually NEED. A pick
175
+ * below the ceiling is still the silent upgrade `class-ceiling` refuses. */
176
+ export const DECLARED_CLOSE_CLASSES = Object.freeze(['hard', 'hitl']);
121
177
 
122
178
  /** @param {any} job @returns {boolean} */
123
179
  export function isDeclaredClose(job) {
@@ -135,7 +191,15 @@ export function isDeclaredClose(job) {
135
191
  */
136
192
  export function declaredStages(closeDecl) {
137
193
  if (!isObj(closeDecl) || !Array.isArray(closeDecl.stages)) return null;
138
- return closeDecl.stages.map((s) => (isObj(s) ? { ...s, gapKeep: DECLARED_GAP_KEEP } : s));
194
+ return closeDecl.stages.map((s) => (isObj(s)
195
+ // RULING 5, stamped by the ARBITER rather than trusted from the artefact:
196
+ // a judged or human stage is `offer: false` BY LAW, so `checkMenu` can never
197
+ // hand the agent `check-passes(<a person>)`. The validator refuses a
198
+ // declaration that says anything else (`human-stage-offered`), so this is
199
+ // never overwriting a signer-visible value — it is filling in a law the
200
+ // declaration schema deliberately gives the composer no way to state.
201
+ ? { ...s, gapKeep: DECLARED_GAP_KEEP, ...(NEVER_OFFERED_KINDS.includes(s.kind) ? { offer: false } : {}) }
202
+ : s));
139
203
  }
140
204
 
141
205
  // ── the spec-level gate ──────────────────────────────────────────────────────
@@ -361,7 +425,7 @@ export function guardNames(closeDecl, verdictType) {
361
425
  * @param {any} r an M1 `StageResult`
362
426
  * @param {(s: string) => string} redact
363
427
  * @returns {{verdict: string, notes?: string, gap?: string, detail?: string,
364
- * exitCode?: number, judgedCount?: number}} `notes` is absent, never empty —
428
+ * exitCode?: number, judgedCount?: number, ask?: string|null}} `notes` is absent, never empty —
365
429
  * a field that is always there says nothing; a RED carries its diagnostics
366
430
  * inside `gap` and so has none of its own.
367
431
  */
@@ -370,6 +434,13 @@ function translate(r, redact) {
370
434
  /** absent rather than empty: a field that is always there says nothing */
371
435
  const notes = gapText ? { notes: gapText } : {};
372
436
  if (r.verdict === 'green') return { verdict: 'satisfied', judgedCount: 1, ...notes };
437
+ // THE PAUSE (N4 §1.3) — its own verdict word, and deliberately NOT one of the
438
+ // three below. `satisfied` would mint a green nobody gave; `needs_revision`
439
+ // would hand the fix worker a gap the person has not written yet; a
440
+ // `CLOSE_FAULTS` word would file a working arbiter as a broken instrument.
441
+ // No `gap` and no `judgedCount`: nothing was judged, and every consumer guards
442
+ // with `if (gap)`.
443
+ if (r.verdict === 'pause') return { verdict: HUMAN_PAUSE, ask: r.detail?.ask ?? null, ...notes };
373
444
  if (r.verdict === 'red') {
374
445
  return {
375
446
  verdict: 'needs_revision',
@@ -408,11 +479,12 @@ function translate(r, redact) {
408
479
  * @param {any[]} stages declared stage descriptors, already validated
409
480
  * @param {(s: string) => string} [redact] the scrub, applied at THIS boundary
410
481
  * @param {{timeoutMs?: number, cwd?: string, seedRef?: string,
411
- * seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree'}} [opts]
482
+ * seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree',
483
+ * humanRuling?: {decision: string, text?: string|null}|null}} [opts]
412
484
  * @returns {Promise<any>}
413
485
  */
414
486
  export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
415
- const { timeoutMs, cwd, seedRef, seedTrees: shared, gapCap, maxBuffer, baselineMode } = opts;
487
+ const { timeoutMs, cwd, seedRef, seedTrees: shared, gapCap, maxBuffer, baselineMode, humanRuling = null } = opts;
416
488
  if (!isNonEmptyString(cwd) || !isNonEmptyString(seedRef)) {
417
489
  // Refuse rather than guess. A declared close measures against a SEED; a
418
490
  // missing one is the close unable to run, not a red about the worker.
@@ -436,6 +508,11 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
436
508
  ...(gapCap !== undefined ? { gapCap } : {}),
437
509
  ...(maxBuffer !== undefined ? { maxBuffer } : {}),
438
510
  ...(baselineMode !== undefined ? { baselineMode } : {}),
511
+ // the SIGNER's answer, when this close has a human stage and a leg is
512
+ // carrying one. Absent (null) is the ordinary path AND the pause path — the
513
+ // human stage reads the absence as "nobody has answered yet", which is the
514
+ // whole of what a pause means.
515
+ humanRuling,
439
516
  seedTrees,
440
517
  };
441
518
  try {
package/src/index.js CHANGED
@@ -60,7 +60,7 @@ export { renderListing, selectionPrompt } from './selection.js';
60
60
  // `resumeTreeGate` is the ruling that a resumed patient is continued dirty and never
61
61
  // reset. Both are read by a runner BEFORE the approval gate, so neither can live behind
62
62
  // `runReuse`'s own entry.
63
- export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate } from './reuse.js';
63
+ export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate } from './reuse.js';
64
64
  export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from './ledger.js';
65
65
  // THE WORK BRANCH (PRD v1.57 §3). `workBranchName` is exported so an operator runner can
66
66
  // SHOW, before the approval gate, which branch the run will work on — the same reason the
@@ -89,6 +89,10 @@ export { workBranchName, prepareWorkBranch, WORK_BRANCH_PREFIX, WORK_BRANCH_RE }
89
89
  export {
90
90
  runDeclaredClose, runStage, seedRead, seedAtHead, seedListing, changedSet, makeSeedTrees,
91
91
  LIVE_KINDS, STOP_FAULTS, JUDGED_MARKER, EXIT_GREEN, EXIT_RED, EXIT_STOP,
92
+ // N4 slice 1 — the hitl surface an adopting runner needs: the three doors, the
93
+ // gate that reads a signer's answer (and refuses an empty one), and the seed
94
+ // exemption ruling 8 states.
95
+ HUMAN_DECISIONS, SEED_EXEMPT_KINDS, normalizeHumanRuling,
92
96
  } from './kinds.js';
93
97
  // M2 — what a declaration may SAY, and whether one said it legally. The
94
98
  // catalogue and the genre are DATA an integrating UI renders (the kind menu, the
@@ -96,10 +100,15 @@ export {
96
100
  export {
97
101
  validateDeclaration, normalizeDeclaration, KIND_CATALOGUE, CATALOGUE_KINDS, CATALOGUE_LIVE_KINDS,
98
102
  LOCKED_KINDS, TYPES_GENRE, TYPES_GENRE_TEMPLATE, GENRE_LANGUAGES, genreEnv, genreOwnedEnvNames,
103
+ // how the genre's own tools PRINT — genre property for the same reason the
104
+ // suppression battery is (2026-08-09, run msmbpjk6)
105
+ genreInstruments,
99
106
  DIRECTIONS, BASELINES, MAX_STAGES,
100
107
  // the verdict-class surface (PRD v1.57 §1–§2): the radio's own menu, the guard
101
108
  // battery keyed off it, and the ceiling that makes the pick a promise
102
109
  VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES, CLASS_BATTERIES, classGuards, closeCeiling,
110
+ // ruling 5 as data: the kinds no agent may ever be offered as an in-run ruler
111
+ NEVER_OFFERED_KINDS,
103
112
  } from './authoring.js';
104
113
  // M3 — the looking half and the writing half. `authorClose` is the grounded
105
114
  // loop; `runAuthorScout`/`buildSeedListing` are exported because a caller that
@@ -125,6 +134,7 @@ export {
125
134
  export {
126
135
  validateCloseDecl, runDeclaredStages, declaredStages, isDeclaredClose, guardNames, closeGrade,
127
136
  DECLARED_GENRES, DECLARED_GAP_PREFIX, DECLARED_GAP_KEEP, DECLARED_CLOSE_CLASSES, CLOSE_DECL_FIELDS,
137
+ HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED, HUMAN_CHECKPOINTS,
128
138
  } from './declaredclose.js';
129
139
  export { closeStagesOf } from './plan.js';
130
140
  // M4b — the interview, the refusal, the composition, and D9's three gates.
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/plans/2026-07-12-n1-job-close-schema-design.md).
2
+ // design record: docs/02-features/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
@@ -103,7 +103,12 @@ export const LOCKED_TOOLS = Object.freeze(['run']);
103
103
  * pattern: disclosure ≠ admission), so the ledger counts the demand and the
104
104
  * non-code rung later fills a declared slot instead of reshaping the schema. */
105
105
  export const VERDICT_TYPES = Object.freeze(['green', 'soft-green', 'hitl']);
106
- export const LOCKED_VERDICTS = Object.freeze(['soft-green', 'hitl']);
106
+ /** N4 slice 1: `hitl` is ADMITTED — the class whose *done* needs a person runs
107
+ * through the same signed spec, the same agent-authored plan and the same
108
+ * arbiter as a green job (the nine rulings, 2026-08-07; the three-button surface,
109
+ * 2026-08-12). `soft-green` stays declared-but-locked until its judged floor is
110
+ * built and proven (slice 2): a wobbly ruler must not mint anything. */
111
+ export const LOCKED_VERDICTS = Object.freeze(['soft-green']);
107
112
  /** which close CLASS a declared verdictType demands — the close hierarchy one
108
113
  * level up: the same laundering guard as CLASS_BY_CLOSE, applied to the plan
109
114
  * shape's single close (green on a rubric would be a fake-hard verdict). */