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.
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
  */
@@ -346,24 +383,55 @@ export async function seedListing(workdir, seedRef) {
346
383
  // ── contract (c): the changed-set primitive ──────────────────────────────────
347
384
 
348
385
  /**
349
- * The two arbiter books, and the shape of each exclusion is the whole point.
386
+ * The arbiter books, and the shape of each exclusion is the whole point.
350
387
  *
351
- * `.litectx/` is a PREFIX — everything beneath the store belongs to the
352
- * arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and never a
353
- * basename or a pattern: the gate audit is written to
388
+ * `.litectx/` and `.smoke/` are PREFIXES — everything beneath either store
389
+ * belongs to the arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and
390
+ * never a basename or a pattern: the gate audit is written to
354
391
  * `join(workdir, 'gate-audit.jsonl')` and nowhere else, so a worker-authored
355
392
  * `src/gate-audit.jsonl` is NOT an arbiter book and must still count as work.
356
393
  * Matching by basename would swallow it — and swallowing a worker's real
357
- * write is the failure direction that reads as a clean tree.
394
+ * write is the failure direction that reads as a clean tree. `src/smoke.js` and
395
+ * `tests/smoke.test.js` are that same decoy for the store below.
396
+ *
397
+ * `.smoke/` — F98's parked residual, closed here. The fence has always denied
398
+ * `join(workdir, '.smoke')` on every worker (src/planrun.js, src/authorscout.js)
399
+ * and the persona now states the rule; this reader, which decides what COUNTS AS
400
+ * WORK, was the one place that never knew the name. On patients whose .gitignore
401
+ * default-denies dot-directories the store never reaches `ls-files --others` and
402
+ * the gap is masked — by the PATIENT's config, not by anything here. Incidental
403
+ * masking is not an exclusion: on a patient without that line the arbiter's own
404
+ * store would enter the changed set as the worker's writing.
358
405
  * @param {string} rel a repo-relative POSIX path
359
406
  * @returns {string|null} the book it belongs to, or null
360
407
  */
361
408
  export function isArbiterBook(rel) {
362
- if (rel === '.litectx' || rel.startsWith('.litectx/')) return '.litectx/';
363
- if (rel === 'gate-audit.jsonl') return 'gate-audit.jsonl';
409
+ for (const store of ARBITER_BOOK_STORES) {
410
+ if (rel === store || rel.startsWith(`${store}/`)) return `${store}/`;
411
+ }
412
+ if (rel === GATE_AUDIT_FILE) return GATE_AUDIT_FILE;
364
413
  return null;
365
414
  }
366
415
 
416
+ /**
417
+ * The arbiter's book NAMES, one home. F98's mechanism was exactly this list
418
+ * living as independent literals: the fence deny arrays (src/planrun.js,
419
+ * src/authorscout.js) knew `.smoke` while `isArbiterBook` — the reader that
420
+ * decides what COUNTS AS WORK — did not, and the drift was masked by the
421
+ * patient's own .gitignore. Every consumer (the fence deny lists, this file's
422
+ * reader, the persona's stated law, run.js's smoke-store root) now spells the
423
+ * names from here, so adding or renaming a book is one edit that cannot leave
424
+ * a site behind.
425
+ *
426
+ * `ARBITER_BOOK_STORES` are directory PREFIXES; `GATE_AUDIT_FILE` is an EXACT
427
+ * repo-relative path (see `isArbiterBook`'s docstring for why never a
428
+ * basename). Order is the fence deny-list order and the persona's prose order.
429
+ */
430
+ export const SMOKE_STORE = '.smoke';
431
+ export const LITECTX_STORE = '.litectx';
432
+ export const GATE_AUDIT_FILE = 'gate-audit.jsonl';
433
+ export const ARBITER_BOOK_STORES = Object.freeze([SMOKE_STORE, LITECTX_STORE]);
434
+
367
435
  /**
368
436
  * Everything this run changed against the seed: the tracked diff PLUS
369
437
  * untracked files (an untracked file has no diff — the case a naive
@@ -699,13 +767,36 @@ export function normalizeParser(parser, at = 'parser') {
699
767
  * that matched BEFORE the filter judged them — kept, unattributable and dropped
700
768
  * alike — and it is the only evidence of liveness a caller has once the scope
701
769
  * has removed the whole population. It is never the number being read.
770
+ *
771
+ * `matched` is the KEPT lines themselves, PER TERM and index-aligned with
772
+ * `breakdown`: entry i holds, in output order, exactly the lines term i's
773
+ * subtotal was computed FROM. Run u-msn227nq paid for the echo — the executor
774
+ * counted 8 real `error TS…` lines and told the worker "8 match(es)", discarding
775
+ * every address, and a worker with a number and no file to open went looking for
776
+ * the arbiter's own books instead.
777
+ *
778
+ * TWO EXCLUSIONS, both of them "this line is not what the count is made of":
779
+ * - a scope-DROPPED line is never in here. It was never counted, so aiming the
780
+ * worker at it would aim it outside its own population.
781
+ * - under `first`, only the line the aggregate actually READ. The later matches
782
+ * are real matches (`breakdown[i].matches` still counts them) but the value
783
+ * never contained them, and echoing them points the worker at a number that
784
+ * is not in the reading.
785
+ *
786
+ * And the GROUPING is the third: a term's sign lives in `breakdown[i]`, so lines
787
+ * pooled flat across terms lose the one fact that says what they DID to the
788
+ * number. A subtracted term's line rendered beside an added one reads as a wall
789
+ * to go fix, and fixing it moves the count the wrong way. Deduplication is
790
+ * therefore within a term only — one line matched by two terms plays two
791
+ * arithmetic roles and is named under each. The count is computed from `values`,
792
+ * never from this list.
702
793
  * @param {string} output @param {any[]} terms normalised terms
703
794
  * Every stop here is `crashed` in the arbiter's vocabulary — the command RAN,
704
795
  * came back, and the number could not be read out of it. Nothing in this
705
796
  * function can observe a spawn failure, a timeout or a signal, so it never
706
797
  * claims one.
707
798
  * @param {{scope?: any, workdir?: string}} [o]
708
- * @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number}}
799
+ * @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number, matched: string[][]}}
709
800
  */
710
801
  export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
711
802
  let value = 0;
@@ -715,6 +806,10 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
715
806
  const breakdown = [];
716
807
  /** @type {string[]} */
717
808
  const notes = [];
809
+ // the KEPT lines PER TERM, in output order and deduplicated within the term —
810
+ // see the note above
811
+ /** @type {string[][]} */
812
+ const matched = [];
718
813
  const filtering = scopeFilters(scope);
719
814
  // one resolver (and one realpath cache) per call, built only when a filter
720
815
  // will actually read it
@@ -739,6 +834,9 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
739
834
  try { re = new RegExp(t.lineMatch, 'g'); } catch (e) { return { stop: `INSTRUMENT: term ${i} lineMatch /${t.lineMatch}/ is not a valid regex: ${String(/** @type {any} */ (e)?.message ?? e)}`, fault: STOP_FAULTS.CRASHED }; }
740
835
  /** @type {number[]} */
741
836
  const values = [];
837
+ /** @type {string[]} */
838
+ const kept = [];
839
+ const seen = new Set();
742
840
  let dropped = 0;
743
841
  let unattributable = 0;
744
842
  for (const line of region.split('\n')) {
@@ -750,6 +848,10 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
750
848
  if (p === null) unattributable += 1;
751
849
  else if (!inScope(p, scope, phys)) { dropped += 1; continue; }
752
850
  }
851
+ // past the filter, so this line is a CANDIDATE for the harvest — kept here
852
+ // rather than at aggregation because this is the only place the raw line
853
+ // still exists, and narrowed to what the aggregate actually read below
854
+ if (!seen.has(line)) { seen.add(line); kept.push(line); }
753
855
  for (const h of hits) {
754
856
  if (t.capture === null) { values.push(1); continue; }
755
857
  const rawCapture = h[t.capture];
@@ -789,10 +891,14 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
789
891
  }
790
892
  subtotal = values[0];
791
893
  }
894
+ // `first` read ONE line — `kept[0]`, since `values[0]` is the first hit on
895
+ // the first kept line. The rest matched (and `matches` below still says so)
896
+ // but are not what this subtotal is made of.
897
+ matched.push(t.aggregate === 'first' ? kept.slice(0, 1) : kept);
792
898
  breakdown.push({ term: i, lineMatch: t.lineMatch, aggregate: t.aggregate, sign: t.sign, matches: values.length, subtotal, contribution: t.sign * subtotal });
793
899
  value += t.sign * subtotal;
794
900
  }
795
- return { stop: null, value, breakdown, notes, preScopeMatches };
901
+ return { stop: null, value, breakdown, notes, preScopeMatches, matched };
796
902
  }
797
903
 
798
904
  // ── D12: the baseline is MEASURED at this run's own seed, every run ──────────
@@ -1084,7 +1190,7 @@ async function runCountNotWorse(stage, ctx) {
1084
1190
  notes: [],
1085
1191
  };
1086
1192
  }
1087
- return { stop: null, value: v.value, breakdown: v.breakdown, notes: v.notes, exit: r.code, dropped: r.dropped, fault: STOP_FAULTS.FAILED };
1193
+ return { stop: null, value: v.value, breakdown: v.breakdown, notes: v.notes, matched: v.matched, exit: r.code, dropped: r.dropped, fault: STOP_FAULTS.FAILED };
1088
1194
  };
1089
1195
 
1090
1196
  const now = await measure(ctx.workdir, 'the current tree');
@@ -1126,8 +1232,30 @@ async function runCountNotWorse(stage, ctx) {
1126
1232
  if (worse) {
1127
1233
  gap.push(`${stage.name}: ${value} against a baseline of ${baseline} (${p.direction}) — worse`);
1128
1234
  gap.push(` baseline ${baselineSource}`);
1129
- for (const b of /** @type {any[]} */ (now.breakdown)) {
1235
+ // …each term stating itself, and THEN the lines that term's subtotal is made
1236
+ // of, nested under it. A count with no addresses is the semantic genre F38
1237
+ // measured as inert: run u-msn227nq's worker was handed "8 match(es)", had
1238
+ // nowhere to open, and probed the arbiter's books until the deny guard ended
1239
+ // the run. The instrument NAMED these lines — echoing what it named is the
1240
+ // same licence `pattern-absent-in-diff` already runs under, not the barred
1241
+ // move of naming a culprit the instrument never reported.
1242
+ //
1243
+ // NESTED, not pooled, because a line's arithmetic role is the term's and only
1244
+ // the term's row carries it. `value` is a SIGNED sum: a term declared
1245
+ // `sign: -1` subtracts, so its lines are ones the count went DOWN for, and a
1246
+ // worker handed them flat beside the added ones reads every line as a wall
1247
+ // and "fixes" one that moves the number the wrong way. The row directly above
1248
+ // each block already says `contributes +n` / `-n`; sitting the lines under it
1249
+ // is what makes that statement reach them. Nothing is withheld — under
1250
+ // `higher-is-better` the subtracted term is exactly where the news usually is
1251
+ // — and nothing is relabelled: the lines are still verbatim, one indent in.
1252
+ //
1253
+ // They ride the Gap like every other line: the stage prefix (Layer R's
1254
+ // redKeep derives from it), the cap, and the trim marker on overflow.
1255
+ const perTerm = /** @type {string[][]} */ (now.matched ?? []);
1256
+ for (const [i, b] of /** @type {any[]} */ (now.breakdown).entries()) {
1130
1257
  gap.push(` term ${b.term} /${b.lineMatch}/ ${b.aggregate}: ${b.matches} match(es), subtotal ${b.subtotal}, contributes ${b.contribution >= 0 ? '+' : ''}${b.contribution}`);
1258
+ for (const l of perTerm[i] ?? []) gap.push(` ${l}`);
1131
1259
  }
1132
1260
  }
1133
1261
  return result(stage, {
@@ -1232,12 +1360,117 @@ async function runFilesChanged(stage, ctx) {
1232
1360
  });
1233
1361
  }
1234
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
+
1235
1467
  /** @type {Record<string, (stage: any, ctx: Ctx) => Promise<StageResult>>} */
1236
1468
  const RUNNERS = {
1237
1469
  'command-exit': runCommandExit,
1238
1470
  'count-not-worse': runCountNotWorse,
1239
1471
  'pattern-absent-in-diff': runPatternAbsentInDiff,
1240
1472
  'files-changed': runFilesChanged,
1473
+ 'human-confirms': runHumanConfirms,
1241
1474
  };
1242
1475
 
1243
1476
  /**
@@ -1296,10 +1529,41 @@ export async function seedRead(declaration, ctx) {
1296
1529
  const owned = !ctx.seedTrees;
1297
1530
  const seedTrees = ctx.seedTrees ?? makeSeedTrees();
1298
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
+ };
1299
1543
  try {
1300
1544
  /** @type {StageResult[]} */
1301
1545
  const rows = [];
1302
- 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
+ }
1303
1567
  return rows;
1304
1568
  } finally {
1305
1569
  if (owned) await seedTrees.cleanup();
@@ -1321,7 +1585,7 @@ export async function seedRead(declaration, ctx) {
1321
1585
  * room would break a documented adopter contract for nothing; the new name says
1322
1586
  * exactly what differs. `src/declaredclose.js` is what bridges the two shapes.
1323
1587
  * @param {any} declaration @param {Ctx} ctx
1324
- * @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[]}>}
1325
1589
  */
1326
1590
  export async function runDeclaredClose(declaration, ctx) {
1327
1591
  const owned = !ctx.seedTrees;
@@ -1340,12 +1604,20 @@ export async function runDeclaredClose(declaration, ctx) {
1340
1604
  stages.push(r);
1341
1605
  if (r.verdict !== 'green') ended = true;
1342
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).
1343
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;
1344
1614
  return {
1345
- verdict: /** @type {'green'|'red'|'instrument-stop'} */ (deciding ? deciding.verdict : 'green'),
1346
- exitCode: deciding ? /** @type {number} */ (deciding.exitCode) : EXIT_GREEN,
1347
- 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),
1348
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,
1349
1621
  stages,
1350
1622
  };
1351
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([
@@ -75,9 +76,35 @@ const EXCLUDED_ESCALATIONS = new Set([
75
76
  // real upstream ask would be sourced from anyway.
76
77
  'step-stalled',
77
78
  'step-variance', // A's replan trigger: a planning story, never a lib bug
78
- 'gate-red', // governance working as intended
79
+ // Governance working as intended — and no longer minted anywhere in this tree
80
+ // (a denial streak is a bounded attempt, not a terminal). Kept listed because
81
+ // this set is the EXECUTABLE excluded-set: dropping a name does not delete the
82
+ // category, it re-files any future emission of it as a counted capability gap.
83
+ 'gate-red',
79
84
  'smoke-red', // already counted via primitive-smoke
80
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',
81
108
  'close-unsupported', // honest refusal, by design
82
109
  // Close authoring (M4, D13) — the interview refusing a job we cannot close: a
83
110
  // genre we do not run, a job with no seed to measure against, a locked kind.