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/ralph.js CHANGED
@@ -590,6 +590,11 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
590
590
  const DECISIONS = {
591
591
  'cap-halt': [`Budget gate tripped mid-run (${e && e.message}). Continue with a higher cap, change approach, or stop?`,
592
592
  ['raise the cap and rerun', 'change the middle/harness', 'abandon the task']],
593
+ // No site in this tree mints `gate-red` any more: a BA-11 denial streak is
594
+ // a bounded attempt now (planrun's two `ask`s), not a terminal, because the
595
+ // fence HOLDING is governance working, not a fault. The entry stays because
596
+ // this catch is a dumb passthrough — it files whatever category the thrower
597
+ // names, so an unlisted one would read out with the generic text.
593
598
  'gate-red': ['The gate denied an action mid-run — the harness asked for something outside its scope.',
594
599
  ['widen the write scope deliberately', 'change the middle/harness', 'abandon the task']],
595
600
  'provider-red': ['The provider path failed mid-run (transport, not logic) — no verdict exists and the spend for the failed call is unknown (F6).',
@@ -624,6 +629,15 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
624
629
  // interpreter" would aim a human at the one component that did nothing wrong.
625
630
  'step-stalled': ['The model stopped producing rounds and reissuing the call did not recover it — a stall, not a fault.',
626
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)']],
627
641
  };
628
642
  // Object.hasOwn, not bare lookup: a category named after an
629
643
  // Object.prototype member ("toString") would return the inherited
package/src/reuse.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — the D7 ENVELOPE and the REUSE RUNNER (design record
2
- // docs/plans/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
2
+ // docs/02-features/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
3
3
  //
4
4
  // hamr's sentence is the whole specification of this file, verbatim:
5
5
  //
@@ -52,6 +52,12 @@ import { closeStagesOf } from './plan.js';
52
52
  import { MIN_WALL_MS, jobSpecHash } from './job.js';
53
53
  import { isObj, isNonEmptyString } from './validate.js';
54
54
  import { readGrade } from './trend.js';
55
+
56
+ /** the paused-run TERMINAL and its spine record share one name (`runPlan` returns
57
+ * it and `runJob` writes it onto `job-end`), spelled once in `declaredclose.js`
58
+ * beside the close-verdict word it translates, so the emitters, the checkpoint
59
+ * list, and the TTL gate below cannot drift from each other */
60
+ import { HITL_PAUSE, HUMAN_CHECKPOINTS } from './declaredclose.js';
55
61
  import { extractArtifact, priceOf } from './text.js';
56
62
  import { runJob as shippedRunJob } from './run.js';
57
63
 
@@ -84,6 +90,34 @@ const { Loop } = require('bare-agent');
84
90
  */
85
91
  export const REUSE_GRADED_RED = Object.freeze(['escalated']);
86
92
 
93
+ /**
94
+ * WHICH TERMINALS ARE A CHECKPOINT — the list a runner hands `readResume` as
95
+ * `resumableOutcomes`, minted HERE rather than in whichever script happens to
96
+ * launch a run.
97
+ *
98
+ * `readResume` deliberately takes it as a parameter and defaults to empty, and
99
+ * that stays true: the reuse loop's own semantics depend on the empty default.
100
+ * What did not exist was a canonical ANSWER, so `scripts/run-u.mjs` kept its own
101
+ * copy — and the exported bundle (PRD v1.44 §2: a thin runner with bareloop as a
102
+ * dependency) would have had to keep a third. The same reasoning that put the
103
+ * pause TTL in the library (OPEN-2, hamr, 2026-08-13) puts this here.
104
+ *
105
+ * Every entry is a stop that left WORK ON DISK and an allowance unspent — money,
106
+ * time, a self-healed stall, and now a person who has not answered yet. A verdict
107
+ * already rendered is never on this list: `green`/`escalated` are graded rows, and
108
+ * re-buying one would pay twice for an answer already in hand. (The signer's own
109
+ * `hitl-cancel` terminal used to be the other example; the door was deleted on
110
+ * 2026-08-17 — a person who does not want to carry on pauses, and the checkpoint
111
+ * they keep is retired by the TTL rather than by a terminal.)
112
+ *
113
+ * The human half is SPLICED IN from `declaredclose.js` rather than spelled again,
114
+ * because the try loop below reads the two halves differently and a second copy is
115
+ * how they would come to disagree about which member is which: the first three are
116
+ * allowances the loop's remaining tries are meant to spend against, and the human
117
+ * ones are the stop no further try can change (see `hardStop`).
118
+ */
119
+ export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', ...HUMAN_CHECKPOINTS]);
120
+
87
121
  /**
88
122
  * v1's definition of "the same KIND of recipe" — and it is the LOAD GATE's own: the
89
123
  * close-stage names, in the same order (`bridges.loadGate` rule 2). One predicate,
@@ -418,13 +452,24 @@ function readTry(events) {
418
452
  };
419
453
  }
420
454
 
421
- /** the three-way verdict read of a runJob outcome, in ONE place: a green, the one
422
- * GRADED red that demotes (D6), or a casualty that keeps its own name. Both the live
423
- * try loop and the resume reader classify through this — two spellings of "what kind
424
- * of answer was that" is how a casualty ends up demoting a recipe.
425
- * @param {string} outcome @returns {'green'|'red'|'casualty'} */
455
+ /** the verdict read of a runJob outcome, in ONE place: a green, the one GRADED red
456
+ * that demotes (D6), a CHECKPOINT waiting on a person, or a casualty that keeps its
457
+ * own name. Both the live try loop and the resume reader classify through this — two
458
+ * spellings of "what kind of answer was that" is how a casualty ends up demoting a
459
+ * recipe.
460
+ *
461
+ * `checkpoint` is its own name rather than a fourth flavour of casualty (2026-08-17).
462
+ * A casualty is a run that DIED — a transport fault, a governance cut, a broken close
463
+ * — and a reader that sees one is being told the attempt is over. A human checkpoint
464
+ * is the opposite fact in every respect: nothing failed, nothing is discarded, the
465
+ * work and the money are exactly where they were, and the allowance still unspent is
466
+ * a person's answer. It behaves like neither a red nor a casualty everywhere it
467
+ * matters already (`hardStop` ends the loop on it, the box is left untouched), so the
468
+ * ROW saying "casualty" was the one place the machinery still mis-described itself.
469
+ * @param {string} outcome @returns {'green'|'red'|'checkpoint'|'casualty'} */
426
470
  function verdictClassOf(outcome) {
427
471
  if (outcome === 'green' || outcome === 'already-green') return 'green';
472
+ if (HUMAN_CHECKPOINTS.includes(outcome)) return 'checkpoint';
428
473
  return REUSE_GRADED_RED.includes(outcome) ? 'red' : 'casualty';
429
474
  }
430
475
 
@@ -665,7 +710,7 @@ function readGradeSeed(seen) {
665
710
  * which is later and better evidence than the last event). Defaults to the last
666
711
  * event's own timestamp — the last sign of life there is.
667
712
  * @returns {any} `{ started, approvalHash, specHash, patient, job, bridgeTries,
668
- * perTryBudgetUsd, perTryWallMs, ended, endOutcome, greened, completed, tried,
713
+ * perTryBudgetUsd, perTryWallMs, ended, endOutcome, wallDerivedHalt, greened, completed, tried,
669
714
  * selectionCostUsd, selectionComplete, strayRounds, r1Missing, restart, deathAtKnown,
670
715
  * spentUsd, spendComplete, carrySpentUsd, carrySpendComplete }`
671
716
  */
@@ -816,7 +861,50 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
816
861
  * CHECKPOINT and re-entering it is the ruling, not a second payment for an answer
817
862
  * already in hand. Empty by default, which is what keeps the reuse loop's own
818
863
  * graded-row semantics exactly where they were. */
819
- const haltedResumable = open?.jobEnd ? resumableHalt.has(String(open.jobEnd.outcome)) : false;
864
+ const recordedOutcome = open?.jobEnd ? String(open.jobEnd.outcome) : null;
865
+ /** D4a — the MISLABELLED wall stop, derived from the run's own primary records.
866
+ *
867
+ * `step-red` is a verdict-shaped terminal and stays non-resumable: a red is an
868
+ * answer. But on u-msmt91t3 the runner minted one 9.9 seconds AFTER its own
869
+ * `wall-bounded` record — a governance stop wearing a capability label, because the
870
+ * step-variance terminal did not re-read the clock (fixed at the source, src/planrun.js).
871
+ * Spines already written under the defect are not re-runnable and cannot be edited (a
872
+ * spine is append-only forever), so the READER derives the truth the file already
873
+ * holds: a run whose own record says the wall was crossed BEFORE it ended is a wall
874
+ * stop, and W-2's ruling applies — it keeps the grades it minted and pauses.
875
+ *
876
+ * Three constraints, each of them load-bearing:
877
+ * * a DERIVATION, never an override. The evidence is the run's own `wall-bounded`
878
+ * event, emitted by the runner at the round seam; no caller can assert this.
879
+ * * `step-red` ONLY, and only when `wall-halt` is itself resumable for this caller.
880
+ * Every other outcome — green, escalated, provider-red, a step-red with no wall
881
+ * record — is untouched, so "a red is an answer" survives intact.
882
+ * * it touches NO close verdict. `step-red` here is a GOVERNANCE stop label; the
883
+ * grades the close rendered are exactly what they were, and the resume seeds
884
+ * from them.
885
+ * @param {any[]} seen @param {any} jobEnd */
886
+ const wallCrossedBefore = (seen, jobEnd) => {
887
+ // strictly BEFORE the terminal: a record written after the run ended could not have
888
+ // been what stopped it. `indexOf` on the window's own array is the ordering there is
889
+ // (the reader never re-sorts), and a jobEnd somehow absent from it reads nothing.
890
+ const end = seen.indexOf(jobEnd);
891
+ if (end < 0) return false;
892
+ for (let i = 0; i < end; i += 1) {
893
+ const e = seen[i];
894
+ if (!isObj(e) || e.type !== 'wall-bounded' || e.bounded !== true) continue;
895
+ // the clock's OWN two spellings of "the deadline passed", both off `clock.report`.
896
+ // Never a re-derivation from timestamps: a second arithmetic for one question is
897
+ // how two instruments come to disagree.
898
+ if (e.remainingMs === 0) return true;
899
+ if (typeof e.elapsedMs === 'number' && typeof e.requestedMs === 'number' && e.elapsedMs >= e.requestedMs) return true;
900
+ }
901
+ return false;
902
+ };
903
+ const wallDerivedHalt = recordedOutcome === 'step-red'
904
+ && resumableHalt.has('wall-halt')
905
+ && wallCrossedBefore(open.seen, open.jobEnd);
906
+ const haltedResumable = recordedOutcome !== null
907
+ && (resumableHalt.has(recordedOutcome) || wallDerivedHalt);
820
908
  if (open) {
821
909
  if (open.jobEnd && !haltedResumable) {
822
910
  // GRADED: runJob returned and the close had its say. Not a restart — the row is
@@ -963,7 +1051,14 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
963
1051
  perTryBudgetUsd: head?.perTryBudgetUsd ?? directHead?.budgetUsd ?? null,
964
1052
  perTryWallMs: head?.perTryWallMs ?? null,
965
1053
  ended: end !== null || (directEnd !== null && !haltedResumable),
1054
+ // the RECORDED terminal, always — the derivation below never rewrites what the run
1055
+ // said about itself, it only says how this reader read it
966
1056
  endOutcome: end?.outcome ?? directEnd?.outcome ?? null,
1057
+ /** D4a — true when a recorded `step-red` was re-read as a wall-halt off this run's
1058
+ * OWN `wall-bounded` record. Surfaced rather than silent: an operator resuming a
1059
+ * run whose spine says `step-red` must be told which record turned it into a
1060
+ * checkpoint, or the gate looks like it simply stopped refusing. */
1061
+ wallDerivedHalt,
967
1062
  greened: completed.some((t) => t.verdictClass === 'green'),
968
1063
  completed,
969
1064
  tried,
@@ -973,6 +1068,81 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
973
1068
  };
974
1069
  }
975
1070
 
1071
+ /**
1072
+ * THE PAUSE TTL (2026-08-12 §2): a hitl checkpoint is kept for 60 days.
1073
+ *
1074
+ * OPEN-2, RULED (hamr, in-turn, 2026-08-13): **the TTL lives in the LIBRARY**, so
1075
+ * the exported bundle (a headless runner with bareloop as a dependency, PRD v1.44
1076
+ * §2) inherits it instead of every runner re-implementing it. A script-only TTL
1077
+ * would vanish on export — the one place the rule is most needed and least
1078
+ * likely to be re-typed.
1079
+ */
1080
+ export const PAUSE_TTL_MS = 60 * 24 * 60 * 60_000;
1081
+
1082
+ /**
1083
+ * The AGE gate for a paused checkpoint — the sibling of `resumeTreeGate`, and it
1084
+ * asks the other question a 60-day-old checkpoint raises. The tree gate asks
1085
+ * *is this still the tree the run left?*; this asks *is this still a decision
1086
+ * anyone is waiting on?*
1087
+ *
1088
+ * THREE things it deliberately does NOT do:
1089
+ *
1090
+ * - it does not widen any other resume rule. The 2026-08-12 ruling is about a
1091
+ * person deciding, so `applies: false` on every other terminal: a cap-halt
1092
+ * checkpoint is a different stop under a different allowance, and quietly
1093
+ * ageing it out would be a governance change nobody ruled.
1094
+ * - it does not treat an unreadable stamp as young (F6). A checkpoint that
1095
+ * cannot say WHEN it paused has an unknown age, and unknown is refused rather
1096
+ * than rounded down to fresh — the direction that fails safe.
1097
+ * - it does not skip silently. An expired checkpoint comes back as a refusal
1098
+ * that NAMES the age and the TTL, because the operator's next move (start
1099
+ * fresh) depends on knowing which of the two it hit.
1100
+ *
1101
+ * The clock is injected for the same reason `runPlan`'s is: a rule measured in
1102
+ * months cannot otherwise be tested at all.
1103
+ *
1104
+ * @param {any[]} events the paused run's own spine, parsed, in file order
1105
+ * @param {{now?: () => number, ttlMs?: number}} [opts]
1106
+ * @returns {{ok: boolean, applies: boolean, ageMs: number|null, ttlMs: number, pausedAt: string|null, detail: string|null}}
1107
+ */
1108
+ export function checkpointAgeGate(events, { now = Date.now, ttlMs = PAUSE_TTL_MS } = {}) {
1109
+ const list = Array.isArray(events) ? events.filter(isObj) : [];
1110
+ const end = list.filter((e) => e.type === 'job-end').at(-1) ?? null;
1111
+ const base = { applies: false, ageMs: null, ttlMs, pausedAt: null, detail: null };
1112
+ if (end?.outcome !== HITL_PAUSE) return { ...base, ok: true };
1113
+
1114
+ // the PAUSE RECORD's own timestamp, and its terminal's as the fallback: the
1115
+ // pause is what the human is answering, so its stamp is when the waiting began
1116
+ const pause = list.filter((e) => e.type === HITL_PAUSE).at(-1) ?? end;
1117
+ const pausedAt = typeof pause.ts === 'string' ? pause.ts : null;
1118
+ const atMs = pausedAt === null ? NaN : Date.parse(pausedAt);
1119
+ if (!Number.isFinite(atMs)) {
1120
+ return {
1121
+ ...base,
1122
+ ok: false,
1123
+ applies: true,
1124
+ detail: 'this checkpoint does not record when it paused, so its age is unknown — and an unknown age is not a '
1125
+ + `young one (F6). The TTL is ${Math.round(ttlMs / 86_400_000)} days; start a fresh run rather than resuming a `
1126
+ + 'checkpoint nothing can date.',
1127
+ };
1128
+ }
1129
+ const ageMs = Math.max(0, now() - atMs);
1130
+ const days = (/** @type {number} */ ms) => Math.floor(ms / 86_400_000);
1131
+ if (ageMs > ttlMs) {
1132
+ return {
1133
+ ...base,
1134
+ ok: false,
1135
+ applies: true,
1136
+ ageMs,
1137
+ pausedAt,
1138
+ detail: `this run paused ${days(ageMs)} days ago and the pause TTL is ${days(ttlMs)} days — the tree, the `
1139
+ + 'toolchain and the job itself have had two months to move, so the evidence the signer would be answering '
1140
+ + 'about is no longer the evidence the run produced. Start a fresh run.',
1141
+ };
1142
+ }
1143
+ return { ...base, ok: true, applies: true, ageMs, pausedAt };
1144
+ }
1145
+
976
1146
  /**
977
1147
  * The PATIENT gate for a resume, which is deliberately NOT the fresh-launch one.
978
1148
  *
@@ -1325,7 +1495,30 @@ export async function runReuse(opts) {
1325
1495
  costUsd: read.spentUsd, spendComplete: read.spendComplete, wallMs, rounds,
1326
1496
  ...(trySpecHash ? { specHash: trySpecHash } : {}),
1327
1497
  };
1328
- if (verdictClass === 'green' && read.plan === null) {
1498
+ if (outcome === 'already-green') {
1499
+ // A GREEN THAT PREDATES THE RUN mints nothing, and the guard keys on the
1500
+ // TERMINAL rather than on the absence of a plan. `already-green` means the
1501
+ // close was satisfied at the precheck — before this leg wrote a byte — so
1502
+ // whatever plan is on the spine did not produce it, and minting a version
1503
+ // from one would be learning credit for work that did not happen.
1504
+ //
1505
+ // It used to be covered by accident: a cold already-green never drafts, so
1506
+ // `read.plan` was null and the branch below caught it. N4 broke that
1507
+ // accident — a hitl try that PAUSED and came back with the signer's
1508
+ // `accept` lands `already-green` with its predecessor's `plan-accepted`
1509
+ // sitting in the same try window. A rule with no wired detector is prose.
1510
+ bridgeWrites.push({
1511
+ name,
1512
+ action: 'none',
1513
+ file: null,
1514
+ reds: [{
1515
+ code: 'green-predates-run',
1516
+ path: 'outcome',
1517
+ detail: 'the close was already satisfied before this run did anything (already-green) — nothing here earned '
1518
+ + 'a version, and a plan on the spine did not produce this green',
1519
+ }],
1520
+ });
1521
+ } else if (verdictClass === 'green' && read.plan === null) {
1329
1522
  // a green with no plan on the spine cannot mint a version — the artifact that
1330
1523
  // inherits is the one that RAN, and there is nothing here to inherit. Reported,
1331
1524
  // never faked with a placeholder.
@@ -1339,6 +1532,26 @@ export async function runReuse(opts) {
1339
1532
  toolsUsed: [...new Set((read.plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
1340
1533
  };
1341
1534
  row.bridgeWrite = writeGreen(bridge, meta, { ...record, plan: read.plan });
1535
+ } else if (HUMAN_CHECKPOINTS.includes(outcome)) {
1536
+ // A PAUSE IS NOT A RESULT, so the box gets nothing — not a casualty row, not a red,
1537
+ // and the workflow is left exactly as it was. The recipe has not been judged: the
1538
+ // close is not FINISHED rendering its verdict, and the answer that would finish it
1539
+ // is a person's. Filing it as history would put a row on the entry that reads as a
1540
+ // run this workflow failed to carry, and the signer's `accept` moments later would
1541
+ // leave that row standing as evidence against a workflow that in fact greened.
1542
+ // Recorded as an explicit no-write for the already-green branch's reason: a reader
1543
+ // never has to branch on absence.
1544
+ bridgeWrites.push({
1545
+ name,
1546
+ action: 'none',
1547
+ file: null,
1548
+ reds: [{
1549
+ code: 'run-paused-for-decision',
1550
+ path: 'outcome',
1551
+ detail: `the run is waiting on a person (${outcome}) — nothing has been judged yet, so there is no result to `
1552
+ + 'file against this workflow. The row is written by whatever the resumed run finally earns.',
1553
+ }],
1554
+ });
1342
1555
  } else if (bridge) {
1343
1556
  // R1: the recipe is NOT edited by a red. `outcome` rides in verbatim — a casualty
1344
1557
  // keeps its own name, and only the literal 'red' demotes (D6).
@@ -1445,6 +1658,31 @@ export async function runReuse(opts) {
1445
1658
  * @param {any} row @returns {any|null} the terminal, or null to carry on
1446
1659
  */
1447
1660
  const hardStop = (row) => {
1661
+ // THE HUMAN HALF, read before the table and off the LIBRARY's own list: a run
1662
+ // waiting on a person is not one of the stops below, which are properties of the
1663
+ // run that would reproduce for $0. This one is the opposite — a try that did real
1664
+ // work and reached the one stage a machine cannot render. Buying the next try (and
1665
+ // then the cold leg) would spend the envelope re-asking a question already put to
1666
+ // someone who has not replied yet, and the second attempt could not be graded any
1667
+ // sooner than the first. The allowance still unspent is a person, so the loop stops
1668
+ // and hands the run back; the checkpoint on disk is untouched and resumable, which
1669
+ // is the runner script's half (N4 §1.6, PAUSE_TTL_MS).
1670
+ if (HUMAN_CHECKPOINTS.includes(row.runOutcome)) {
1671
+ return done(row.runOutcome, {
1672
+ category: row.runOutcome,
1673
+ decision: 'The run reached a stage only a person can render, and it is waiting on that answer. Every further '
1674
+ + 'try and the cold leg would run the same job to the same question, so the loop stops here rather than '
1675
+ + 'buying attempts nobody can grade yet. Nothing is lost: the run kept everything it did, and it resumes '
1676
+ + 'from this checkpoint once the decision is in.',
1677
+ options: [
1678
+ 'read the evidence package the pause recorded, then answer it and resume this run',
1679
+ 'leave it: an unanswered checkpoint expires on its own once the pause TTL is up',
1680
+ ],
1681
+ // NOT re-filed, for `interpreter-red`'s reason: the pause already escalated from
1682
+ // its own site inside the run, under this same name and with the evidence package
1683
+ // attached. A second copy here would count one person's pending decision twice.
1684
+ }, false);
1685
+ }
1448
1686
  const decisions = {
1449
1687
  'unapproved-spec': {
1450
1688
  decision: `No approval record matches the per-try spec (hash ${trySpecHash}). Tightening the caps with an envelope makes a NEW spec version, and a new version is signed, not inherited — nothing was run.`,
package/src/root.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer R — the root: the within-run ratchet (LAYERS.md item 2; design record
2
- // docs/plans/2026-07-19-layer-r-design.md). One state that survives attempts
2
+ // docs/02-features/2026-07-19-layer-r-design.md). One state that survives attempts
3
3
  // inside ONE run so a never-green run stops repeating itself (F21). Role is
4
4
  // CONTINUITY ONLY — F39 measured that hand-delivered state buys no conversion;
5
5
  // the semantic converter is Layer 2's job, not this module's.
package/src/run.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // smoke, the ONE budget ledger (cap-not-estimate; unpriced is never free, F6),
4
4
  // and the decision-ready stops. It composes the plan flow below it and
5
5
  // interprets nothing itself.
6
- // Design record: docs/plans/2026-07-12-n2-headless-loop-design.md.
6
+ // Design record: docs/02-features/2026-07-12-n2-headless-loop-design.md.
7
7
  //
8
8
  // The legacy operator-authored `steps[]` path was DELETED 2026-07-26 (PRD
9
9
  // v1.32): the drafting call, config-v1, the per-step interpret loops and the
@@ -15,6 +15,7 @@ import { join } from 'node:path';
15
15
  import { LiteCtx } from 'litectx';
16
16
  import { validateJob, jobSpecHash, checkApproval } from './job.js';
17
17
  import { runPlan } from './planrun.js';
18
+ import { SMOKE_STORE } from './kinds.js';
18
19
 
19
20
  /** @typedef {{code: string, path: string, detail?: string}} Red */
20
21
 
@@ -29,7 +30,7 @@ import { runPlan } from './planrun.js';
29
30
  */
30
31
  async function primitiveSmoke(workdir) {
31
32
  try {
32
- const lc = new LiteCtx({ root: join(workdir, '.smoke') });
33
+ const lc = new LiteCtx({ root: join(workdir, SMOKE_STORE) });
33
34
  await lc.remember('smoke-known-answer', 'bareloop primitive smoke: the answer is 42', { kind: 'fact' });
34
35
  const hits = await lc.recall('bareloop primitive smoke known answer', { kind: 'fact', n: 5, body: true });
35
36
  const ok = hits.some((h) => String(h.body ?? '').includes('the answer is 42'));
@@ -139,12 +140,18 @@ async function primitiveSmoke(workdir) {
139
140
  * a manufactured-fixation probe). `true` is the ON/experimental arm; the
140
141
  * default is the OFF arm. (Shell-owned seam, same doctrine as the provider
141
142
  * binding.)
143
+ * @param {{decision: string, text?: string}|null} [opts.humanRuling] N4 — the SIGNER's
144
+ * answer at a hitl pause (accept | rerun <text> | pause), carried by the leg that
145
+ * RESUMES a paused run and forwarded verbatim to the plan flow. Absent on every
146
+ * ordinary run; never authored and never defaulted (a defaulted answer to "is this
147
+ * done?" is the rubber-stamp the class exists to prevent, 2026-08-12 §4).
142
148
  * @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
143
149
  * 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
144
150
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
145
- * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' | `step-red:<id>`
151
+ * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
152
+ * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
146
153
  */
147
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null }) {
154
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null }) {
148
155
  // 0. the ledger's counters, declared FIRST so that every job-end — including
149
156
  // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
150
157
  // not a zero: a consumer reads `undefined` and either crashes or launders it
@@ -282,7 +289,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
282
289
  // accounts it natively (F12) and the job-end money contract is unchanged.
283
290
  {
284
291
  const outcome = await runPlan(job, {
285
- workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch,
292
+ workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling,
286
293
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
287
294
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
288
295
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact
package/src/selection.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — D3's DISPLAY half: the workflow LISTING, and the prompt that asks for a
2
- // pick (design record docs/plans/2026-08-01-layer-3-reuse-design.md, D3).
2
+ // pick (design record docs/02-features/2026-08-01-layer-3-reuse-design.md, D3).
3
3
  //
4
4
  // hamr's ruling, verbatim: *"i think there should be a listing for workflows names that
5
5
  // user can choose or auto-choose, or do we want llm to always decide (we still need to
package/src/text.js CHANGED
@@ -70,6 +70,85 @@ export function priceOf(r) {
70
70
  };
71
71
  }
72
72
 
73
+ /**
74
+ * The F6 tally over a list of PRICED CALLS — `priceOf`'s per-call read, summed.
75
+ *
76
+ * `knownUsd` carries the priced half explicitly and `costUsd` is the honest
77
+ * total: null the moment anything went unpriced, never the priced half wearing a
78
+ * complete coat. An EMPTY list is a complete $0 and not an unknown — nothing
79
+ * spent is a number somebody can act on.
80
+ * @param {{costUsd: number|null, unpricedRounds?: number}[]|null|undefined} entries
81
+ * @returns {{costUsd: number|null, knownUsd: number, spendComplete: boolean,
82
+ * nullCostCalls: number, unpricedRounds: number}}
83
+ */
84
+ export function tallyCalls(entries) {
85
+ const list = Array.isArray(entries) ? entries : [];
86
+ const known = list.reduce((a, e) => a + (typeof e.costUsd === 'number' ? e.costUsd : 0), 0);
87
+ const nullCostCalls = list.filter((e) => e.costUsd === null).length;
88
+ const unpricedRounds = list.reduce((a, e) => a + (e.unpricedRounds || 0), 0);
89
+ const spendComplete = nullCostCalls === 0 && unpricedRounds === 0;
90
+ return {
91
+ costUsd: spendComplete ? Number(known.toFixed(6)) : null,
92
+ knownUsd: Number(known.toFixed(6)),
93
+ spendComplete,
94
+ nullCostCalls,
95
+ unpricedRounds,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * THE ONE MONEY-CEILING PREDICATE, for the same reason `priceOf` above is one:
101
+ * the authoring pipeline meters two populations of paid call in two different
102
+ * accumulators (the survey's `calls[]`, the declaration loop's cost book), and
103
+ * two hand-spelled answers to "am I over the ceiling?" is two instruments.
104
+ *
105
+ * It is asked BETWEEN metered calls and never inside one. A cap that binds
106
+ * mid-call kills the row before it can be graded — F45 measured that as 3 of 7
107
+ * launches lost and $5.82 unreadable.
108
+ *
109
+ * Three answers, and the order between the last two is load-bearing:
110
+ * - NO CEILING → null, always. An absent cap is UNBOUNDED, and unbounded is a
111
+ * visible operator choice, never a defaulted number nobody set (the
112
+ * `maxWallMs` precedent). There is nothing to enforce, so unpriced spend
113
+ * here is merely reported.
114
+ * - KNOWN SPEND AT OR OVER THE CEILING → `cap-halt`, even when the total is
115
+ * unknown. The breach is CERTAIN on the priced half alone — whatever the
116
+ * unpriced remainder turns out to be, it can only be larger — so naming this
117
+ * `pricing-red` would send the operator to fix a meter when the fact is that
118
+ * the money is gone. At-or-over rather than over: the next call is the one
119
+ * that breaches, and it is the one being decided.
120
+ * - SPEND THAT CANNOT BE KNOWN → `pricing-red`. `?? 0` launders unknown into
121
+ * $0 (F6) and a ceiling enforced against a laundered floor is not enforced
122
+ * at all. If the spend cannot be seen the cap cannot govern it, and that is
123
+ * its own stop rather than a silent pass.
124
+ *
125
+ * A MALFORMED ceiling is the one input it refuses rather than answers: `null` and
126
+ * an absent field mean UNBOUNDED, and they are the ONLY way to get there. Anything
127
+ * else that is not a finite number (`'2.50'`, `NaN`, `Infinity`) reading as
128
+ * unbounded would be F6's laundering in its ceiling form — a caller who set a cap
129
+ * getting a paid pipeline with nothing enforcing it, which is the exact collapse
130
+ * the ceiling exists to prevent, wearing the caller's own typo (PRD v1.62). The
131
+ * CLI's `parseCeiling` guards the FLAG; this guards the LIBRARY, and `authorClose`
132
+ * / `runAuthorScout` / `authorCloseForJob` are documented adopter seams. It THROWS
133
+ * rather than clamping, unlike the attempt caps beside it, because those axes have
134
+ * a safe direction to clamp toward and this one does not: every candidate default
135
+ * is either a number nobody set or the unbounded run the caller just declined.
136
+ * @param {{ceilingUsd: number|null|undefined, knownUsd: number, spendComplete: boolean}} o
137
+ * @returns {'cap-halt'|'pricing-red'|null}
138
+ * @throws {Error} when the ceiling is neither absent nor a finite number
139
+ */
140
+ export function capStop({ ceilingUsd, knownUsd, spendComplete }) {
141
+ if (ceilingUsd === null || ceilingUsd === undefined) return null;
142
+ if (typeof ceilingUsd !== 'number' || !Number.isFinite(ceilingUsd)) {
143
+ throw new Error(`capStop: a money ceiling must be a finite number of dollars or null — got ${typeof ceilingUsd} `
144
+ + `${String(ceilingUsd)}. A malformed ceiling is an ERROR, never a silent fall back to UNBOUNDED: omit it, or `
145
+ + 'pass an explicit null, to run unbounded — that is a stated operator choice and never a typo arrived at');
146
+ }
147
+ if (knownUsd >= ceilingUsd) return 'cap-halt';
148
+ if (!spendComplete) return 'pricing-red';
149
+ return null;
150
+ }
151
+
73
152
  // ── THE RAW MODEL OUTPUT, AS A PERSISTED RECORD ─────────────────────────────
74
153
  //
75
154
  // Run mslhn707 died on a survey that broke JSON at position 1339 and left NO
package/src/tools.js CHANGED
@@ -31,6 +31,7 @@ import { extractArtifact, priceOf } from './text.js';
31
31
  // caller can forget to pass, and this leak has to be inexpressible, not optional.
32
32
  // Same ONE inventory the spine channel and the close output ride through.
33
33
  import { redactSecrets } from './validate.js';
34
+ import { GATE_AUDIT_FILE, ARBITER_BOOK_STORES } from './kinds.js';
34
35
 
35
36
  /** @typedef {{body?: string|null, text?: string|null}} RecallHit litectx recall hit — body present only with `{body: true}` */
36
37
 
@@ -45,6 +46,16 @@ const PERSONA = 'You are a senior engineer. Reply with ONLY the complete content
45
46
  // cheap, wrong attempt the rational move — which is exactly what the loop wants.
46
47
  export const PERSONA_TOOLS = 'You are a senior engineer working in a repository through file tools. '
47
48
  + 'ALWAYS use absolute paths — relative paths resolve against the process, not the repository, and will be denied. '
49
+ // F98's residual: the fence ALREADY denies these (planrun's Gate deny list), and the
50
+ // spine is written outside the patient entirely — but nothing ever SAID so, and a
51
+ // denial is a wall while a stated law is a map. The way a worker learned the rule was
52
+ // by spending rounds of a bounded attempt probing bookkeeping files, and the rounds a
53
+ // probe costs are rounds the fix does not get. Registered here, beside the other fence
54
+ // fact a worker cannot infer, so it renders for every worker on every grant.
55
+ // the names are SPELLED FROM the one home (`ARBITER_BOOK_STORES`) so the stated
56
+ // law can never drift from the fence that enforces it
57
+ + `The repository root also holds the arbiter's own books — ${GATE_AUDIT_FILE}, ${ARBITER_BOOK_STORES.join(', ')}, and this run's spine. `
58
+ + 'They are ALWAYS denied and record how you are being judged, never anything about the task: never read them, and never try to route around the denial. '
48
59
  + 'You are ONE attempt inside an automated loop: when you finish, a test suite runs and, if it still fails, you are called again with its output. '
49
60
  + 'So do not try to be certain before acting. Read only what you need to form your best hypothesis, make the change with the write tool, and stop. '
50
61
  + 'A wrong cheap attempt is corrected by the next round; exhaustive reading is not — every file you read is re-sent on every later round and the run has a hard budget it can exhaust before you ever write. '