bareloop 0.6.0 → 0.7.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
@@ -118,7 +118,12 @@ function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
118
118
  * Two rules, deliberately kept BOTH ways round:
119
119
  * - `names` / `prefixes` — the knowns. A literal denylist is a chase (every
120
120
  * new provider mints a new variable), so it is not load-bearing on its own;
121
- * it exists for names the shape rule cannot see (`AWS_ACCESS_KEY_ID`).
121
+ * it exists for names the shape rule STRUCTURALLY cannot see — either no
122
+ * `_`-bounded credential suffix at all (`AWS_ACCESS_KEY_ID`, `PGPASSWORD`,
123
+ * `MYSQL_PWD`), a `*_KEY` that is not `*_API_KEY` (`SSH_PRIVATE_KEY`), or a
124
+ * BARE spelling with nothing in front of the suffix (`TOKEN`, `SECRET`).
125
+ * Names are matched EXACTLY, which is what keeps connection config out of
126
+ * the strip (`PGHOST`, `SECRETS_DIR`, `TOKENIZERS_PARALLELISM` all survive).
122
127
  * - `shape` — the NAME-SHAPE rule, which is what bounds the chase: anything
123
128
  * whose name ends in the credential suffixes goes, whoever minted it and
124
129
  * whether or not anyone here has heard of it. Case-insensitive; no `g` flag,
@@ -130,8 +135,18 @@ function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
130
135
  */
131
136
  export const CLOSE_ENV_DENY = Object.freeze({
132
137
  names: Object.freeze([
138
+ // provider keys
133
139
  'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
134
140
  'ANTHROPIC_AUTH_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN', 'NPM_TOKEN',
141
+ // credential spellings the shape rule structurally cannot see, because their
142
+ // names carry no `_`-bounded suffix (operator ruling 2026-07-31, "add", on a
143
+ // delta-review finding that MEASURED every one of these reaching the child).
144
+ // Documented tools' own variables, not invented ones:
145
+ 'PGPASSWORD', 'PGPASSFILE', 'PGSSLKEY', // libpq (`PGPASS` is NOT a libpq variable — the FILE is ~/.pgpass, named by PGPASSFILE)
146
+ 'MYSQL_PWD', 'REDISCLI_AUTH', // mysql client, redis-cli
147
+ 'STRIPE_KEY', 'STRIPE_SECRET_KEY', // `*_KEY` is not `*_API_KEY`: the shape rule misses it
148
+ 'SSH_PRIVATE_KEY', 'PRIVATE_KEY', 'SECRET_KEY',
149
+ 'TOKEN', 'SECRET', 'API_KEY', 'PASSWORD', 'PASSWD', // the bare spellings; a suffix rule needs a prefix to bound
135
150
  ]),
136
151
  prefixes: Object.freeze(['AWS_']),
137
152
  shape: /(_API_KEY|_SECRET|_TOKEN|_PASSWORD|_CREDENTIALS?)$/i,
@@ -408,6 +423,27 @@ export const CLOSE_FAULTS = Object.freeze({
408
423
  },
409
424
  });
410
425
 
426
+ /**
427
+ * The exhaustion-by-PROGRESS seam, as a contract rather than "whatever one module
428
+ * happens to return". Two governors implement it and they read different signals —
429
+ * the step ladder's repeats-and-writes (src/ladder.js) and the close-fix loop's
430
+ * per-stage graded numbers (src/trend.js) — which is the whole reason ralph takes an
431
+ * interface here and interprets nothing itself.
432
+ *
433
+ * `record` returns the governor's own reading, which ralph emits verbatim and never
434
+ * inspects; each reading names its `governor` so two instruments under one spine type
435
+ * can never be averaged into one number. `report` is the only shape ralph itself
436
+ * reads. `terminal` is optional and overrides the exhaustion PROSE only — never the
437
+ * category, never the outcome.
438
+ *
439
+ * @typedef {object} LoopGovernor
440
+ * @property {(o: {iteration: number, gap?: string}) => object} record
441
+ * @property {() => boolean} struckOut
442
+ * @property {() => {strikes: number, limit: number, iterations: number, distinctGaps?: number}} report
443
+ * @property {() => string} brief
444
+ * @property {() => {decision: string, options: string[]}} [terminal]
445
+ */
446
+
411
447
  /**
412
448
  * The loop: `while close-red and under-cap: run the middle`. Stops at first
413
449
  * green (within-run tuning past a visible close is the fit-to-pass surface,
@@ -433,7 +469,28 @@ export const CLOSE_FAULTS = Object.freeze({
433
469
  * zone, worker-crash routing (F32), and the cap taxonomy apply unchanged.
434
470
  * Injected by run.js only; inexpressible in any config or plan — the agent
435
471
  * never authors its judge any more than its close.
436
- * @param {number} opts.capRuns budget: max middle runs
472
+ * @param {number} [opts.capRuns] budget: max middle runs. Required UNLESS `ladder`
473
+ * is supplied — the two exhaustion rules are alternatives, never both at once.
474
+ * The "unless" is ENFORCED at entry (a param guard that throws), not merely
475
+ * documented: an optional-in-the-contract bound with nothing checking it made a
476
+ * loop that never ran report a governance stop that never happened.
477
+ * @param {LoopGovernor} [opts.ladder]
478
+ * the PROGRESS governor (src/ladder.js) that replaces the fixed count for a plan
479
+ * STEP's micro-loop. With it, the loop is bounded by strikes rather than by a
480
+ * number of runs: iterations float, and money, the wall, the variance meter and
481
+ * the stall fuse keep every bit of their current authority (each still ends the
482
+ * loop through the middle's own throw). Ralph still interprets nothing — it hands
483
+ * the ladder the iteration's gap and reads back a boolean.
484
+ *
485
+ * The CLOSE-FIX loop now supplies one too (PRD v1.46 §4, once its own $0 replay
486
+ * over all 8 archived fix loops came back clean: 0 greens harmed, 1 waste case
487
+ * caught). Its governor reads a different signal — the close's own per-stage
488
+ * graded numbers (src/trend.js), not repeats and writes — which is exactly why
489
+ * this is a SEAM and not a hard-coded rule: ralph still interprets nothing.
490
+ * An optional `terminal()` on the governor overrides the exhaustion prose, because
491
+ * the step ladder's copy offers the human a replan and there is no planner at the
492
+ * close. The category and the outcome are NOT overridable: one exhaustion terminal,
493
+ * two triggers.
437
494
  * @param {(type: string, data?: object) => object} opts.emit a spine emitter
438
495
  * @param {(s: string) => string} [opts.redact] source scrubber for close output
439
496
  * (secrets never enter the spine); injected so the shell stays stdlib-only
@@ -452,8 +509,30 @@ export const CLOSE_FAULTS = Object.freeze({
452
509
  * with zero writes, a crash stays what it always was: an instrument stop.
453
510
  * @returns {Promise<'green'|'escalated'>}
454
511
  */
455
- export async function ralph({ middle, close, judge, capRuns, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
456
- emit('run-start', { capRuns, close: judge ? 'judge:injected' : /** @type {string[]} */ (close).join(' ') });
512
+ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
513
+ // PARAM GUARD (the BA-4 class — a caller's malformed argument, which is the one
514
+ // thing this shell throws for; a WORKER's bad reach comes back as a refusal
515
+ // RESULT instead, and that rule is about feedback, not about the API contract).
516
+ // `capRuns` became optional in this signature when `ladder` arrived, and nothing
517
+ // enforced the "unless". MEASURED with neither supplied: `1 <= undefined` is
518
+ // false, so the middle ran 0 times and ralph still emitted `cap-halt` and an
519
+ // escalation reading `undefined/undefined runs spent, close still red` — a
520
+ // governance stop narrated for a run that never happened, on the public export.
521
+ // A bound that reports halting a loop it never entered is exactly the
522
+ // blind-instrument class this repo keeps paying for, so the malformed call dies
523
+ // at the door instead of minting a record. The message names BOTH alternatives:
524
+ // either one repairs the call, and naming one would send the caller to the wrong
525
+ // repair.
526
+ if (!ladder && !(Number.isInteger(capRuns) && /** @type {number} */ (capRuns) > 0)) {
527
+ // quoted only for a string, so `"3"` reads as the type error it is while
528
+ // `NaN`/`undefined` read as themselves (JSON.stringify renders both as a lie)
529
+ const got = typeof capRuns === 'string' ? JSON.stringify(capRuns) : String(capRuns);
530
+ throw new Error(`ralph: capRuns must be a positive integer (got ${got}) unless a \`ladder\` governor is supplied — they are alternative exhaustion rules and a loop with neither cannot stop honestly`);
531
+ }
532
+ emit('run-start', {
533
+ ...(ladder ? { strikeLimit: ladder.report().limit } : { capRuns }),
534
+ close: judge ? 'judge:injected' : /** @type {string[]} */ (close).join(' '),
535
+ });
457
536
  // The blind spot is NAMED, never hidden: with no judgment-rendered signal this
458
537
  // close cannot tell a crash from an honest red (they are byte-identical at the
459
538
  // exit-code seam), so the record says so out loud rather than passing the exit
@@ -463,7 +542,22 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
463
542
  if (!judged && !judge) emit('close-unaudited', { close: /** @type {string[]} */ (close).join(' '), meaning: 'no judgment-rendered signal declared — a crash-at-load is indistinguishable from an honest red for this close' });
464
543
  const verdicts = [];
465
544
  let gap;
466
- for (let iteration = 1; iteration <= capRuns; iteration++) {
545
+ // ONE exhaustion terminal, two triggers — the same three records in the same
546
+ // order either way, so the count path and the ladder path cannot drift into
547
+ // reporting the stop differently. The CATEGORY is `cap-halt` for both, and
548
+ // deliberately: downstream counts escalation categories against an executable
549
+ // excluded-set (ledger.js), and the step loop routes its ONE replan off exactly
550
+ // this name. Only the trigger changed; the taxonomy did not.
551
+ /** @param {number} iteration @param {object} halt @param {object} spend @param {string} decision @param {string[]} options */
552
+ const exhausted = (iteration, halt, spend, decision, options) => {
553
+ emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', ...halt });
554
+ emit('escalation', { category: 'cap-halt', decisionReady: true, verdicts, spend, decision, options });
555
+ emit('run-end', { outcome: 'escalated', iterations: iteration });
556
+ return /** @type {'escalated'} */ ('escalated');
557
+ };
558
+ // With a ladder the count is not the bound: the loop runs until the ladder strikes
559
+ // out, or until the middle throws (money, wall, variance, stall — all unchanged).
560
+ for (let iteration = 1; ladder || iteration <= /** @type {number} */ (capRuns); iteration++) {
467
561
  emit('iteration-start', { iteration });
468
562
  try {
469
563
  await middle(iteration, gap);
@@ -554,30 +648,56 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
554
648
  gap = `Your edit CRASHED the test suite — it can no longer even load and judge (${v.detail}). `
555
649
  + `Files you have written this run: ${shown.join(', ')}${more > 0 ? ` (and ${more} more)` : ''}. `
556
650
  + 'Fix or revert your change so the suite can run again — nothing can pass while the suite cannot load.';
557
- continue;
651
+ } else {
652
+ // Forbidden zone: no judgment was rendered, so there is no verdict. Escalate
653
+ // by the outcome's OWN name and never retry — retrying a broken arbiter is
654
+ // the §5b violation adaptlearn found live in its shipped shell (F25/Z-3).
655
+ const fault = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
656
+ if (fault) {
657
+ emit('escalation', {
658
+ category: fault.category, decisionReady: true, verdicts,
659
+ spend: { runs: iteration, capRuns },
660
+ decision: fault.decision, options: fault.options, detail: v.detail,
661
+ });
662
+ emit('run-end', { outcome: 'escalated', iterations: iteration });
663
+ return 'escalated';
664
+ }
665
+ gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
558
666
  }
559
- // Forbidden zone: no judgment was rendered, so there is no verdict. Escalate
560
- // by the outcome's OWN name and never retry — retrying a broken arbiter is
561
- // the §5b violation adaptlearn found live in its shipped shell (F25/Z-3).
562
- const fault = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
563
- if (fault) {
564
- emit('escalation', {
565
- category: fault.category, decisionReady: true, verdicts,
566
- spend: { runs: iteration, capRuns },
567
- decision: fault.decision, options: fault.options, detail: v.detail,
568
- });
569
- emit('run-end', { outcome: 'escalated', iterations: iteration });
570
- return 'escalated';
667
+ // ── the ladder reads THIS red iteration, whichever gap it ended up with (a
668
+ // worker-crash gap is feedback the attempt really received, so it is a reading
669
+ // like any other). ONE call site, after both branches have settled `gap`: two
670
+ // would let a future branch quietly opt out of the bound.
671
+ if (ladder) {
672
+ const r = ladder.record({ iteration, gap });
673
+ emit('ladder', r);
674
+ if (ladder.struckOut()) {
675
+ const rep = ladder.report();
676
+ // The governor may name its own decision/options; it may NOT name its own
677
+ // category or outcome. A stop is spelled the same either way (`cap-halt`,
678
+ // 'escalated') because downstream counts categories against an executable
679
+ // excluded-set — only the PROSE a human reads is governor-specific.
680
+ const term = typeof ladder.terminal === 'function' ? ladder.terminal() : null;
681
+ return exhausted(iteration,
682
+ {
683
+ strikes: rep.strikes,
684
+ strikeLimit: rep.limit,
685
+ iterations: rep.iterations,
686
+ ...(rep.distinctGaps === undefined ? {} : { distinctGaps: rep.distinctGaps }),
687
+ },
688
+ { runs: iteration, strikes: rep.strikes, strikeLimit: rep.limit },
689
+ term?.decision ?? `${rep.strikes}/${rep.limit} strikes — the step stopped making progress. ${ladder.brief()} Continue, change approach, or stop?`,
690
+ term?.options ?? ['let the planner re-allocate what is left (replan)', 'change approach', 'abandon the task']);
691
+ }
571
692
  }
572
- gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
573
693
  }
574
- emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', capRuns });
575
- emit('escalation', {
576
- category: 'cap-halt', decisionReady: true, verdicts,
577
- spend: { runs: capRuns, capRuns },
578
- decision: `${capRuns}/${capRuns} runs spent, close still red. Continue, change approach, or stop?`,
579
- options: ['raise the cap and rerun', 'change the middle/harness', 'abandon the task'],
580
- });
581
- emit('run-end', { outcome: 'escalated', iterations: capRuns });
582
- return 'escalated';
694
+ // The FIXED-COUNT exhaustion, reached only without a ladder. Both callers inside this
695
+ // package now supply one (the step loop's `createLadder`, the close-fix loop's
696
+ // progress governor), so the only route left is the public export — kept because it IS
697
+ // the public export, and because a `capRuns` bound with no reachable exhaustion would
698
+ // be a bound in name only.
699
+ const spent = /** @type {number} */ (capRuns);
700
+ return exhausted(spent, { capRuns }, { runs: spent, capRuns },
701
+ `${spent}/${spent} runs spent, close still red. Continue, change approach, or stop?`,
702
+ ['raise the cap and rerun', 'change the middle/harness', 'abandon the task']);
583
703
  }