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/authoring.js CHANGED
@@ -94,9 +94,11 @@ import { isObj, isNonEmptyString, hasNestedQuantifier, globToPrefix } from './va
94
94
 
95
95
  /** the whole menu, in ascending order of what it takes to render the verdict */
96
96
  export const VERDICT_CLASSES = Object.freeze(['green', 'soft-green', 'hitl']);
97
- /** declared-but-locked: selecting one is COUNTED demand, never an admission */
98
- export const LOCKED_CLASSES = Object.freeze(['soft-green', 'hitl']);
99
- /** the classes v1 actually builds — exactly one */
97
+ /** declared-but-locked: selecting one is COUNTED demand, never an admission.
98
+ * `hitl` LEFT this list at N4 slice 1 (see `src/job.js`'s twin) — the class is
99
+ * built, and its battery is below. `soft-green` waits for the judged floor. */
100
+ export const LOCKED_CLASSES = Object.freeze(['soft-green']);
101
+ /** the classes v1 actually builds */
100
102
  export const LIVE_CLASSES = Object.freeze(VERDICT_CLASSES.filter((c) => !LOCKED_CLASSES.includes(c)));
101
103
 
102
104
  /** the hierarchy as a comparable number. `green` is the FLOOR: a mechanical
@@ -237,11 +239,22 @@ export const KIND_CATALOGUE = Object.freeze({
237
239
  shape: 'LOCKED — not available in v1',
238
240
  asserts: 'a judged score must clear a floor. Declaring it is recorded as demand; it will not run.',
239
241
  }),
242
+ // LIVE at N4 slice 1, and the ONLY kind that does not measure anything: it
243
+ // does not RUN, it PAUSES the run and hands the tree to a person (ruling 1).
244
+ // `ask` is the whole parameter surface — no cmd, no timeoutMs, no env — so a
245
+ // human stage cannot spawn, cannot be env-capable, and cannot be given a
246
+ // second job. `offer` is never one of its fields either: ruling 5's law is a
247
+ // LAW, checked by the validator, not a knob the composer sets.
240
248
  'human-confirms': Object.freeze({
241
249
  verdictClass: 'hitl',
242
- required: Object.freeze([]), optional: Object.freeze([]), pathParams: Object.freeze([]), locked: true,
243
- shape: 'LOCKED — not available in v1',
244
- asserts: 'a person renders the verdict. Declaring it is recorded as demand; it will not run.',
250
+ required: Object.freeze(['ask']),
251
+ optional: Object.freeze([]),
252
+ pathParams: Object.freeze([]),
253
+ shape: 'ask: string',
254
+ asserts: 'a PERSON renders this verdict, at the end of the run, and nothing about it is measured. Write `ask` as '
255
+ + 'the plain question they answer while looking at the finished result. It is always the LAST stage, it is '
256
+ + 'never offered to the agent as an in-run check, and every part of the job a command can decide belongs in a '
257
+ + 'mechanical stage before it.',
245
258
  }),
246
259
  });
247
260
 
@@ -613,30 +626,40 @@ function language(lang) {
613
626
  * than hand back nothing if anything ever does.
614
627
  * @type {Record<string, {locked: boolean, guards: readonly any[]|null}>}
615
628
  */
616
- export const CLASS_BATTERIES = Object.freeze({
617
- green: Object.freeze({
618
- locked: false,
619
- guards: Object.freeze([
620
- Object.freeze({
621
- name: 'changed-from-seed',
622
- kind: 'files-changed',
623
- params: Object.freeze({ requireNonEmpty: true }),
624
- compose: Object.freeze([]),
625
- fill: Object.freeze(['allowPrefixes']),
626
- }),
627
- Object.freeze({
628
- name: 'no-suppressions',
629
- kind: 'pattern-absent-in-diff',
630
- // NO scope: the scan covers every changed file of these extensions. A
631
- // narrowing here is the round-3 arm A defect and is a red, not a taste.
632
- params: Object.freeze({}),
633
- compose: Object.freeze(['patterns', 'extensions']),
634
- fill: Object.freeze([]),
635
- }),
636
- ]),
629
+ /** THE MECHANICAL BATTERY — the two guards a machine can always render, named
630
+ * once because TWO classes carry exactly them (OPEN-1's ruling below). It is
631
+ * shared by reference and never mutated: `classGuards` deep-copies before it
632
+ * hands anything back, so one battery cannot be weakened through another class. */
633
+ const MECHANICAL_GUARDS = Object.freeze([
634
+ Object.freeze({
635
+ name: 'changed-from-seed',
636
+ kind: 'files-changed',
637
+ params: Object.freeze({ requireNonEmpty: true }),
638
+ compose: Object.freeze([]),
639
+ fill: Object.freeze(['allowPrefixes']),
640
+ }),
641
+ Object.freeze({
642
+ name: 'no-suppressions',
643
+ kind: 'pattern-absent-in-diff',
644
+ // NO scope: the scan covers every changed file of these extensions. A
645
+ // narrowing here is the round-3 arm A defect and is a red, not a taste.
646
+ params: Object.freeze({}),
647
+ compose: Object.freeze(['patterns', 'extensions']),
648
+ fill: Object.freeze([]),
637
649
  }),
650
+ ]);
651
+
652
+ export const CLASS_BATTERIES = Object.freeze({
653
+ green: Object.freeze({ locked: false, guards: MECHANICAL_GUARDS }),
638
654
  'soft-green': Object.freeze({ locked: true, guards: null }),
639
- hitl: Object.freeze({ locked: true, guards: null }),
655
+ // OPEN-1, RULED (hamr, in-turn, 2026-08-13): **hitl inherits the green
656
+ // mechanical guards and adds nothing a human stage cannot see.** D5 makes the
657
+ // battery mandatory, shown-and-fixed and un-removable, and PRD v1.57 §2 keys it
658
+ // to the CLASS; the mechanical-first composition law makes every hitl close
659
+ // mostly mechanical anyway, so the green battery is the right FLOOR. A guard a
660
+ // person cannot verify by looking would be a guard nobody checks — the one
661
+ // thing D5 exists to make unsayable.
662
+ hitl: Object.freeze({ locked: false, guards: MECHANICAL_GUARDS }),
640
663
  });
641
664
 
642
665
  /**
@@ -925,7 +948,12 @@ function scopeOfJob(declaration, idx, guards) {
925
948
  * stage — applied to the one population that decides whether the law fires at
926
949
  * all.
927
950
  */
928
- const AT_MOST_ONCE_KINDS = Object.freeze(['files-changed']);
951
+ const AT_MOST_ONCE_KINDS = Object.freeze(['files-changed', 'human-confirms']);
952
+
953
+ /** the kinds NO ONE runs as an in-run ruler — ruling 5's law, as data. A
954
+ * declaration that offers one is a red rather than a normalization: the signer
955
+ * must not read one thing and have the runner store another. */
956
+ export const NEVER_OFFERED_KINDS = Object.freeze(['human-confirms', 'judged-floor']);
929
957
 
930
958
  /** @param {any} v */
931
959
  const strArray = (v) => Array.isArray(v) && v.length > 0 && v.every(isNonEmptyString);
@@ -1128,13 +1156,43 @@ export function validateDeclaration(declaration, opts = {}) {
1128
1156
  if (AT_MOST_ONCE_KINDS.includes(s.kind)) {
1129
1157
  const owner = kindOwners.get(s.kind);
1130
1158
  if (owner !== undefined) {
1131
- red('duplicate-kind', `${at}.kind`, `stage "${label}" is a second ${s.kind} stage — "${owner}" already declares `
1132
- + 'which files this job may change. Stages are ANDed, so two of them mean the intersection of two prefix sets, '
1133
- + 'which is itself a prefix set and already says the same thing in one stage; what a second one does add is a '
1134
- + 'second answer to what this job\'s scope IS', { kind: s.kind, stage: label, twin: owner });
1159
+ red('duplicate-kind', `${at}.kind`, s.kind === 'human-confirms'
1160
+ ? `stage "${label}" is a second ${s.kind} stage — "${owner}" already asks the person. One signer, one judge `
1161
+ + '(ruling 4), asked ONCE at the end: a second door is a second verdict, and the run has no way to say '
1162
+ + 'which of them was the one it stopped for'
1163
+ : `stage "${label}" is a second ${s.kind} stage — "${owner}" already declares `
1164
+ + 'which files this job may change. Stages are ANDed, so two of them mean the intersection of two prefix sets, '
1165
+ + 'which is itself a prefix set and already says the same thing in one stage; what a second one does add is a '
1166
+ + 'second answer to what this job\'s scope IS', { kind: s.kind, stage: label, twin: owner });
1135
1167
  } else kindOwners.set(s.kind, label);
1136
1168
  }
1137
1169
 
1170
+ // ── RULING 5, AS LAW: judged and human stages are `offer: false`, and the
1171
+ // law is checked rather than applied. `checkMenu` filters `offer !== false`,
1172
+ // so a declaration that says nothing is silent about a rule the runner
1173
+ // enforces anyway (`declaredStages` stamps it) — that part is the arbiter's
1174
+ // own bookkeeping. What must never happen is a declaration that says
1175
+ // something ELSE: `offer: true` on a human stage is the composer asking to
1176
+ // use a person as an in-run ruler, and a silent normalization would leave
1177
+ // the signer reading one thing while the runner stored another.
1178
+ if (NEVER_OFFERED_KINDS.includes(s.kind) && Object.hasOwn(s, 'offer') && s.offer !== false) {
1179
+ red('human-stage-offered', `${at}.offer`, `stage "${label}" is a ${s.kind} stage and offers itself as an in-run `
1180
+ + 'check. A person (or a paid judge) is never an in-run ruler (ruling 5): the agent may never use one to '
1181
+ + 'decide a step, and this is refused rather than quietly rewritten to offer: false',
1182
+ { kind: s.kind, stage: label });
1183
+ }
1184
+ // ── THE COMPOSITION LAW's ordering half (2026-08-07: *mechanical first,
1185
+ // judge minimal, human LAST*), enforced where it is cheap. First-red-wins
1186
+ // means anything after the human stage never runs — so a human stage in the
1187
+ // middle silently deletes the mechanical stages behind it AND empties the
1188
+ // evidence package ruling 2 requires the person to be shown.
1189
+ if (s.kind === 'human-confirms' && i !== stages.length - 1) {
1190
+ red('human-stage-not-last', `${at}.kind`, `stage "${label}" asks a person and is not the LAST stage. The close `
1191
+ + 'is first-red-wins, so every stage after this one would never run: the person would be shown an incomplete '
1192
+ + 'evidence package and the mechanical stages behind them would be silently deleted (the composition law: '
1193
+ + 'mechanical first, human last)', { kind: s.kind, stage: label, at: i, last: stages.length - 1 });
1194
+ }
1195
+
1138
1196
  const p = s.params;
1139
1197
  if (!isObj(p)) { red('missing-field', `${at}.params`, `an object carrying: ${spec.required.join(', ')}`); return; }
1140
1198
  for (const k of spec.required) {
@@ -1338,6 +1396,15 @@ function checkKind({ kind, params: p, at, red, scoped, label, populations, envOw
1338
1396
  if (!strArray(p.allowPrefixes)) red('invalid-value', `${at}.params.allowPrefixes`, 'a non-empty array of path prefixes');
1339
1397
  if (p.requireNonEmpty !== true) red('invalid-value', `${at}.params.requireNonEmpty`, 'literally true');
1340
1398
  break;
1399
+ case 'human-confirms':
1400
+ // the whole surface: the question the signer answers. It is shown to a
1401
+ // person verbatim, so an empty one is a bare "approve?" — ruling 2's
1402
+ // named failure mode, refused here rather than rendered.
1403
+ if (!isNonEmptyString(p.ask)) {
1404
+ red('invalid-value', `${at}.params.ask`, 'the plain question the signer answers while looking at the finished '
1405
+ + 'result — a human stage with nothing to ask is the bare "approve?" ruling 2 forbids');
1406
+ }
1407
+ break;
1341
1408
  default:
1342
1409
  red('unknown-kind', `${at}.kind`, `"${kind}" is in the catalogue but has no checker`, { kind });
1343
1410
  }
package/src/authorjob.js CHANGED
@@ -96,7 +96,7 @@ import {
96
96
  GENRE_LANGUAGES, LOCKED_KINDS, TYPES_GENRE, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES,
97
97
  } from './authoring.js';
98
98
  import { QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose } from './authorflow.js';
99
- import { runAuthorScout, buildSeedListing } from './authorscout.js';
99
+ import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
100
100
  import {
101
101
  DECLARED_GAP_PREFIX, DECLARED_GENRES, guardNames, isDeclaredClose, validateCloseDecl,
102
102
  } from './declaredclose.js';
@@ -366,6 +366,8 @@ function composerRefusal(reds) {
366
366
  * verdictType?: string|null,
367
367
  * questions?: Record<string|number, string>|null, generate?: Function, provider?: any,
368
368
  * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null,
369
+ * onPhase?: (phase: string, data?: any) => void,
370
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
369
371
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
370
372
  * authorFn?: Function, authorOpts?: object}} o
371
373
  * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
@@ -376,6 +378,13 @@ export async function authorCloseForJob({
376
378
  answers, repoPath = null, lang, verdictType = null, questions = null,
377
379
  generate, provider = null, seedRef = null, scout = null, listing = null,
378
380
  ceilingUsd = null,
381
+ // THE PROGRESS SEAMS. This composition is the pipeline's long silence — a real
382
+ // survey, a real declaration ladder, and a real toolchain per close stage — and
383
+ // it emitted nothing until it returned, so an operator watching a live run
384
+ // could not tell work from a hang. The library REPORTS and the shell PRINTS
385
+ // (`runJob`'s `emit` → `runPlan`, the same shape): nothing here reads either
386
+ // callback back, and no decision in this file or below it depends on one.
387
+ onPhase = () => {}, onCall = () => {},
379
388
  seedFn = seedAtHead, scoutFn = runAuthorScout, listingFn = buildSeedListing,
380
389
  authorFn = authorClose, authorOpts = {},
381
390
  }) {
@@ -425,6 +434,10 @@ export async function authorCloseForJob({
425
434
  // D8 — the seed, READ rather than typed.
426
435
  let seed = seedRef;
427
436
  if (!isNonEmptyString(seed)) {
437
+ // Announced only on the path that actually READS one: a caller who handed a
438
+ // seedRef in never paid for a git call, and a phase line for work nobody did
439
+ // is the blind-instrument class, one line wide.
440
+ onPhase('seed', {});
428
441
  const s = await seedFn(workdir);
429
442
  if (s.stop !== null) {
430
443
  // same git-stderr channel as `prepareSigning`'s, scrubbed at the same
@@ -445,15 +458,46 @@ export async function authorCloseForJob({
445
458
  // form). `null` travels EXPLICITLY rather than being left off — unbounded is a
446
459
  // stated operator choice, and an absent field read as falsy is the same state
447
460
  // arrived at by accident.
448
- const survey = scout ?? await scoutFn({ workdir, provider, ceilingUsd });
449
- const seeds = listing ?? await listingFn({
450
- workdir, seedRef: seed, sourcePaths: survey?.facts?.sourcePaths, testPaths: survey?.facts?.testPaths,
451
- });
461
+ /** @type {any} */
462
+ let survey = scout;
463
+ // `== null` and not `!survey`: this is the `??` these two lines replaced, kept
464
+ // exactly — a caller-supplied survey is used whatever it is, and only an
465
+ // ABSENT one is paid for.
466
+ if (survey == null) {
467
+ // The scout is the pipeline's FIRST paid stage and its longest single
468
+ // silence: up to three ladder attempts, each a real toolable survey over a
469
+ // real tree. `attempts` is the ceiling the scout enforces on itself, so the
470
+ // reader knows how many the line could repeat for.
471
+ onPhase('scout', { attempts: SCOUT_ATTEMPTS });
472
+ survey = await scoutFn({ workdir, provider, ceilingUsd, onCall });
473
+ // …and the survey's own VERDICT, said the moment it lands. `state` is what
474
+ // every downstream refusal keys on (F59: absent is never "no facts needed"),
475
+ // and the fact count is the one number that says whether the ladder came
476
+ // back with anything to author from.
477
+ onPhase('scout-done', { state: survey?.state ?? null, facts: Object.keys(survey?.facts ?? {}).length });
478
+ }
479
+ /** @type {any} */
480
+ let seeds = listing;
481
+ if (seeds == null) {
482
+ onPhase('listing', {});
483
+ seeds = await listingFn({
484
+ workdir, seedRef: seed, sourcePaths: survey?.facts?.sourcePaths, testPaths: survey?.facts?.testPaths,
485
+ });
486
+ // the count is only knowable AFTER, so it rides the done line rather than
487
+ // being guessed onto the start one
488
+ onPhase('listing-done', { files: seeds?.files?.length ?? null, stop: seeds?.stop ?? null });
489
+ }
452
490
 
491
+ // The declaration ladder. The REVISION CAP is deliberately not quoted here:
492
+ // the tighten-only clamp lives in `authorClose` (and `authorOpts` may lower
493
+ // it), so a number spelled at this call site could disagree with the one the
494
+ // loop obeys — `author-call` carries the enforced cap instead, from where it
495
+ // is computed.
496
+ onPhase('author', {});
453
497
  const authored = await authorFn({
454
498
  workdir, seedRef: seed, lang, verdictType: picked,
455
499
  answers: interview.answers, questions: questions ?? questionsFor(picked),
456
- scout: survey, listing: seeds, generate, ceilingUsd, ...authorOpts,
500
+ scout: survey, listing: seeds, generate, ceilingUsd, onPhase, onCall, ...authorOpts,
457
501
  });
458
502
 
459
503
  if (!authored.ok) {
@@ -349,6 +349,7 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider
349
349
  * @param {{workdir: string, provider?: any,
350
350
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
351
351
  * attempts?: number, ceilingUsd?: number|null,
352
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
352
353
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
353
354
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
354
355
  * @returns {Promise<Survey & {raw: string, raws: ReturnType<typeof scrubRaw>[],
@@ -364,6 +365,7 @@ export async function runAuthorScout({
364
365
  ctx = true,
365
366
  attempts = SCOUT_ATTEMPTS,
366
367
  ceilingUsd = null,
368
+ onCall = () => {},
367
369
  createLoop = defaultLoop,
368
370
  createSurveyor = defaultSurveyor,
369
371
  }) {
@@ -384,7 +386,17 @@ export async function runAuthorScout({
384
386
  * @param {string} label @param {number} attempt @param {any} r @returns {number}
385
387
  */
386
388
  const record = (label, attempt, r) => {
387
- 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 });
388
400
  return raws.push(scrubRaw({ label, attempt, text: r?.text })) - 1;
389
401
  };
390
402
  // TIGHTEN-ONLY: an operator may lower the ceiling, never raise it. A garbage
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:
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
@@ -103,6 +107,8 @@ export {
103
107
  // the verdict-class surface (PRD v1.57 §1–§2): the radio's own menu, the guard
104
108
  // battery keyed off it, and the ceiling that makes the pick a promise
105
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,
106
112
  } from './authoring.js';
107
113
  // M3 — the looking half and the writing half. `authorClose` is the grounded
108
114
  // loop; `runAuthorScout`/`buildSeedListing` are exported because a caller that
@@ -128,6 +134,7 @@ export {
128
134
  export {
129
135
  validateCloseDecl, runDeclaredStages, declaredStages, isDeclaredClose, guardNames, closeGrade,
130
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,
131
138
  } from './declaredclose.js';
132
139
  export { closeStagesOf } from './plan.js';
133
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). */