bareloop 0.34.0 → 0.36.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bareloop.context.md +276 -20
  3. package/package.json +1 -1
  4. package/src/authorrun.js +3 -2
  5. package/src/bundlerun.js +17 -12
  6. package/src/draftspend.js +197 -0
  7. package/src/exits.js +10 -1
  8. package/src/index.js +3 -3
  9. package/src/interviewrun.js +7 -7
  10. package/src/job.js +37 -0
  11. package/src/judged.js +2 -2
  12. package/src/kinds.js +2 -1
  13. package/src/ledger.js +101 -5
  14. package/src/legs.js +171 -0
  15. package/src/monthly.js +111 -72
  16. package/src/movefile.js +22 -0
  17. package/src/npminstall.js +55 -0
  18. package/src/panel/authorroutes.js +84 -14
  19. package/src/panel/authorsession.js +490 -125
  20. package/src/panel/importroutes.js +359 -0
  21. package/src/panel/importrun.js +203 -0
  22. package/src/panel/index.html +1194 -213
  23. package/src/panel/runroutes.js +219 -0
  24. package/src/panel/server.js +964 -79
  25. package/src/panel/settingsroutes.js +52 -2
  26. package/src/panel/status.js +51 -0
  27. package/src/planrun.js +60 -26
  28. package/src/providerrows.js +17 -0
  29. package/src/ralph.js +4 -1
  30. package/src/replay.js +139 -19
  31. package/src/replayio.js +11 -4
  32. package/src/reuse.js +20 -3
  33. package/src/run.js +6 -3
  34. package/src/runlist.js +75 -7
  35. package/src/source.js +43 -4
  36. package/src/text.js +20 -0
  37. package/src/u-patient.js +3 -2
  38. package/src/u-readout.js +9 -1
  39. package/src/u-watchdog.mjs +4 -2
  40. package/src/userrun.js +192 -65
  41. package/src/worktree.js +120 -0
  42. package/types/draftspend.d.ts +141 -0
  43. package/types/job.d.ts +25 -0
  44. package/types/ledger.d.ts +44 -0
  45. package/types/legs.d.ts +103 -0
  46. package/types/monthly.d.ts +23 -18
  47. package/types/movefile.d.ts +9 -0
  48. package/types/npminstall.d.ts +30 -0
  49. package/types/panel/authorroutes.d.ts +23 -1
  50. package/types/panel/authorsession.d.ts +110 -2
  51. package/types/panel/importroutes.d.ts +129 -0
  52. package/types/panel/importrun.d.ts +77 -0
  53. package/types/panel/runroutes.d.ts +32 -0
  54. package/types/panel/server.d.ts +266 -2
  55. package/types/panel/settingsroutes.d.ts +6 -1
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/planrun.d.ts +13 -21
  58. package/types/providerrows.d.ts +12 -0
  59. package/types/ralph.d.ts +19 -0
  60. package/types/replay.d.ts +25 -1
  61. package/types/replayio.d.ts +2 -2
  62. package/types/reuse.d.ts +5 -0
  63. package/types/run.d.ts +6 -2
  64. package/types/runlist.d.ts +52 -2
  65. package/types/source.d.ts +13 -5
  66. package/types/text.d.ts +19 -0
  67. package/types/userrun.d.ts +10 -0
  68. package/types/worktree.d.ts +48 -0
@@ -14,7 +14,8 @@
14
14
  import { checkHumanGuard } from './authorroutes.js';
15
15
  import { readConfig, updateConfig, ConfigError } from '../config.js';
16
16
  import { spendSummary, monthlyLimitOf } from '../monthly.js';
17
- import { loadKeysEnv, keysFilePath, filledKeyNames, ensureKeysFile } from '../keysfile.js';
17
+ import { spawn as realSpawn } from 'node:child_process';
18
+ import { loadKeysEnv, keysFilePath, keysHome, filledKeyNames, ensureKeysFile } from '../keysfile.js';
18
19
  import { SHAPES, PRESET_KEY_NAMES, keyRows, endpointOf, defaultUrlOf, usableKey } from '../providerrows.js';
19
20
  import { apiKeyProblem, checkProviderReachable } from '../providers.js';
20
21
 
@@ -22,8 +23,11 @@ import { apiKeyProblem, checkProviderReachable } from '../providers.js';
22
23
  const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance';
23
24
 
24
25
  /**
26
+ * `openFolderSpawn` / `platform` are test seams for the Open keys folder button (a real caller passes
27
+ * neither: `child_process.spawn` and `process.platform`).
25
28
  * @param {{ port: number, token: string, home?: string, env?: Record<string,string|undefined>,
26
- * fetchImpl?: typeof fetch, now?: () => number }} opts
29
+ * fetchImpl?: typeof fetch, now?: () => number, openFolderSpawn?: (cmd: string, args: string[], o: object) => any,
30
+ * platform?: string }} opts
27
31
  */
28
32
  export function createSettingsRoutes(opts) {
29
33
  const fetchImpl = opts.fetchImpl ?? fetch;
@@ -134,6 +138,8 @@ export function createSettingsRoutes(opts) {
134
138
  placeholder: defaultUrlOf(r.provider),
135
139
  keyStatus: !raw ? 'not set' : (raw === 'null' ? 'no key needed' : (problem ? `bad shape (${problem})` : 'found')),
136
140
  canTest: r.provider !== 'gemini-api',
141
+ priceInPerM: typeof r.priceInPerM === 'number' ? r.priceInPerM : null,
142
+ priceOutPerM: typeof r.priceOutPerM === 'number' ? r.priceOutPerM : null,
137
143
  tokens: s.tokensByRow[r.envName] ?? 0,
138
144
  balance: r.provider === 'anthropic-api'
139
145
  ? { kind: 'note', usd: typeof note === 'number' && Number.isFinite(note) ? note : null }
@@ -148,11 +154,32 @@ export function createSettingsRoutes(opts) {
148
154
  const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
149
155
  if (!row) { send(400, { ok: false, error: 'unknown key — add NAME=key to your keys file and reload keys' }); return true; }
150
156
  // only the three settings a row has; each is optional in the body, the whole triple is stored
157
+ /** @type {Record<string, string|number|null>} */
151
158
  const patch = {
152
159
  name: typeof body?.name === 'string' ? body.name.trim() : row.name,
153
160
  shape: typeof body?.shape === 'string' ? body.shape : row.provider,
154
161
  baseUrl: typeof body?.baseUrl === 'string' ? body.baseUrl.trim().replace(/\/+$/, '') : row.baseUrl,
155
162
  };
163
+ // the two prices (USD per 1M tokens): absent = unchanged; blank / null = not set (the field is removed,
164
+ // never written as 0); otherwise a finite number >= 0 — anything else is refused and nothing is saved
165
+ for (const f of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
166
+ if (!Object.hasOwn(body ?? {}, f)) continue;
167
+ const v = body[f];
168
+ const label = f === 'priceInPerM' ? 'In' : 'Out';
169
+ if (v === null || (typeof v === 'string' && v.trim() === '')) { patch[f] = null; continue; }
170
+ const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+(\.\d+)?$/.test(v.trim()) ? Number(v.trim()) : NaN);
171
+ if (!(Number.isFinite(n) && n >= 0)) {
172
+ send(400, { ok: false, error: `the ${label} price must be a number, 0 or more (USD per 1M tokens), or blank for not set` });
173
+ return true;
174
+ }
175
+ patch[f] = n;
176
+ }
177
+ // a price is both fields or neither — ratesFor refuses a half-set one at run time, so never save half
178
+ const after = (/** @type {'priceInPerM'|'priceOutPerM'} */ f) => (Object.hasOwn(patch, f) ? patch[f] !== null : row[f] !== undefined);
179
+ if (after('priceInPerM') !== after('priceOutPerM')) {
180
+ send(400, { ok: false, error: 'set both prices (In and Out, USD per 1M tokens) or neither — a half-set price is refused' });
181
+ return true;
182
+ }
156
183
  try {
157
184
  updateConfig({ keys: { [row.envName]: patch } }, { home: opts.home });
158
185
  } catch (e) {
@@ -164,6 +191,29 @@ export function createSettingsRoutes(opts) {
164
191
  return true;
165
192
  }
166
193
 
194
+ if (pathname === '/api/settings/open-keys-folder' && req.method === 'POST') {
195
+ // opens the FOLDER (never the file, never an editor) in the file manager; argv array, no shell, detached.
196
+ // Another OS, or a failed spawn: say so and hand back the path, which the page then shows as text.
197
+ const dir = keysHome(opts.home);
198
+ const platform = opts.platform ?? process.platform;
199
+ const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
200
+ const cannot = () => send(200, { ok: true, opened: false, path: dir });
201
+ if (!cmd) { cannot(); return true; }
202
+ let child;
203
+ try {
204
+ child = (opts.openFolderSpawn ?? realSpawn)(cmd, [dir], { detached: true, stdio: 'ignore' });
205
+ } catch { cannot(); return true; }
206
+ let settled = false;
207
+ child.once('error', () => { if (!settled) { settled = true; cannot(); } });
208
+ child.once('spawn', () => {
209
+ if (settled) return;
210
+ settled = true;
211
+ if (typeof child.unref === 'function') child.unref();
212
+ send(200, { ok: true, opened: true, path: dir });
213
+ });
214
+ return true;
215
+ }
216
+
167
217
  if (pathname === '/api/settings/providers/balance-note' && req.method === 'POST') {
168
218
  // the Anthropic balance is a NOTE the person types; no check ever reads it
169
219
  const raw = body?.usd;
@@ -0,0 +1,51 @@
1
+ // PANEL status words — ONE code-owned table (sign -> word) and ONE writer of "which word does this run wear".
2
+ // hamr's ruling 2026-10-04 (option A): every surface (run cards, expanded run rows, the right-side header, imported
3
+ // rows) reads `status` from the server; the page never maps an outcome to a word itself.
4
+ //
5
+ // [▶] running · [·] waiting · [✓] passed · [✗] failed · [✗] capped · [✗] stopped · [?] died
6
+ //
7
+ // Outcome -> word (every outcome the panel knows; anything not listed is `failed`):
8
+ // green, already-green, satisfied -> passed
9
+ // no job-end, runner alive (or starting) -> running
10
+ // no job-end, runner gone (died) -> died
11
+ // cap-halt, wall-halt -> capped (money cap / time cap)
12
+ // escalated + money-halt record, escalated + wall-halt -> capped
13
+ // stopped (the person pressed Stop) -> stopped
14
+ // plan-red, check-red, step-red, escalated (strikes / provider / other), close-red, provider-red, step-stalled,
15
+ // and every other outcome (refusals, instrument stops) -> failed
16
+ // `waiting` is the step-level sign for a step that has not started; no run outcome maps to it.
17
+
18
+ /** the sentence a passed run carries as its reason (the Ended block's `line`) */
19
+ export const GOAL_MET_LINE = 'goal met';
20
+
21
+ /** @typedef {'running'|'waiting'|'passed'|'failed'|'capped'|'stopped'|'died'} StatusKey */
22
+
23
+ /** the table: key -> sign + word */
24
+ export const STATUS = Object.freeze({
25
+ running: Object.freeze({ sign: '▶', word: 'running' }),
26
+ waiting: Object.freeze({ sign: '·', word: 'waiting' }),
27
+ passed: Object.freeze({ sign: '✓', word: 'passed' }),
28
+ failed: Object.freeze({ sign: '✗', word: 'failed' }),
29
+ capped: Object.freeze({ sign: '✗', word: 'capped' }),
30
+ stopped: Object.freeze({ sign: '✗', word: 'stopped' }),
31
+ died: Object.freeze({ sign: '?', word: 'died' }),
32
+ });
33
+
34
+ /**
35
+ * The status of one run.
36
+ * @param {{outcome: string|null|undefined, died?: boolean, category?: string|null, moneyHalt?: boolean}} o
37
+ * `category` is the last escalation's category; `moneyHalt` is whether the spine carries a `money-halt` record
38
+ * @returns {{key: StatusKey, sign: string, word: string}}
39
+ */
40
+ export function statusFor(o) {
41
+ /** @type {StatusKey} */
42
+ let key = 'failed';
43
+ const out = o.outcome;
44
+ if (o.died) key = 'died';
45
+ else if (out === null || out === undefined) key = 'running';
46
+ else if (out === 'green' || out === 'already-green' || out === 'satisfied') key = 'passed';
47
+ else if (out === 'cap-halt' || out === 'wall-halt') key = 'capped';
48
+ else if (out === 'stopped') key = 'stopped';
49
+ else if (out === 'escalated' && ((o.category === 'cap-halt' && o.moneyHalt) || o.category === 'wall-halt')) key = 'capped';
50
+ return { key, ...STATUS[key] };
51
+ }
package/src/planrun.js CHANGED
@@ -21,7 +21,7 @@
21
21
  // which putting either in fs.deny would silently contradict.
22
22
 
23
23
  import { createRequire } from 'node:module';
24
- import { readFileSync, readdirSync } from 'node:fs';
24
+ import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs';
25
25
  import { createHash } from 'node:crypto';
26
26
  import { join, resolve, relative } from 'node:path';
27
27
  import { Gate } from 'bareguard';
@@ -39,8 +39,9 @@ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, str
39
39
  import { createReadShim, readShimArm, readShimStrategy } from './readshim.js';
40
40
  import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
41
41
  import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
42
- import { extractArtifact } from './text.js';
42
+ import { extractArtifact, rateSourceFields } from './text.js';
43
43
  import { defaultJudgeLoop } from './judged.js';
44
+ export { rateSourceFields };
44
45
  import { createClock, isWallTimeout } from './clock.js';
45
46
  import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
46
47
  import {
@@ -536,25 +537,8 @@ export const FORBIDDEN_WRITE_SEGMENT_PATTERN = /"path":"(?:(?:[^"\\]|\\.)*\/)?(?
536
537
  */
537
538
  export const BOUND_REASON_MAX = 200;
538
539
 
539
- /**
540
- * BA-21 — the WRITE side of the pricing-provenance signal (read side: `rateProvenance` /
541
- * `spendProvenance` in src/ledger.js). bare-agent >=0.37 rides `rateSource` beside
542
- * `pricing` on every metering payload; both metering callbacks below forward it onto the
543
- * spine through THIS one helper, because two sites spelling one rule are two instruments
544
- * (the ripgrep fix that landed in ci.yml but not publish.yml).
545
- *
546
- * Forwarded VERBATIM — the write side reports what upstream said, and src/ledger.js is
547
- * the one place that decides what counts as a guess. And an ABSENT provenance stays
548
- * absent: a payload carrying no `rateSource` (bare-agent <0.37, and every round already
549
- * in the archive) emits no field at all, so a reader sees UNKNOWN rather than a label we
550
- * invented. Never defaulted to `null` here — `null` is upstream saying "nothing was
551
- * priced", which is a different fact from "nobody told us".
552
- * @param {any} arg an `onLlmResult` / `onTurn` payload
553
- * @returns {{rateSource?: string|null}} the field to spread into the spine record
554
- */
555
- export function rateSourceFields(arg) {
556
- return arg && typeof arg === 'object' && 'rateSource' in arg ? { rateSource: arg.rateSource } : {};
557
- }
540
+ /** the recorded reason for a step attempt whose round was cut at the output cap (F122 2A b) */
541
+ export const TRUNCATED_REASON = 'output cut at the token limit (max_tokens)';
558
542
 
559
543
  /**
560
544
  * What the NEXT attempt is told about the bound that cut the previous one.
@@ -589,6 +573,9 @@ export function rateSourceFields(arg) {
589
573
  */
590
574
  export function boundedNote(bounded, rounds) {
591
575
  if (!bounded) return null;
576
+ if (bounded.cause === 'truncated') {
577
+ return `Your previous attempt was CUT OFF: ${TRUNCATED_REASON}. That response was discarded.`;
578
+ }
592
579
  if (bounded.cause !== 'denied') {
593
580
  return `Your previous attempt was CUT OFF after ${rounds} tool rounds. `
594
581
  + 'Reading is bounded; writing is not. Form a hypothesis EARLY and make the change.';
@@ -982,12 +969,18 @@ ${scoutBlob || '(no scout notes)'}`;
982
969
  * passes `false` so the three `--resume`-naming readouts below (`MONEY_OPTIONS`,
983
970
  * the resume-plan-red option, the fix-loop terminal) say "resume is `run-u`-only
984
971
  * in v1" instead of naming a flag that would fail if typed.
972
+ * @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
973
+ * (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of a step worker
974
+ * (the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
975
+ * the file, end the Loop after that round, and end the leg `stopped` (a checkpoint outcome, filed
976
+ * the way a mid-step cap-halt is; resume re-enters that step). Nothing is in flight at that
977
+ * point, so the spend is exact. `null` = no stop surface (the default).
985
978
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
986
979
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
987
- * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
980
+ * 'branch-red' | 'cap-halt' | 'wall-halt' | 'stopped' | 'provider-red' | 'interpreter-red' |
988
981
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
989
982
  */
990
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
983
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null }) {
991
984
  // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
992
985
  // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
993
986
  // worker) — one accumulator closed over by all of them, because the shim's ledger
@@ -2434,7 +2427,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2434
2427
  * `iteration` mirrors `roundIteration` exactly, undefined included: the bound
2435
2428
  * cannot know a label the run never set, and inventing one here would be the
2436
2429
  * only place in this file that claims to.
2437
- * @type {{iteration: number|string|undefined, cause: 'rounds'|'wall'|'denied', reason: string|null}|undefined}
2430
+ * @type {{iteration: number|string|undefined, cause: 'rounds'|'wall'|'denied'|'truncated', reason: string|null}|undefined}
2438
2431
  */
2439
2432
  let attemptBounded;
2440
2433
  /**
@@ -2658,6 +2651,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2658
2651
  // completes no round and returns from no step, which are the only two places
2659
2652
  // `clock.expired()` is otherwise consulted). Same clock object as every other
2660
2653
  // reader — never a second time source.
2654
+ /** P5 item 5 — set by the round seam when the person's stop request was honoured on this worker */
2655
+ let stopHit = false;
2656
+ const throwIfStopped = () => {
2657
+ if (!stopHit) return;
2658
+ const err = /** @type {CategorizedError} */ (new Error('stopped at the person\'s request (a stop request was honoured at a round boundary)'));
2659
+ err.category = 'stopped';
2660
+ throw err;
2661
+ };
2661
2662
  const stallWatch = createStallWatch({
2662
2663
  onStall: (n) => emit('stall', { phase, iteration: roundIteration, stall: n, stallMs: STALL_MS, maxStalls: MAX_STALLS }),
2663
2664
  expired: () => clock.expired(),
@@ -2740,7 +2741,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2740
2741
  // step loop after a step returns (between steps), the head of a step's attempt
2741
2742
  // (a step that would BEGIN past the deadline is never funded), and the head of
2742
2743
  // a close-fix iteration (W-2 — the run stops on the verdict already minted).
2743
- if (clock.expired()) {
2744
+ // P5 item 5 — the person's STOP, read at THIS seam: the round boundary, where the money cap binds
2745
+ // (nothing in flight, the round just metered). It is the ONE owner of the stop request. The file is
2746
+ // consumed here; `self.stop()` ends the Loop after this round exactly as a bound does; `ask`/`askFrom`
2747
+ // then throw category `stopped`, which the step loop files as the checkpoint outcome `stopped` the way a
2748
+ // mid-step cap-halt is filed. Step workers only (scout/plan/fix never read it).
2749
+ if (stopFile !== null && !stopHit && phase.startsWith('step:') && existsSync(stopFile)) {
2750
+ try { unlinkSync(stopFile); } catch { /* already consumed */ }
2751
+ stopHit = true;
2752
+ emit('stop-requested', { phase, step: phase.slice('step:'.length), iteration: roundIteration, round: roundsThisAttempt, meaning: 'the person asked to stop; the leg ends after this round and a resume re-enters this step' });
2753
+ self.stop();
2754
+ } else if (clock.expired()) {
2744
2755
  attemptBounded = { iteration: roundIteration, cause: 'wall', reason: null };
2745
2756
  emit('wall-bounded', { phase, iteration: roundIteration, ...clock.report(closeTimeoutForReport) });
2746
2757
  self.stop();
@@ -2777,7 +2788,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2777
2788
  // bare-agent's own Loop.chat() (loop.js ~1321) strips a leading system
2778
2789
  // message before re-running for exactly this reason; do the same here.
2779
2790
  const rest = msgs[0]?.role === 'system' ? msgs.slice(1) : msgs;
2780
- return await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2791
+ const r = await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2792
+ throwIfStopped();
2793
+ return r;
2781
2794
  } catch (e) {
2782
2795
  throw categorize(e).err;
2783
2796
  }
@@ -2786,6 +2799,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2786
2799
  let r;
2787
2800
  try {
2788
2801
  r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2802
+ throwIfStopped();
2789
2803
  } catch (e) {
2790
2804
  throw categorize(e).err;
2791
2805
  }
@@ -2803,6 +2817,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2803
2817
  emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds, reason });
2804
2818
  return r;
2805
2819
  }
2820
+ // F122 "2A" half (b), hamr 2026-10-05: a STEP or FIX worker round cut at max_tokens
2821
+ // is a FAILED WORKER ATTEMPT, not transport. Same lane as the deny streak above: the
2822
+ // cut round's output is discarded (bare-agent already dropped it), its spend is booked
2823
+ // by the per-round meter, the tree is judged as it stands, and the step's strike
2824
+ // ladder (or the fix governor) applies unchanged (hamr ruling A extended it to `fix`
2825
+ // after run muux1x96). Scout, drafter, replan keep the transport route below.
2826
+ if (r.error && r.error.startsWith('truncated:') && (phase.startsWith('step:') || phase === 'fix')) {
2827
+ attemptBounded = { iteration: roundIteration, cause: 'truncated', reason: TRUNCATED_REASON };
2828
+ emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds, reason: TRUNCATED_REASON, stopReason: r.stopReason ?? null });
2829
+ return r;
2830
+ }
2806
2831
  // the remaining error-return taxonomy (one map, same doctrine as native's):
2807
2832
  // halt → cap-halt, API truncation → provider-red, anything else is wiring
2808
2833
  if (r.error) {
@@ -3690,6 +3715,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3690
3715
  // (attempts used, steps done); its absence means the deadline landed inside a call
3691
3716
  // instead, which is F64's cutMidCall stop — and run.js keys the job-end money floor
3692
3717
  // on exactly that field, so conflating the two would report an unknown as exact.
3718
+ // P5 item 5 — the person stopped the leg at a round boundary inside this step: a checkpoint, not a verdict
3719
+ if (cat === 'stopped') {
3720
+ planExecuted();
3721
+ return 'stopped';
3722
+ }
3693
3723
  if (cat === 'wall-halt') {
3694
3724
  emitWallHalt(stepWallStop ?? { cutMidCall: true, phase: `step:${step.id}`, stepsDone: idx, stepsPlanned: plan.steps.length });
3695
3725
  planExecuted();
@@ -4053,6 +4083,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
4053
4083
  .filter((s) => s.values.length > 0)
4054
4084
  .map((s) => `${s.stage} ${s.values.join(' → ')}`);
4055
4085
  const factsLine = [checksFact, ...stageFacts].filter(Boolean).join(' · ');
4086
+ // same iteration numbering as the step loop: `w.setIteration(iteration)` above
4087
+ // stamps the bound, so "the attempt just before this one" is `iteration - 1`
4088
+ const bound = w.wasBounded();
4056
4089
  await w.ask([
4057
4090
  'The job\'s final verification is failing. Fix the repository so it passes.',
4058
4091
  `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
@@ -4060,6 +4093,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
4060
4093
  !gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
4061
4094
  gap && `Previous attempt failed the verification:\n${gap}`,
4062
4095
  factsLine && `The close's own numbers so far, oldest first (facts only — no advice):\n${factsLine}`,
4096
+ bound?.iteration === iteration - 1 && boundedNote(bound, maxStepRounds),
4063
4097
  rootInj && rootInj.note,
4064
4098
  ].filter(Boolean).join('\n\n'));
4065
4099
  };
@@ -134,6 +134,23 @@ export function endpointOf(provider, baseUrl) {
134
134
  } catch { return raw.replace(/\/+$/, ''); }
135
135
  }
136
136
 
137
+ /**
138
+ * A job's provider in plain words for a refusal line: the host its Base URL points at (else the
139
+ * shape's own default host, else the shape's label), plus the model when the spec names one.
140
+ * Never a key, a path or a status code.
141
+ * @param {{provider?: string, baseUrl?: string|null, model?: string|null}} spec
142
+ * @returns {string}
143
+ */
144
+ export function providerInWords(spec) {
145
+ const provider = String(spec?.provider ?? '');
146
+ const url = endpointOf(provider, spec?.baseUrl ?? null);
147
+ let host = '';
148
+ try { host = new URL(url).host; } catch { host = ''; }
149
+ if (host === '') host = SHAPES.find((s) => s.id === provider)?.label ?? provider;
150
+ const model = typeof spec?.model === 'string' ? spec.model.trim() : '';
151
+ return model !== '' ? `${host}, ${model}` : host;
152
+ }
153
+
137
154
  /**
138
155
  * The URL a row talks to, for display ('' = the vendor's own host, which has no URL to show).
139
156
  * @param {KeyRow} row
package/src/ralph.js CHANGED
@@ -49,7 +49,7 @@ const CLOSE_KILL_GRACE_MS = 2000;
49
49
  * @param {{env: NodeJS.ProcessEnv, cwd?: string, timeoutMs?: number}} o
50
50
  * @returns {Promise<{error: (Error & {code?: string})|null, status: number|null, signal: string|null, stdout: string, stderr: string}>}
51
51
  */
52
- function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
52
+ export function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
53
53
  return new Promise((resolve) => {
54
54
  const child = spawn(cmd, args, { env, cwd });
55
55
  /** @type {Buffer[]} */ const outChunks = [];
@@ -653,6 +653,9 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
653
653
  ['raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
654
654
  'revise the goal/spec so the work fits the time (same re-approval)',
655
655
  'abandon the task']],
656
+ // P5 item 5 — the PERSON asked to stop at a round boundary: nothing failed, nothing is discarded.
657
+ 'stopped': ['You stopped the run. Nothing is discarded: the work on disk stands, and a resume re-enters this step.',
658
+ ['resume (the same run continues)', 'abandon the task']],
656
659
  // F66 — the STALL WATCHDOG gave up: no round completed for the stall window,
657
660
  // three times over, and reissuing the call did not recover it. It borrows both
658
661
  // siblings' rules. Like `step-variance`, the caller reads this category and