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/kinds.js CHANGED
@@ -64,7 +64,7 @@ import { realpathSync } from 'node:fs';
64
64
  import { readFile, stat, mkdtemp, rm, symlink } from 'node:fs/promises';
65
65
  import { tmpdir } from 'node:os';
66
66
  import { join, sep } from 'node:path';
67
- import { globToPrefix } from './validate.js';
67
+ import { globToPrefix, isNonEmptyString } from './validate.js';
68
68
  import { CLOSE_ENV_DENY } from './ralph.js';
69
69
 
70
70
  /** the close's two judgment exits — a stage's verdict, in the shape the
@@ -136,19 +136,56 @@ export const SIGNS = Object.freeze([1, -1]);
136
136
  * are catalogue entries and are deliberately absent here: declaring one is
137
137
  * counted demand (the request-red path), and reaching one at runtime is a
138
138
  * stop — never a red, and never a silent pass. */
139
- export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed']);
139
+ export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed', 'human-confirms']);
140
+ /** RULING 8 — the stages the seed-verdict read (D12) must NOT run. A judged
141
+ * number at seed is unstable and a person at seed is an interview, not a
142
+ * measurement; their bar comes from calibration (soft-green) or from the person
143
+ * themselves (hitl), never from the seed. The skip is RECORDED by name rather
144
+ * than taken in silence (F59: absent is not empty), so `seedRead` still returns
145
+ * one row per declared stage. */
146
+ export const SEED_EXEMPT_KINDS = Object.freeze(['judged-floor', 'human-confirms']);
147
+ /** the one kind a PERSON renders — named once, because the runner keys three
148
+ * decisions on it (is there a door to answer, which stage paused, whose ruling
149
+ * this is) and three literals is how they come to disagree */
150
+ export const HUMAN_KIND = 'human-confirms';
151
+ /** the three doors the signer is offered at a hitl pause (2026-08-12 §1, doors
152
+ * re-cut 2026-08-17). No fourth door, and no free-text-only variant: a red that
153
+ * is not one of these two is not a ruling the run can act on.
154
+ *
155
+ * `cancel` was the third door and is DELETED as a concept (hamr: *"what's the
156
+ * point of cancel anyways? pause can resume — that would be more honest"*). The
157
+ * case it served — a person who looks and does not want to carry on — is a pause
158
+ * nobody returns to, which the 60-day checkpoint TTL retires on its own. Nothing
159
+ * here forces a forever-decision at the one moment the person has least reason
160
+ * to make one. */
161
+ export const HUMAN_DECISIONS = Object.freeze(['accept', 'rerun', 'pause']);
140
162
 
141
163
  /**
142
164
  * @typedef {{code: string, path: string, detail: string}} Red
143
165
  * @typedef {{workdir: string, seedRef: string, gapKeep: string,
144
166
  * timeoutMsDefault?: number, baselineMode?: 'auto'|'worktree',
145
- * gapCap?: number, maxBuffer?: number, seedTrees?: SeedTrees}} Ctx
167
+ * gapCap?: number, maxBuffer?: number, seedTrees?: SeedTrees,
168
+ * humanRuling?: {decision: string, text?: string|null}|null,
169
+ * onStage?: (row: {stage: string|null, kind: string|null, verdict: string, durationMs: number}) => void}} Ctx
146
170
  * The arbiter's half of a stage run. Every field here is operator/runner
147
171
  * territory: a declaration parameterises kinds, never the contracts.
148
- * @typedef {{verdict: 'green'|'red'|'instrument-stop'|'not-reached',
172
+ * `humanRuling` is the SIGNER's answer at a hitl pause, carried in from the
173
+ * runner — never authored, never defaulted, and absent on every ordinary run.
174
+ * `onStage` is a REPORTING seam and nothing else: the seed read spawns a real
175
+ * toolchain per stage (a suite is the slow one) and a whole read is one opaque
176
+ * await, so the shell gets told as each stage lands. It decides nothing, it is
177
+ * never consulted, and no verdict depends on it — the house pattern is
178
+ * `runJob`'s `emit` (the library reports, the shell prints).
179
+ * @typedef {{verdict: 'green'|'red'|'instrument-stop'|'not-reached'|'pause'|'skipped',
149
180
  * exitCode: number|null, value: number|null, baseline: number|null,
150
181
  * baselineSource: string|null, gapLines: string[], judged: boolean,
151
182
  * stage: string|null, kind: string|null, detail: Record<string, any>}} StageResult
183
+ * Six outcomes, and only two of them are verdicts. `pause` is N4's fifth: the
184
+ * close reached a stage a machine cannot render and is WAITING on a person —
185
+ * neither green nor red, and never graded (the F17 forbidden-zone rule applied
186
+ * to a non-verdict). `skipped` is the sixth and says a stage was deliberately
187
+ * not run at THIS reading (ruling 8's seed exemption), which is a different
188
+ * fact from `not-reached` (an earlier stage decided first).
152
189
  * @typedef {{ensure: (workdir: string, seedRef: string) => Promise<{stop: string, fault: string}|{stop: null, dir: string}>,
153
190
  * cleanup: () => Promise<{ok: boolean, leaked: string[], detail: string|null}>}} SeedTrees
154
191
  */
@@ -1323,12 +1360,117 @@ async function runFilesChanged(stage, ctx) {
1323
1360
  });
1324
1361
  }
1325
1362
 
1363
+ /**
1364
+ * THE SIGNER'S ANSWER, normalised at the seam that accepts it — the library half
1365
+ * of the three-button gate (2026-08-12 §1).
1366
+ *
1367
+ * `null` is not a refusal: no ruling at all IS the pause path, which is how every
1368
+ * ordinary run and every first leg of a hitl run arrives here. What is refused is
1369
+ * an answer that is not one of the three doors, and — the case the POC's negative
1370
+ * control measured — a `rerun` with no text. Ruling 3 makes the human's words the
1371
+ * gap the worker converts from; an empty one launches a fix worker that re-runs
1372
+ * as though nothing had been said, which is the failure this refusal exists for.
1373
+ *
1374
+ * It never throws and never repairs: a decision that cannot be read is handed
1375
+ * back as a refusal with its reason, and the caller decides what to do about it.
1376
+ * @param {any} ruling
1377
+ * @returns {{ok: boolean, ruling: {decision: string, text: string|null}|null, why: string|null}}
1378
+ */
1379
+ export function normalizeHumanRuling(ruling) {
1380
+ if (ruling === null || ruling === undefined) return { ok: true, ruling: null, why: null };
1381
+ if (typeof ruling !== 'object' || Array.isArray(ruling)) {
1382
+ return { ok: false, ruling: null, why: `a decision is an object {decision, text?} — one of ${HUMAN_DECISIONS.join(' | ')}` };
1383
+ }
1384
+ const decision = String(/** @type {any} */ (ruling).decision);
1385
+ if (!HUMAN_DECISIONS.includes(decision)) {
1386
+ return {
1387
+ ok: false,
1388
+ ruling: null,
1389
+ why: `"${decision}" is not one of the three doors (${HUMAN_DECISIONS.join(' | ')}). There is no fourth, and no `
1390
+ + 'free-text-only variant: a red that is not one of these two is not a ruling the run can act on',
1391
+ };
1392
+ }
1393
+ const text = /** @type {any} */ (ruling).text;
1394
+ if (decision === 'rerun') {
1395
+ if (typeof text !== 'string' || text.trim().length === 0) {
1396
+ return {
1397
+ ok: false,
1398
+ ruling: null,
1399
+ why: 'a "rerun" decision needs TEXT: the human is the gap author (ruling 3), and their words are the whole of '
1400
+ + 'what the fix worker is given. An empty gap re-runs the worker as though nothing had been said',
1401
+ };
1402
+ }
1403
+ return { ok: true, ruling: { decision, text }, why: null };
1404
+ }
1405
+ return { ok: true, ruling: { decision, text: null }, why: null };
1406
+ }
1407
+
1408
+ /**
1409
+ * THE HUMAN STAGE (N4 slice 1) — the one kind that measures nothing.
1410
+ *
1411
+ * With no ruling in hand it does not RUN, it PAUSES: the fifth outcome, neither
1412
+ * green nor red, so nothing downstream grades it. With the signer's ruling in
1413
+ * hand it renders exactly what the person said — `accept` is a green a PERSON
1414
+ * judged, and `rerun` is a red whose gap is the human's own words, verbatim,
1415
+ * through the same bounded/announced channel every other stage's gap uses.
1416
+ *
1417
+ * It spawns nothing, reads nothing and touches no tree, which is why it can
1418
+ * render both without a workdir that exists.
1419
+ *
1420
+ * `pause` is deliberately NOT a case here. It is not a judgment about the tree
1421
+ * — it is the signer saying "not now" — and it is consumed above this stage, by
1422
+ * the runner and by `runPlan` before the close is ever asked (a `pause` that
1423
+ * reached this far has bypassed that seam, so it renders NO verdict rather than
1424
+ * being guessed into one). With no ruling in hand this kind pauses anyway, which
1425
+ * is the same place the person is left; what the door adds is the RECORD that
1426
+ * they looked and chose to keep the checkpoint.
1427
+ * @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>}
1428
+ */
1429
+ async function runHumanConfirms(stage, ctx) {
1430
+ const ask = stage?.params?.ask;
1431
+ const norm = normalizeHumanRuling(ctx.humanRuling ?? null);
1432
+ if (!norm.ok) {
1433
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" was handed a decision it cannot read — ${norm.why}`,
1434
+ {}, STOP_FAULTS.CRASHED);
1435
+ }
1436
+ if (norm.ruling === null) {
1437
+ // THE PAUSE. `judged: false` and `exitCode: null` together are the whole
1438
+ // contract: nothing was measured and no process ran, so a zero here would be
1439
+ // F6 in an exit code's coat.
1440
+ return result(stage, {
1441
+ verdict: 'pause',
1442
+ exitCode: null,
1443
+ judged: false,
1444
+ detail: { ask: isNonEmptyString(ask) ? ask : null },
1445
+ });
1446
+ }
1447
+ if (norm.ruling.decision === 'accept') {
1448
+ return result(stage, { verdict: 'green', exitCode: EXIT_GREEN, judged: true, detail: { ask: ask ?? null, decision: 'accept' } });
1449
+ }
1450
+ if (norm.ruling.decision === 'rerun') {
1451
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1452
+ gap.push(`${stage.name}: the signer reviewed the result and asked for another pass. Their words:`);
1453
+ gap.push(/** @type {string} */ (norm.ruling.text));
1454
+ return result(stage, {
1455
+ verdict: 'red',
1456
+ exitCode: EXIT_RED,
1457
+ judged: true,
1458
+ gapLines: gap.render(),
1459
+ detail: { ask: ask ?? null, decision: 'rerun' },
1460
+ });
1461
+ }
1462
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" was handed a "${norm.ruling.decision}" decision, which `
1463
+ + 'is the signer ending the run and is never a verdict about the tree — the runner consumes it before the close',
1464
+ {}, STOP_FAULTS.CRASHED);
1465
+ }
1466
+
1326
1467
  /** @type {Record<string, (stage: any, ctx: Ctx) => Promise<StageResult>>} */
1327
1468
  const RUNNERS = {
1328
1469
  'command-exit': runCommandExit,
1329
1470
  'count-not-worse': runCountNotWorse,
1330
1471
  'pattern-absent-in-diff': runPatternAbsentInDiff,
1331
1472
  'files-changed': runFilesChanged,
1473
+ 'human-confirms': runHumanConfirms,
1332
1474
  };
1333
1475
 
1334
1476
  /**
@@ -1387,10 +1529,41 @@ export async function seedRead(declaration, ctx) {
1387
1529
  const owned = !ctx.seedTrees;
1388
1530
  const seedTrees = ctx.seedTrees ?? makeSeedTrees();
1389
1531
  const inner = { ...ctx, seedTrees };
1532
+ /** AFTER the stage completes, never before: a line saying a stage STARTED and
1533
+ * a line saying what it RETURNED are two different facts, and only the second
1534
+ * one can carry a verdict. The duration is measured around the call rather
1535
+ * than reported by it, so a stage that stops still gets a real number.
1536
+ * @param {StageResult} row @param {number} startedAt */
1537
+ const told = (row, startedAt) => {
1538
+ if (typeof ctx.onStage === 'function') {
1539
+ ctx.onStage({ stage: row.stage, kind: row.kind, verdict: row.verdict, durationMs: Date.now() - startedAt });
1540
+ }
1541
+ return row;
1542
+ };
1390
1543
  try {
1391
1544
  /** @type {StageResult[]} */
1392
1545
  const rows = [];
1393
- for (const stage of declaration?.stages ?? []) rows.push(await runStageIn(stage, inner));
1546
+ for (const stage of declaration?.stages ?? []) {
1547
+ // RULING 8 — judged and human stages are EXEMPT from the seed read, and
1548
+ // the exemption is a ROW, not an absence (F59). A person asked at the seed
1549
+ // is being interviewed, not measured, and there is nothing to baseline.
1550
+ const startedAt = Date.now();
1551
+ if (SEED_EXEMPT_KINDS.includes(stage?.kind)) {
1552
+ rows.push(told(result(stage, {
1553
+ verdict: 'skipped',
1554
+ exitCode: null,
1555
+ judged: false,
1556
+ detail: {
1557
+ skipped: true,
1558
+ why: 'ruling 8 (2026-08-07): judged and human stages skip the seed-verdict read — a judged number at seed '
1559
+ + 'is unstable and a person at seed is an interview, not a measurement. This stage has no seed baseline '
1560
+ + 'and contributes none.',
1561
+ },
1562
+ }), startedAt));
1563
+ continue;
1564
+ }
1565
+ rows.push(told(await runStageIn(stage, inner), startedAt));
1566
+ }
1394
1567
  return rows;
1395
1568
  } finally {
1396
1569
  if (owned) await seedTrees.cleanup();
@@ -1412,7 +1585,7 @@ export async function seedRead(declaration, ctx) {
1412
1585
  * room would break a documented adopter contract for nothing; the new name says
1413
1586
  * exactly what differs. `src/declaredclose.js` is what bridges the two shapes.
1414
1587
  * @param {any} declaration @param {Ctx} ctx
1415
- * @returns {Promise<{verdict: 'green'|'red'|'instrument-stop', exitCode: number, judged: boolean, firstRed: string|null, stages: StageResult[]}>}
1588
+ * @returns {Promise<{verdict: 'green'|'red'|'instrument-stop'|'pause', exitCode: number|null, judged: boolean, firstRed: string|null, pausedAt: string|null, stages: StageResult[]}>}
1416
1589
  */
1417
1590
  export async function runDeclaredClose(declaration, ctx) {
1418
1591
  const owned = !ctx.seedTrees;
@@ -1431,12 +1604,20 @@ export async function runDeclaredClose(declaration, ctx) {
1431
1604
  stages.push(r);
1432
1605
  if (r.verdict !== 'green') ended = true;
1433
1606
  }
1607
+ // A PAUSE stops the close exactly like a red does, and is DELIBERATELY not
1608
+ // in this list: `deciding` names the stage that rendered a VERDICT, and a
1609
+ // pause rendered none. Reading it as one would fall through to green — the
1610
+ // close would mint a verdict nobody gave (the defect this line exists to
1611
+ // make impossible).
1434
1612
  const deciding = stages.find((s) => s.verdict === 'red' || s.verdict === 'instrument-stop');
1613
+ const paused = deciding ? null : stages.find((s) => s.verdict === 'pause') ?? null;
1435
1614
  return {
1436
- verdict: /** @type {'green'|'red'|'instrument-stop'} */ (deciding ? deciding.verdict : 'green'),
1437
- exitCode: deciding ? /** @type {number} */ (deciding.exitCode) : EXIT_GREEN,
1438
- judged: deciding ? deciding.judged : true,
1615
+ verdict: /** @type {'green'|'red'|'instrument-stop'|'pause'} */ (deciding ? deciding.verdict : (paused ? 'pause' : 'green')),
1616
+ exitCode: deciding ? /** @type {number} */ (deciding.exitCode) : (paused ? null : EXIT_GREEN),
1617
+ judged: deciding ? deciding.judged : (paused ? false : true),
1439
1618
  firstRed: deciding ? deciding.stage : null,
1619
+ /** the stage the close is WAITING on, when it is waiting on a person */
1620
+ pausedAt: paused ? paused.stage : null,
1440
1621
  stages,
1441
1622
  };
1442
1623
  } finally {
package/src/ledger.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // The upstream ledger (module 4) — reactive lib/primitive incident monitoring.
2
- // Design record: docs/plans/2026-07-11-upstream-ledger-design.md (adaptlearn
2
+ // Design record: docs/02-features/2026-07-11-upstream-ledger-design.md (adaptlearn
3
3
  // POC'd spec) + its 2026-07-13 addendum: the bareloop event mapping. One
4
4
  // append-only JSONL both the consumer (the panel, N6) and the maintainer read;
5
5
  // the A1/A2/A3 upstream-ask flow, mechanized — evidence in, human judgment out.
@@ -22,6 +22,7 @@
22
22
  import { readFileSync, appendFileSync, existsSync } from 'node:fs';
23
23
  import { basename } from 'node:path';
24
24
  import { createHash } from 'node:crypto';
25
+ import { HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
25
26
 
26
27
  /** severity order, worst-first (the fold's display order; frequency ranks within) */
27
28
  export const LEDGER_CLASSES = Object.freeze([
@@ -82,6 +83,28 @@ const EXCLUDED_ESCALATIONS = new Set([
82
83
  'gate-red',
83
84
  'smoke-red', // already counted via primitive-smoke
84
85
  'hitl-close', // by design: a human is the close
86
+ // N4 slice 1 — the hitl class's own terminals, and each is here for its own
87
+ // reason rather than by family resemblance. `hitl-pause` is a CHECKPOINT (the
88
+ // cap-halt story with a person instead of a budget: the run stopped because it
89
+ // reached the one stage a machine cannot render). `hitl-decision-red` is an
90
+ // honest refusal of the OPERATOR's own input, exactly like `close-unsupported`
91
+ // below, and the one direction that would be wrong is filing either of them as
92
+ // a capability gap against a library that did nothing.
93
+ //
94
+ // MINTED, never borrowed: the legacy `hitl-close` above is the deleted path's
95
+ // close TYPE and keeps its own meaning. Reusing that name would have made two
96
+ // different facts one number (the `step-stalled` lesson).
97
+ HITL_PAUSE,
98
+ HITL_DECISION_RED,
99
+ // LEGACY, read-only, and a bare STRING on purpose (the `gate-red` precedent
100
+ // above): `hitl-cancel` was the third door's terminal until 2026-08-17, when
101
+ // cancel was deleted in favour of a pause that can resume. Nothing in this tree
102
+ // mints it any more — there is no constant left to import — but this set is the
103
+ // EXECUTABLE excluded-set, and dropping a name does not delete the category: it
104
+ // re-files any spine that still CARRIES one as a counted capability gap against
105
+ // a library that did nothing. A reader must keep recognising what a writer can
106
+ // no longer write.
107
+ 'hitl-cancel',
85
108
  'close-unsupported', // honest refusal, by design
86
109
  // Close authoring (M4, D13) — the interview refusing a job we cannot close: a
87
110
  // genre we do not run, a job with no seed to measure against, a locked kind.
package/src/planrun.js CHANGED
@@ -33,8 +33,11 @@ import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
33
33
  import { validateBridge, loadGate } from './bridges.js';
34
34
  import { extractArtifact } from './text.js';
35
35
  import { createClock, isWallTimeout } from './clock.js';
36
- import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade } from './declaredclose.js';
37
- import { seedAtHead, seedListing, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES } from './kinds.js';
36
+ import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
37
+ import {
38
+ seedAtHead, seedListing, changedSet, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES,
39
+ HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling,
40
+ } from './kinds.js';
38
41
  import { workBranchName, prepareWorkBranch } from './workbranch.js';
39
42
 
40
43
  const require = createRequire(import.meta.url);
@@ -65,6 +68,11 @@ const NATIVE_READ_STRATEGY = '\nINTERFACE LIMIT: on this surface a whole-file re
65
68
  const SCOUT_MIN_BYTES = 200;
66
69
  /** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
67
70
  const ARTIFACT_MAX = 2000;
71
+ /** how many changed paths ride the hitl pause's evidence package. Bounded like
72
+ * every other list this repo puts on a spine, and the trim ANNOUNCED (F28): the
73
+ * person needs to see WHAT moved, and a run that touched a thousand files is
74
+ * telling them something a thousand lines would not. */
75
+ const PAUSE_CHANGED_CAP = 50;
68
76
  /** the wallet floor below which a replan is a stop, not an adaptation (review
69
77
  * #5): a money-gate halt drains the wallet to ~0, so replanning against dust
70
78
  * just burns another draft and mislabels the money-cut as "exits still red"
@@ -581,12 +589,24 @@ ${scoutBlob || '(no scout notes)'}`;
581
589
  * it the deterministic name would collide with that very branch and the collision walk
582
590
  * would mint a `-2` beside the progress the resume exists to keep. Absent is the cold
583
591
  * path. A recorded branch that no longer exists is a STOP, never a fresh start.
592
+ * @param {{decision: string, text?: string}|null} [opts.humanRuling] N4 — the SIGNER's
593
+ * answer at a hitl pause (2026-08-12 §1, re-cut 2026-08-17: accept | rerun <text> | pause), carried in
594
+ * by the runner on the leg that RESUMES a paused run. Absent on every ordinary run and
595
+ * on the leg that pauses; it is never authored, never defaulted, and never inferred.
596
+ *
597
+ * It is spent ONCE, on this leg's close readings up to the moment the fix loop opens:
598
+ * `accept` greens the human stage on fresh evidence (OPEN-3 — the mechanical stages
599
+ * re-run first, because the tree can change while a run is paused), `rerun` reds it
600
+ * with the human's own words as the gap, and `pause` keeps the checkpoint exactly as it
601
+ * is, before anything is read at all. After the fix loop opens the ruling is gone, so once the mechanical
602
+ * stages pass again the run PAUSES for a second review rather than converting the same
603
+ * sentence forever.
584
604
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
585
605
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
586
606
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
587
- * 'step-stalled' | `step-red:<id>`
607
+ * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
588
608
  */
589
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null }) {
609
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null }) {
590
610
  workdir = resolve(workdir);
591
611
  // ONE spelling of the redaction, housed next to the inventory it reads
592
612
  // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
@@ -638,6 +658,79 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
638
658
  return 'close-unsupported';
639
659
  }
640
660
 
661
+ // ── THE SIGNER'S ANSWER (N4 §1.4), read before anything costs anything.
662
+ //
663
+ // Three gates, and each refuses rather than guesses. A decision that is not one
664
+ // of the three doors — or a `rerun` whose text is empty — is refused at the
665
+ // seam that ACCEPTS it, because the whole of what a rerun gives the fix worker
666
+ // is the human's words, and an empty gap re-runs the worker as though nothing
667
+ // had been said (the POC's negative control measured exactly that). A decision
668
+ // handed to a close with no human stage is refused too: there is nothing for it
669
+ // to rule on, and silently ignoring it would leave a person believing they had
670
+ // answered. It is a terminal of its own rather than an `interpreter-red` — no
671
+ // library failed, the operator's own input is what could not be read.
672
+ const hasHumanStage = stagedClose.some((/** @type {any} */ s) => s?.kind === HUMAN_KIND);
673
+ const ruling = normalizeHumanRuling(humanRuling);
674
+ const decisionRed = (/** @type {string} */ detail) => {
675
+ emit('escalation', {
676
+ category: HITL_DECISION_RED, decisionReady: true,
677
+ decision: 'The decision handed to this run could not be applied, so nothing was run and nothing was spent.',
678
+ options: [
679
+ `re-run the resume with one of: ${HUMAN_DECISIONS.join(' | ')} (a rerun carries the text the worker converts)`,
680
+ 'abandon the task',
681
+ ],
682
+ detail: scrub(detail),
683
+ });
684
+ return HITL_DECISION_RED;
685
+ };
686
+ if (!ruling.ok) return decisionRed(/** @type {string} */ (ruling.why));
687
+ if (ruling.ruling !== null && !hasHumanStage) {
688
+ return decisionRed(`this job's close has no ${HUMAN_KIND} stage, so a signer's "${ruling.ruling.decision}" has `
689
+ + 'nothing to rule on — the verdict class is a promise about the CLOSE, and only a close that asks a person '
690
+ + 'can be answered by one');
691
+ }
692
+ /** the ruling still to be spent. Cleared the moment the fix loop opens: the
693
+ * human's words are what OPENS that loop, and once the worker holds them the
694
+ * next machine-clean tree is a new question for the person, not the old one. */
695
+ let liveRuling = ruling.ruling;
696
+
697
+ // ── PAUSE: the third door, and it costs nothing at all (2026-08-12 §1, doors
698
+ // re-cut 2026-08-17). Before the branch, the seed, the precheck and the clock,
699
+ // because a person who answered "not now" has asked for nothing to be done: no
700
+ // gap, no continuation, no worker round. It is deliberately NOT routed through
701
+ // the close — running four stages to arrive back at the question the person has
702
+ // just declined to answer is wall time spent on an answer already in hand.
703
+ //
704
+ // hamr's ruling, verbatim: *"what's the point of cancel anyways? pause can
705
+ // resume — that would be more honest"*. So this mints the SAME checkpoint
706
+ // terminal the machine-side pause mints, which is what keeps it resumable under
707
+ // the existing TTL: the run re-enters at the start of its last step whenever
708
+ // somebody comes back, and if nobody does, the checkpoint expires on its own.
709
+ // That expiry IS the case cancel used to serve, without forcing a person into a
710
+ // forever-decision at the moment they least want to make one.
711
+ //
712
+ // The record is EXPLICIT (`humanDecision`) rather than a silent re-pause: "a
713
+ // person looked and kept it" and "nobody has looked yet" are two different
714
+ // facts, and one record spelling both is how a reader comes to confuse them.
715
+ // The evidence package is not re-assembled here — it is already on the spine of
716
+ // the leg that asked, and this leg measured nothing, so it states nothing (F6:
717
+ // absent, never an empty list dressed as a reading).
718
+ if (liveRuling?.decision === 'pause') {
719
+ emit(HITL_PAUSE, {
720
+ stage: stagedClose.find((/** @type {any} */ s) => s?.kind === HUMAN_KIND)?.name ?? null,
721
+ humanDecision: 'pause',
722
+ decisionReady: true,
723
+ // EXPLICITLY null, never absent: every gap consumer guards with `if (gap)`,
724
+ // and the field states the ruling rather than leaving it to be inferred.
725
+ gap: null,
726
+ decision: 'The signer looked and paused: nothing was asked for, so nothing was run and nothing was spent. The '
727
+ + 'checkpoint stands exactly as it was — the work, the plan and the money are where the paused leg left them.',
728
+ options: [...HUMAN_DECISIONS],
729
+ meaning: 'not a verdict — the run is still paused, the clock is still stopped, and the same three doors are open',
730
+ });
731
+ return HITL_PAUSE;
732
+ }
733
+
641
734
  // ── native wiring: a clipipe-subscription job needs the native provider
642
735
  // FACTORY (native governance is constructor-time + per-worker, so the runner
643
736
  // cannot reuse one injected instance). A missing factory is an adopter wiring
@@ -827,7 +920,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
827
920
  * @param {any[]} stages
828
921
  */
829
922
  const runCloseStages = (stages) => (declared
830
- ? runDeclaredStages(stages, scrub, { ...closeOpts, seedRef: /** @type {any} */ (declaredCtx).seedRef })
923
+ ? runDeclaredStages(stages, scrub, {
924
+ ...closeOpts,
925
+ seedRef: /** @type {any} */ (declaredCtx).seedRef,
926
+ // the signer's answer reaches the human stage the same way every other
927
+ // arbiter-owned fact reaches a stage: through the ctx, never through the
928
+ // declaration. A command close has no human stage to hand it to.
929
+ humanRuling: liveRuling,
930
+ })
831
931
  : runStages(stages, scrub, closeOpts));
832
932
  /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
833
933
  let closeStage;
@@ -884,6 +984,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
884
984
  ...extra,
885
985
  });
886
986
  };
987
+ /**
988
+ * THE PAUSE (N4 §1.3/§1.4, rulings 1 and 2) — a decision-ready checkpoint, and
989
+ * never a bare "approve?". What the person is shown is assembled here rather
990
+ * than by whatever surface renders it: every mechanical stage's own result, the
991
+ * question the close itself declared, and the set of files this run changed.
992
+ *
993
+ * The changed set is the "before/after" half, at the granularity a library can
994
+ * state honestly — WHICH files moved. Rendering the lines belongs to the
995
+ * surface that has a screen; carrying the list is what makes the surface's job
996
+ * possible without it re-deriving the run's own facts.
997
+ * @param {any} v the close verdict that paused @returns {Promise<void>}
998
+ */
999
+ const emitHitlPause = async (v) => {
1000
+ const cs = declaredCtx ? await changedSet(workdir, declaredCtx.seedRef) : { stop: 'no seed', paths: [] };
1001
+ const paths = cs.stop === null ? cs.paths : [];
1002
+ const shown = paths.slice(0, PAUSE_CHANGED_CAP);
1003
+ emit(HITL_PAUSE, {
1004
+ stage: v.stage ?? null,
1005
+ ask: v.ask ?? null,
1006
+ decisionReady: true,
1007
+ // ruling 2: the whole evidence package, never a bare "approve?"
1008
+ stages: v.stages ?? [],
1009
+ changed: {
1010
+ paths: shown,
1011
+ // the trim is ANNOUNCED (F28), and an unreadable set is UNKNOWN, never
1012
+ // an empty list dressed as "nothing changed" (F6)
1013
+ ...(paths.length > shown.length ? { more: paths.length - shown.length } : {}),
1014
+ ...(cs.stop === null ? {} : { unreadable: scrub(String(cs.stop)) }),
1015
+ },
1016
+ decision: `The close reached its human stage and is waiting on you: ${v.ask ?? 'review the result'}`,
1017
+ options: [...HUMAN_DECISIONS],
1018
+ meaning: 'not a verdict — the run is paused, the clock is stopped, and the last thing it did stands until you answer',
1019
+ });
1020
+ };
1021
+
887
1022
  /** the close, as ONE verdict: every stage in order, first red wins */
888
1023
  const judgeClose = async () => {
889
1024
  const v = await runCloseStages(stagedClose);
@@ -1057,6 +1192,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1057
1192
  const pre = await judgeClose();
1058
1193
  emit('close-precheck', { ...pre });
1059
1194
  if (pre.verdict === 'satisfied') return 'already-green';
1195
+ // …and the pause's own arm of the same gate. Reaching a human stage HERE means
1196
+ // every mechanical stage already passes on the untouched tree: the machine half
1197
+ // of this job is done and the only thing left is a person. Pausing costs $0 and
1198
+ // is the honest answer; running a plan first would spend a budget to arrive at
1199
+ // exactly this question. No `planExecuted` — there is no plan yet, and an empty
1200
+ // one on the spine would be a record of work nobody did.
1201
+ if (pre.verdict === HUMAN_PAUSE) { await emitHitlPause(pre); return HITL_PAUSE; }
1060
1202
  const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
1061
1203
  if (preFault) {
1062
1204
  emit('escalation', { category: preFault.category, decisionReady: true, decision: preFault.decision, options: preFault.options, detail: pre.detail });
@@ -2579,6 +2721,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2579
2721
  planExecuted();
2580
2722
  return 'green';
2581
2723
  }
2724
+ // ── THE THIRD BRANCH (N4 §1.3): between `satisfied` and the fix loop, because
2725
+ // a pause is neither. It is not green (nobody said so), not a red (nobody has
2726
+ // written a gap), and not a CLOSE_FAULT (nothing broke) — so it must be caught
2727
+ // before the fix loop, which would otherwise buy a worker round to convert a
2728
+ // wall that does not exist yet.
2729
+ if (post.verdict === HUMAN_PAUSE) {
2730
+ await emitHitlPause(post);
2731
+ planExecuted();
2732
+ return HITL_PAUSE;
2733
+ }
2582
2734
  const postFault = Object.hasOwn(CLOSE_FAULTS, post.verdict) ? CLOSE_FAULTS[post.verdict] : undefined;
2583
2735
  if (postFault) {
2584
2736
  emit('escalation', { category: postFault.category, decisionReady: true, decision: postFault.decision, options: postFault.options, detail: post.detail });
@@ -2586,6 +2738,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2586
2738
  return 'close-red';
2587
2739
  }
2588
2740
  emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
2741
+ // ── THE RULING IS SPENT HERE (N4 §1.5). The human's words are what OPENS this
2742
+ // loop; the worker now holds them. Leaving the ruling live would make the human
2743
+ // stage red on every judgment for the rest of the leg, so the loop would keep
2744
+ // converting one sentence until the governor or the money stopped it. Cleared,
2745
+ // the next machine-clean tree pauses for a SECOND review — which is the honest
2746
+ // cycle: a person asked for a change, the change happened, the person looks
2747
+ // again. A mechanical red in between is still converted normally, because
2748
+ // first-red-wins reaches it before the person's stage.
2749
+ liveRuling = null;
2589
2750
  let fixOutcome;
2590
2751
  /** @type {object|null} W-2 — set when the WALL stopped the fix loop BETWEEN
2591
2752
  * iterations. It carries that stop's own record fields, and its presence is what
@@ -2667,6 +2828,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2667
2828
  const fixGovernor = {
2668
2829
  record: (/** @type {{iteration: number, gap?: string}} */ o) => {
2669
2830
  fixIterationsUsed = o.iteration;
2831
+ // A PAUSE IS NEVER GRADED. ralph records every non-satisfied iteration,
2832
+ // and a pause is one — but it carries no gap and no number, so feeding it
2833
+ // to the trend would mint a strike out of a non-verdict and could strike
2834
+ // the loop out into a `cap-halt` that never happened. The reading says
2835
+ // what it is and the meter is left untouched; `middle` stops the loop on
2836
+ // the next iteration, before anything is bought.
2837
+ if (lastCloseVerdict?.verdict === HUMAN_PAUSE) {
2838
+ return { governor: 'close-trend', trend: 'unknown', reading: 'the close is waiting on a person — not a grade', iteration: o.iteration, paused: true };
2839
+ }
2670
2840
  return { governor: 'close-trend', ...fixTrend.record({ gap: o.gap }), iteration: o.iteration };
2671
2841
  },
2672
2842
  struckOut: fixTrend.struckOut,
@@ -2708,6 +2878,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2708
2878
  // until the next iteration is about to start. The close is never bounded — a
2709
2879
  // wall that kills grading leaves the run unreadable after the money is spent
2710
2880
  // (the F45 class), so the one thing this must never do is stop a close.
2881
+ // ── THE CLOSE IS WAITING ON A PERSON. Read here, in the same place and for
2882
+ // the same reason the wall is: past this point the iteration is guaranteed
2883
+ // to be worthless, because the machine half of the close already passes and
2884
+ // nothing a worker writes can answer a question only a person can. So the
2885
+ // loop stops ON that reading — the run pauses again, with everything it has
2886
+ // done kept, exactly as the first pause did.
2887
+ if (lastCloseVerdict?.verdict === HUMAN_PAUSE) {
2888
+ const err = /** @type {CategorizedError} */ (new Error(
2889
+ `the close is waiting on the signer at stage "${lastCloseVerdict.stage ?? 'unknown'}" — every mechanical `
2890
+ + `stage passes, so fix attempt ${iteration} could not change the answer`));
2891
+ err.category = HITL_PAUSE;
2892
+ throw err;
2893
+ }
2711
2894
  if (clock.expired()) {
2712
2895
  // ONE progress instrument, shared with the money halt (PRD v1.46 §2/§4).
2713
2896
  // This site used to run its own `gapTrend`: byte equality of the last two
@@ -2789,6 +2972,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2789
2972
  return relay(e, 'fix');
2790
2973
  }
2791
2974
  planExecuted();
2975
+ // The fix loop ran into the person again (the `middle` stop above). Same
2976
+ // terminal as the first pause, and never `escalated`: nothing failed, and the
2977
+ // work this loop did is exactly what the signer is now being asked about.
2978
+ if (fixOutcome !== 'green' && lastEscalation?.category === HITL_PAUSE) {
2979
+ await emitHitlPause(lastCloseVerdict);
2980
+ return HITL_PAUSE;
2981
+ }
2792
2982
  if (fixOutcome !== 'green' && lastEscalation?.category === 'wall-halt') {
2793
2983
  // F64 in the close-fix loop: the same governance stop, and it must not ride out
2794
2984
  // as a bare `escalated` either — that reads as "the fix failed" when the fix was
package/src/ralph.js CHANGED
@@ -629,6 +629,15 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
629
629
  // interpreter" would aim a human at the one component that did nothing wrong.
630
630
  'step-stalled': ['The model stopped producing rounds and reissuing the call did not recover it — a stall, not a fault.',
631
631
  ['let the planner re-allocate what is left (replan)', 'retry the run', 'check provider status', 'abandon the task']],
632
+ // N4 — the close is WAITING ON A PERSON. Nothing failed and nothing is
633
+ // discarded: every mechanical stage passes and the one stage left is a
634
+ // question no worker can answer, so the loop stopped rather than buying
635
+ // an attempt that could not change the answer. The caller renders the
636
+ // real decision-ready package (`hitl-pause`, with the evidence); this
637
+ // entry exists so the passthrough never narrates a paused run as a
638
+ // broken middle.
639
+ 'hitl-pause': ['The close reached its human stage — the run is paused, decision-ready, and the clock is stopped.',
640
+ ['accept', 'rerun (your words become the gap the worker converts)', 'pause (the checkpoint keeps, and it resumes)']],
632
641
  };
633
642
  // Object.hasOwn, not bare lookup: a category named after an
634
643
  // Object.prototype member ("toString") would return the inherited