bareloop 0.6.0 → 0.8.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,
@@ -344,10 +359,17 @@ export const GAP_KEEP_TRIM_MARKER = 'more elided by the';
344
359
  * `keepPattern` is set (F28), lines of the WHOLE stream matching it are also
345
360
  * carried in a clearly-delimited block between head and tail — capped — so the
346
361
  * failing-test names buried in the middle still reach the worker.
362
+ * Exported for the ONE reason `GAP_KEEP_TRIM_MARKER` above is: a second consumer
363
+ * that needs a gap bounded must reuse THIS envelope, not spell its own. A private
364
+ * copy would be a second truncation scheme, and the two would drift on exactly the
365
+ * property that matters — whether the red lines survive, and whether the trim
366
+ * announces itself (F28: silent truncation is the disease this function cures).
367
+ * The close path is unchanged: `runClose` still calls it with the stage's own
368
+ * `gapKeep`, byte-identically.
347
369
  * @param {string} s the (redacted) close output
348
370
  * @param {string} [keepPattern] regex source; matching lines survive the elision
349
371
  */
350
- function boundGap(s, keepPattern) {
372
+ export function boundGap(s, keepPattern) {
351
373
  if (s.length <= GAP_HEAD + GAP_TAIL + 100) return s;
352
374
  const head = s.slice(0, GAP_HEAD);
353
375
  const tail = s.slice(-GAP_TAIL);
@@ -408,6 +430,27 @@ export const CLOSE_FAULTS = Object.freeze({
408
430
  },
409
431
  });
410
432
 
433
+ /**
434
+ * The exhaustion-by-PROGRESS seam, as a contract rather than "whatever one module
435
+ * happens to return". Two governors implement it and they read different signals —
436
+ * the step ladder's repeats-and-writes (src/ladder.js) and the close-fix loop's
437
+ * per-stage graded numbers (src/trend.js) — which is the whole reason ralph takes an
438
+ * interface here and interprets nothing itself.
439
+ *
440
+ * `record` returns the governor's own reading, which ralph emits verbatim and never
441
+ * inspects; each reading names its `governor` so two instruments under one spine type
442
+ * can never be averaged into one number. `report` is the only shape ralph itself
443
+ * reads. `terminal` is optional and overrides the exhaustion PROSE only — never the
444
+ * category, never the outcome.
445
+ *
446
+ * @typedef {object} LoopGovernor
447
+ * @property {(o: {iteration: number, gap?: string}) => object} record
448
+ * @property {() => boolean} struckOut
449
+ * @property {() => {strikes: number, limit: number, iterations: number, distinctGaps?: number}} report
450
+ * @property {() => string} brief
451
+ * @property {() => {decision: string, options: string[]}} [terminal]
452
+ */
453
+
411
454
  /**
412
455
  * The loop: `while close-red and under-cap: run the middle`. Stops at first
413
456
  * green (within-run tuning past a visible close is the fit-to-pass surface,
@@ -433,7 +476,28 @@ export const CLOSE_FAULTS = Object.freeze({
433
476
  * zone, worker-crash routing (F32), and the cap taxonomy apply unchanged.
434
477
  * Injected by run.js only; inexpressible in any config or plan — the agent
435
478
  * never authors its judge any more than its close.
436
- * @param {number} opts.capRuns budget: max middle runs
479
+ * @param {number} [opts.capRuns] budget: max middle runs. Required UNLESS `ladder`
480
+ * is supplied — the two exhaustion rules are alternatives, never both at once.
481
+ * The "unless" is ENFORCED at entry (a param guard that throws), not merely
482
+ * documented: an optional-in-the-contract bound with nothing checking it made a
483
+ * loop that never ran report a governance stop that never happened.
484
+ * @param {LoopGovernor} [opts.ladder]
485
+ * the PROGRESS governor (src/ladder.js) that replaces the fixed count for a plan
486
+ * STEP's micro-loop. With it, the loop is bounded by strikes rather than by a
487
+ * number of runs: iterations float, and money, the wall, the variance meter and
488
+ * the stall fuse keep every bit of their current authority (each still ends the
489
+ * loop through the middle's own throw). Ralph still interprets nothing — it hands
490
+ * the ladder the iteration's gap and reads back a boolean.
491
+ *
492
+ * The CLOSE-FIX loop now supplies one too (PRD v1.46 §4, once its own $0 replay
493
+ * over all 8 archived fix loops came back clean: 0 greens harmed, 1 waste case
494
+ * caught). Its governor reads a different signal — the close's own per-stage
495
+ * graded numbers (src/trend.js), not repeats and writes — which is exactly why
496
+ * this is a SEAM and not a hard-coded rule: ralph still interprets nothing.
497
+ * An optional `terminal()` on the governor overrides the exhaustion prose, because
498
+ * the step ladder's copy offers the human a replan and there is no planner at the
499
+ * close. The category and the outcome are NOT overridable: one exhaustion terminal,
500
+ * two triggers.
437
501
  * @param {(type: string, data?: object) => object} opts.emit a spine emitter
438
502
  * @param {(s: string) => string} [opts.redact] source scrubber for close output
439
503
  * (secrets never enter the spine); injected so the shell stays stdlib-only
@@ -452,8 +516,30 @@ export const CLOSE_FAULTS = Object.freeze({
452
516
  * with zero writes, a crash stays what it always was: an instrument stop.
453
517
  * @returns {Promise<'green'|'escalated'>}
454
518
  */
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(' ') });
519
+ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
520
+ // PARAM GUARD (the BA-4 class — a caller's malformed argument, which is the one
521
+ // thing this shell throws for; a WORKER's bad reach comes back as a refusal
522
+ // RESULT instead, and that rule is about feedback, not about the API contract).
523
+ // `capRuns` became optional in this signature when `ladder` arrived, and nothing
524
+ // enforced the "unless". MEASURED with neither supplied: `1 <= undefined` is
525
+ // false, so the middle ran 0 times and ralph still emitted `cap-halt` and an
526
+ // escalation reading `undefined/undefined runs spent, close still red` — a
527
+ // governance stop narrated for a run that never happened, on the public export.
528
+ // A bound that reports halting a loop it never entered is exactly the
529
+ // blind-instrument class this repo keeps paying for, so the malformed call dies
530
+ // at the door instead of minting a record. The message names BOTH alternatives:
531
+ // either one repairs the call, and naming one would send the caller to the wrong
532
+ // repair.
533
+ if (!ladder && !(Number.isInteger(capRuns) && /** @type {number} */ (capRuns) > 0)) {
534
+ // quoted only for a string, so `"3"` reads as the type error it is while
535
+ // `NaN`/`undefined` read as themselves (JSON.stringify renders both as a lie)
536
+ const got = typeof capRuns === 'string' ? JSON.stringify(capRuns) : String(capRuns);
537
+ 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`);
538
+ }
539
+ emit('run-start', {
540
+ ...(ladder ? { strikeLimit: ladder.report().limit } : { capRuns }),
541
+ close: judge ? 'judge:injected' : /** @type {string[]} */ (close).join(' '),
542
+ });
457
543
  // The blind spot is NAMED, never hidden: with no judgment-rendered signal this
458
544
  // close cannot tell a crash from an honest red (they are byte-identical at the
459
545
  // exit-code seam), so the record says so out loud rather than passing the exit
@@ -463,7 +549,22 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
463
549
  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
550
  const verdicts = [];
465
551
  let gap;
466
- for (let iteration = 1; iteration <= capRuns; iteration++) {
552
+ // ONE exhaustion terminal, two triggers — the same three records in the same
553
+ // order either way, so the count path and the ladder path cannot drift into
554
+ // reporting the stop differently. The CATEGORY is `cap-halt` for both, and
555
+ // deliberately: downstream counts escalation categories against an executable
556
+ // excluded-set (ledger.js), and the step loop routes its ONE replan off exactly
557
+ // this name. Only the trigger changed; the taxonomy did not.
558
+ /** @param {number} iteration @param {object} halt @param {object} spend @param {string} decision @param {string[]} options */
559
+ const exhausted = (iteration, halt, spend, decision, options) => {
560
+ emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', ...halt });
561
+ emit('escalation', { category: 'cap-halt', decisionReady: true, verdicts, spend, decision, options });
562
+ emit('run-end', { outcome: 'escalated', iterations: iteration });
563
+ return /** @type {'escalated'} */ ('escalated');
564
+ };
565
+ // With a ladder the count is not the bound: the loop runs until the ladder strikes
566
+ // out, or until the middle throws (money, wall, variance, stall — all unchanged).
567
+ for (let iteration = 1; ladder || iteration <= /** @type {number} */ (capRuns); iteration++) {
467
568
  emit('iteration-start', { iteration });
468
569
  try {
469
570
  await middle(iteration, gap);
@@ -554,30 +655,56 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
554
655
  gap = `Your edit CRASHED the test suite — it can no longer even load and judge (${v.detail}). `
555
656
  + `Files you have written this run: ${shown.join(', ')}${more > 0 ? ` (and ${more} more)` : ''}. `
556
657
  + 'Fix or revert your change so the suite can run again — nothing can pass while the suite cannot load.';
557
- continue;
658
+ } else {
659
+ // Forbidden zone: no judgment was rendered, so there is no verdict. Escalate
660
+ // by the outcome's OWN name and never retry — retrying a broken arbiter is
661
+ // the §5b violation adaptlearn found live in its shipped shell (F25/Z-3).
662
+ const fault = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
663
+ if (fault) {
664
+ emit('escalation', {
665
+ category: fault.category, decisionReady: true, verdicts,
666
+ spend: { runs: iteration, capRuns },
667
+ decision: fault.decision, options: fault.options, detail: v.detail,
668
+ });
669
+ emit('run-end', { outcome: 'escalated', iterations: iteration });
670
+ return 'escalated';
671
+ }
672
+ gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
558
673
  }
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';
674
+ // ── the ladder reads THIS red iteration, whichever gap it ended up with (a
675
+ // worker-crash gap is feedback the attempt really received, so it is a reading
676
+ // like any other). ONE call site, after both branches have settled `gap`: two
677
+ // would let a future branch quietly opt out of the bound.
678
+ if (ladder) {
679
+ const r = ladder.record({ iteration, gap });
680
+ emit('ladder', r);
681
+ if (ladder.struckOut()) {
682
+ const rep = ladder.report();
683
+ // The governor may name its own decision/options; it may NOT name its own
684
+ // category or outcome. A stop is spelled the same either way (`cap-halt`,
685
+ // 'escalated') because downstream counts categories against an executable
686
+ // excluded-set — only the PROSE a human reads is governor-specific.
687
+ const term = typeof ladder.terminal === 'function' ? ladder.terminal() : null;
688
+ return exhausted(iteration,
689
+ {
690
+ strikes: rep.strikes,
691
+ strikeLimit: rep.limit,
692
+ iterations: rep.iterations,
693
+ ...(rep.distinctGaps === undefined ? {} : { distinctGaps: rep.distinctGaps }),
694
+ },
695
+ { runs: iteration, strikes: rep.strikes, strikeLimit: rep.limit },
696
+ term?.decision ?? `${rep.strikes}/${rep.limit} strikes — the step stopped making progress. ${ladder.brief()} Continue, change approach, or stop?`,
697
+ term?.options ?? ['let the planner re-allocate what is left (replan)', 'change approach', 'abandon the task']);
698
+ }
571
699
  }
572
- gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
573
700
  }
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';
701
+ // The FIXED-COUNT exhaustion, reached only without a ladder. Both callers inside this
702
+ // package now supply one (the step loop's `createLadder`, the close-fix loop's
703
+ // progress governor), so the only route left is the public export — kept because it IS
704
+ // the public export, and because a `capRuns` bound with no reachable exhaustion would
705
+ // be a bound in name only.
706
+ const spent = /** @type {number} */ (capRuns);
707
+ return exhausted(spent, { capRuns }, { runs: spent, capRuns },
708
+ `${spent}/${spent} runs spent, close still red. Continue, change approach, or stop?`,
709
+ ['raise the cap and rerun', 'change the middle/harness', 'abandon the task']);
583
710
  }