bareloop 0.39.0 → 0.41.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 (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bareloop.context.md +137 -112
  3. package/package.json +2 -2
  4. package/src/authorflow.js +333 -58
  5. package/src/authoring.js +65 -2
  6. package/src/authorjob.js +57 -9
  7. package/src/authorreadout.js +50 -2
  8. package/src/authorrun.js +30 -27
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/calibrate.js +1 -1
  14. package/src/cardauthor.js +4 -1
  15. package/src/cli.js +1 -1
  16. package/src/config.js +129 -29
  17. package/src/declaredclose.js +4 -3
  18. package/src/draftspend.js +6 -1
  19. package/src/index.js +2 -4
  20. package/src/interviewrun.js +73 -52
  21. package/src/job.js +50 -11
  22. package/src/jobblock.js +140 -0
  23. package/src/judged.js +12 -44
  24. package/src/keysfile.js +15 -7
  25. package/src/kinds.js +2 -2
  26. package/src/ledger.js +1 -1
  27. package/src/modellist.js +84 -0
  28. package/src/monthly.js +23 -43
  29. package/src/panel/authorroutes.js +34 -19
  30. package/src/panel/authorsession.js +187 -72
  31. package/src/panel/index.html +699 -215
  32. package/src/panel/jobplan.js +107 -0
  33. package/src/panel/runroutes.js +3 -0
  34. package/src/panel/server.js +102 -28
  35. package/src/panel/settingsroutes.js +232 -173
  36. package/src/planrun.js +6 -6
  37. package/src/promptregisters.js +1 -1
  38. package/src/providerrows.js +226 -141
  39. package/src/providers.js +58 -250
  40. package/src/providershapes.js +18 -0
  41. package/src/replay.js +1 -1
  42. package/src/reuse.js +1 -1
  43. package/src/root.js +1 -1
  44. package/src/run.js +3 -3
  45. package/src/selection.js +1 -1
  46. package/src/source.js +64 -1
  47. package/src/text.js +13 -0
  48. package/src/thinking.js +130 -0
  49. package/src/transport.js +1 -1
  50. package/src/userrun.js +53 -76
  51. package/types/authorflow.d.ts +258 -39
  52. package/types/authoring.d.ts +7 -1
  53. package/types/authorjob.d.ts +35 -3
  54. package/types/authorreadout.d.ts +17 -1
  55. package/types/authorscout.d.ts +5 -3
  56. package/types/config.d.ts +23 -6
  57. package/types/declaredclose.d.ts +4 -1
  58. package/types/draftspend.d.ts +8 -2
  59. package/types/index.d.ts +1 -1
  60. package/types/job.d.ts +6 -6
  61. package/types/jobblock.d.ts +102 -0
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/modellist.d.ts +50 -0
  65. package/types/monthly.d.ts +4 -3
  66. package/types/panel/authorsession.d.ts +44 -15
  67. package/types/panel/jobplan.d.ts +58 -0
  68. package/types/panel/server.d.ts +34 -4
  69. package/types/planrun.d.ts +1 -1
  70. package/types/providerrows.d.ts +192 -99
  71. package/types/providers.d.ts +35 -116
  72. package/types/providershapes.d.ts +10 -0
  73. package/types/run.d.ts +1 -1
  74. package/types/source.d.ts +26 -0
  75. package/types/thinking.d.ts +90 -0
  76. package/types/userrun.d.ts +6 -1
package/src/providers.js CHANGED
@@ -21,63 +21,13 @@
21
21
  // (`buildRunnerProviders`, below); it never prices a round (F113: rates
22
22
  // passthrough is dead), and never decides a budget.
23
23
 
24
+ import { listModels } from './modellist.js';
25
+ import { SHAPE_IDS } from './providershapes.js';
24
26
  import { createRequire } from 'node:module';
25
27
 
26
28
  const require = createRequire(import.meta.url);
27
29
  const { AnthropicProvider, OpenAIProvider, GeminiProvider } = require('bare-agent/providers');
28
30
 
29
- /**
30
- * Today's anthropic-api tier table — EXACTLY the `DEFAULT_TIER_MODELS` both
31
- * runners hardcoded before this factory existed. Any change to these two
32
- * values is a change to shipped anthropic-api behaviour, not a refactor.
33
- */
34
- export const ANTHROPIC_TIER_MODELS = Object.freeze({
35
- sonnet: 'claude-sonnet-5',
36
- haiku: 'claude-haiku-4-5-20251001',
37
- });
38
-
39
- /**
40
- * DeepSeek (F149/F150) is a SINGLE model, not a tiered family — hamr's
41
- * ruling above: one secondary provider, not a menu. Both tiers map to the
42
- * same model id ON PURPOSE: `resolveWorkerModel`/a runner's `providerFor
43
- * (tier)` only ever LOOK UP a tier's model id (src/job.js:818-827,
44
- * scripts/run-u.mjs's `providerFor`) — neither assumes the tiers name
45
- * different models — so repeating the one id is the honest representation
46
- * of a single-model provider, not a second model masquerading as `haiku`.
47
- * If DeepSeek (or a future openai-api entrant) ever ships a genuinely
48
- * cheaper tier model, this is where it would earn its own `haiku` id.
49
- *
50
- * MODEL SWAP (F171, item 34 L16, hamr 2026-09-13): `deepseek-chat` is no
51
- * longer served — a live `/models` call returned only `deepseek-flash` and
52
- * `deepseek-v4-pro`. Switched to `deepseek-flash` (DeepSeek V4.1). F149's
53
- * `legacyMaxTokens` measurement (asked 64 output tokens, got 665/608) was
54
- * taken on `deepseek-chat` and is UNVERIFIED on `deepseek-flash` — kept on
55
- * below as the fail-safe (over-capping direction is safe; see F171).
56
- */
57
- export const OPENAI_TIER_MODELS = Object.freeze({
58
- sonnet: 'deepseek-flash',
59
- haiku: 'deepseek-flash',
60
- });
61
-
62
- /**
63
- * Gemini (PRD item 31.3, hamr 2026-09-09: "anthropic, openai, gemini drop
64
- * ollama for now"). Two real tiers, unlike DeepSeek's single model: `flash` is
65
- * the cheap tier by design, so `haiku` maps to it honestly rather than
66
- * repeating one id.
67
- *
68
- * ADMITTED-PENDING-PROBE. The probe rule stands (PRD item 28 ruling (d),
69
- * 2026-09-09): no endpoint is in the menu without its own clean paid probe,
70
- * and `gemini-api` has ZERO runs. `anthropic-api` is the original (~167
71
- * archived runs) and `openai-api` was probed green through the shipped runner
72
- * (run mtu12vks, $1.64/$4, F157); gemini owes that fire and has not paid it.
73
- * `PROBE_STATUS` below is the machine-readable form of that debt — nothing in
74
- * here silently pretends the provider is proven.
75
- */
76
- export const GEMINI_TIER_MODELS = Object.freeze({
77
- sonnet: 'gemini-2.5-pro',
78
- haiku: 'gemini-2.5-flash',
79
- });
80
-
81
31
  /**
82
32
  * Which admitted providers have paid for their own end-to-end probe, and which
83
33
  * have not. Read by the runner to print a loud marker; never read to REFUSE —
@@ -91,64 +41,10 @@ export const PROBE_STATUS = Object.freeze({
91
41
  'gemini-api': Object.freeze({ probed: false, evidence: 'ZERO runs — admitted to the menu, owes its own paid probe (PRD item 31.3)' }),
92
42
  });
93
43
 
94
- /**
95
- * Per-MODEL request-key gating (F149, live-measured 2026-09-09): DeepSeek
96
- * silently IGNORES the modern `max_completion_tokens` key — asked for 64
97
- * output tokens, it returned 665/608 — and only honours the legacy
98
- * `max_tokens` key. `bare-agent`'s `OpenAIProvider` already exposes the
99
- * switch (`legacyMaxTokens`, BA-24; no model-name sniffing inside
100
- * bare-agent itself — "the caller declares the dialect"), so THIS table is
101
- * bareloop's routing of it, keyed per model, never a global default. An
102
- * output cap that does not bind is a money hazard: a reasoning model can
103
- * run to its own ceiling on every round, silently, so getting this wrong
104
- * for even one model defeats every budget that model's tier sets.
105
- *
106
- * A model with no entry here gets `{}` — bare-agent's own default
107
- * (`max_completion_tokens`, GPT-5-safe) applies, unmodified.
108
- *
109
- * F171 (2026-09-13): `deepseek-chat` was retired and swapped for
110
- * `deepseek-flash` (item 34 L16). This gating was measured on
111
- * `deepseek-chat` only and carried forward onto `deepseek-flash`
112
- * UNVERIFIED — re-verify before any paid DeepSeek run relies on it.
113
- */
114
- const OPENAI_MODEL_OPTIONS = Object.freeze({
115
- 'deepseek-flash': Object.freeze({ legacyMaxTokens: true }),
116
- });
117
-
118
- /**
119
- * Per-MODEL JUDGE-ONLY request options (F192, hamr ruling A 2026-10-09). deepseek-flash
120
- * thinks by default and bills the reasoning as output: run mv117wde's rubric judge locate
121
- * truncated at JUDGE_MAX_TOKENS=4000 four times, and a probe measured 1835 of 2409
122
- * completion tokens as reasoning. The judge extracts quotes and decides nothing, so it
123
- * runs with thinking off. These are PER-CALL options (bare-agent >=0.49 forwards
124
- * `thinking` verbatim to `body.thinking`), never constructor options: the judge may be
125
- * the very same provider instance as the worker (`buildRunnerProviders` reuses it), and
126
- * the worker is deliberately left as it was. A model with no entry gets nothing; the
127
- * field is never sent to a model not listed here (OpenAI/Anthropic would reject or
128
- * misread it). Read through `judgeCallOptions` only — one owner.
129
- */
130
- const OPENAI_JUDGE_CALL_OPTIONS = Object.freeze({
131
- 'deepseek-flash': Object.freeze({ thinking: Object.freeze({ type: 'disabled' }) }),
132
- });
133
-
134
- /**
135
- * The per-call options the rubric JUDGE's model calls carry for this provider
136
- * instance, from the allow-list above. Empty for any provider/model not listed.
137
- * @param {any} provider a constructed bare-agent provider
138
- * @returns {Record<string, any>}
139
- */
140
- export function judgeCallOptions(provider) {
141
- if (!(provider instanceof OpenAIProvider)) return {};
142
- return OPENAI_JUDGE_CALL_OPTIONS[/** @type {keyof typeof OPENAI_JUDGE_CALL_OPTIONS} */ (provider.model)] ?? {};
143
- }
144
-
145
44
  /**
146
45
  * @typedef {object} ProviderTableEntry
147
46
  * @property {new (options: any) => any} ctor the bare-agent provider class
148
- * @property {string} envKey the environment variable this provider reads its key from
149
- * @property {Readonly<Record<string, string>>} tiers tier name -> model id
150
47
  * @property {string} endpointKey the CONSTRUCTOR OPTION NAME this provider reads its endpoint from — NOT assumed to be `baseUrl` (see below)
151
- * @property {(model: string|undefined) => Record<string, any>} paramsFor per-model request-key/param gating, applied on TOP of {exposeErrorBody:true, apiKey, model, <endpointKey>?}
152
48
  */
153
49
 
154
50
  // ── WHY `endpointKey` IS A FIELD AND NOT THE LITERAL `baseUrl` ──────────────
@@ -178,35 +74,16 @@ export function judgeCallOptions(provider) {
178
74
  // the menu holds only http-shaped API providers, and the FIRST thing that
179
75
  // changes when Ollama is admitted is the one line nobody would think to look at.
180
76
 
181
- /** @type {Readonly<Record<string, ProviderTableEntry>>} */
77
+ /**
78
+ * The three client classes bare-agent exports, chosen by shape id. NO key name, model id or per-model gating lives here: a row's
79
+ * key is its own env name (src/providerrows.js), a job carries its own model, and the old max_tokens spelling is the ROW's tick
80
+ * (`legacyMaxTokens`, `makeProvider`).
81
+ * @type {Readonly<Record<string, ProviderTableEntry>>}
82
+ */
182
83
  const PROVIDER_TABLE = Object.freeze({
183
- 'anthropic-api': Object.freeze({
184
- ctor: AnthropicProvider,
185
- envKey: 'ANTHROPIC_API_KEY',
186
- tiers: ANTHROPIC_TIER_MODELS,
187
- endpointKey: 'baseUrl',
188
- // Anthropic has only ever had one request key for the output cap
189
- // (`max_tokens`) — nothing to gate here, ever (unlike OpenAI-shaped
190
- // backends, which fork on `max_tokens` vs `max_completion_tokens`).
191
- paramsFor: () => ({}),
192
- }),
193
- 'openai-api': Object.freeze({
194
- ctor: OpenAIProvider,
195
- envKey: 'OPENAI_API_KEY',
196
- tiers: OPENAI_TIER_MODELS,
197
- endpointKey: 'baseUrl',
198
- paramsFor: (model) => OPENAI_MODEL_OPTIONS[/** @type {string} */ (model)] ?? {},
199
- }),
200
- 'gemini-api': Object.freeze({
201
- ctor: GeminiProvider,
202
- envKey: 'GEMINI_API_KEY',
203
- tiers: GEMINI_TIER_MODELS,
204
- endpointKey: 'baseUrl',
205
- // Gemini carries no `max_tokens`/`max_completion_tokens` fork of its own —
206
- // bare-agent's GeminiProvider maps the cap internally. Nothing to gate, and
207
- // an empty object is the honest statement of that, not an omission.
208
- paramsFor: () => ({}),
209
- }),
84
+ 'anthropic-api': Object.freeze({ ctor: AnthropicProvider, endpointKey: 'baseUrl' }),
85
+ 'openai-api': Object.freeze({ ctor: OpenAIProvider, endpointKey: 'baseUrl' }),
86
+ 'gemini-api': Object.freeze({ ctor: GeminiProvider, endpointKey: 'baseUrl' }),
210
87
  });
211
88
 
212
89
  /**
@@ -232,7 +109,7 @@ export function resolveProvider(providerName) {
232
109
  /**
233
110
  * Report whether a resolved API key VALUE (not its presence — every call
234
111
  * site already presence-checks separately) carries a shape an HTTP header
235
- * value cannot: F181 (`docs/logs/FINDINGS.md`) — a two-line secret-store
112
+ * value cannot: F181 (`docs/wiki/FINDINGS.md`) — a two-line secret-store
236
113
  * entry put the key plus a trailing metadata line into `OPENAI_API_KEY`
237
114
  * (`"<key>\nmeta"`), the presence-only check at every door passed, and Node
238
115
  * crashed `TypeError [ERR_INVALID_CHAR]` inside its own header-encode at the
@@ -266,15 +143,15 @@ export function apiKeyProblem(value) {
266
143
  }
267
144
 
268
145
  /**
269
- * Construct ONE provider instance for `providerName`/`model`, applying
270
- * `exposeErrorBody: true` (F153 — every constructed provider, unconditionally)
271
- * and the model's own request-key gating (`paramsFor`). `baseUrl` (PRD item
272
- * 28, ruling (d), 2026-09-09: admitted in v1) is forwarded only when given —
273
- * every provider constructor here defaults it on its own when absent.
146
+ * Construct ONE provider instance for `providerName`/`model`, applying `exposeErrorBody: true` (F153 — every constructed
147
+ * provider, unconditionally). `baseUrl` (PRD item 28, ruling (d), 2026-09-09: admitted in v1) is forwarded only when given.
148
+ * `legacyMaxTokens` is the Settings ROW's "old max_tokens" tick: a server that ignores `max_completion_tokens` (F149) does not
149
+ * bind the output cap until it is on. It is forwarded only for `openai-api` and only when `true` — nothing here sniffs a host
150
+ * or a model id to decide it.
274
151
  * @param {string} providerName
275
- * @param {{apiKey?: string, model?: string, baseUrl?: string}} [options]
152
+ * @param {{apiKey?: string, model?: string, baseUrl?: string, legacyMaxTokens?: boolean}} [options]
276
153
  */
277
- export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
154
+ export function makeProvider(providerName, { apiKey, model, baseUrl, legacyMaxTokens } = {}) {
278
155
  const entry = resolveProvider(providerName);
279
156
  return new entry.ctor({
280
157
  exposeErrorBody: true,
@@ -287,105 +164,59 @@ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
287
164
  // is spelled differently would otherwise IGNORE the endpoint silently and
288
165
  // run against its own default; see the note above the table.
289
166
  ...(baseUrl !== undefined ? { [entry.endpointKey]: baseUrl } : {}),
290
- ...entry.paramsFor(model),
167
+ ...(providerName === 'openai-api' && legacyMaxTokens === true ? { legacyMaxTokens: true } : {}),
291
168
  });
292
169
  }
293
170
 
294
171
  /**
295
- * Build the `{provider, providerFor, judgeProvider}` triple both runners
296
- * need (`src/authorrun.js` and `src/panel/authorsession.js` call it) — ONE seam instead of two copies of the same construction and
297
- * tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
298
- * (the caller runs `resolveWorkerModel` itself, exactly as both runners did
299
- * before this factory existed — that decision stays where each runner's own
300
- * `--model` flag/spec precedence lives, not duplicated in here).
301
- *
302
- * THE JUDGE IS NO LONGER PINNED TO ANTHROPIC (PRD item 32.2, hamr 2026-09-10:
303
- * *"what i care about is that judge becomes llm agnostic and not set to one
304
- * model or provider"*). It is constructed through this same factory, from a
305
- * `judgeProviderName`/`judgeModel` pair the CALLER resolved (`resolveJudge`,
306
- * src/judged.js — the spec's signed `judge` override, else the job's own worker
307
- * provider and model). Item 28's pin, and item 28 part (2)'s deferral of a
308
- * signed `judge:{provider,model}` field, both close here.
172
+ * Build the `{provider, providerFor, judgeProvider}` triple both authoring runners need (`src/authorrun.js` and
173
+ * `src/panel/authorsession.js` call it) — ONE seam instead of two copies of the same construction. `model` is the
174
+ * already-RESOLVED worker model id (the caller picked the Settings line).
309
175
  *
310
- * `judgeApiKey` stays a SEPARATE argument rather than being derived: the judge
311
- * may legitimately be a different provider from the worker, and reading its key
312
- * for it would put a second env-var reader in a module whose whole job is to
313
- * have exactly one. The caller reads the resolved judge provider's own `envKey`
314
- * and hands the key in — the same contract the worker key already has here.
176
+ * THE JUDGE IS NOT PINNED (PRD item 32.2): it is constructed through this same factory from a `judgeProviderName`/`judgeModel`
177
+ * pair the CALLER resolved. `judgeApiKey` stays a SEPARATE argument: the judge may be a different provider from the worker,
178
+ * and each key is read by the caller from its own row.
315
179
  * @param {object} args
316
- * @param {string} args.providerName spec.provider (or the anthropic-api default)
317
- * @param {string|undefined} args.apiKey the WORKER's key, read from the provider's own `envKey`
180
+ * @param {string} args.providerName the worker's API shape id
181
+ * @param {string|undefined} args.apiKey the WORKER's key, read from its row's env name
318
182
  * @param {string} args.model the already-resolved worker model id
319
- * @param {Readonly<Record<string, string>>} args.tierModels the already-resolved tier -> model map (spec override folded in by the caller)
320
- * @param {string|undefined} [args.baseUrl] spec.baseUrl, if any
321
- * @param {string|undefined} args.judgeApiKey the judge's own key, read by the caller from the RESOLVED judge provider's `envKey`
183
+ * @param {string|undefined} [args.baseUrl] the worker row's Base URL
184
+ * @param {boolean} [args.legacyMaxTokens] the worker row's old max_tokens tick
185
+ * @param {string|undefined} args.judgeApiKey the judge's own key
322
186
  * @param {string} args.judgeModel the resolved judge model id
323
- * @param {string} [args.judgeProviderName] the resolved judge provider name; defaults to `providerName` (the job's own worker provider), which is `resolveJudge`'s own default
324
- * @param {string|undefined} [args.judgeBaseUrl] the judge provider's endpoint, when it takes one
187
+ * @param {string} [args.judgeProviderName] the resolved judge shape; defaults to `providerName`
188
+ * @param {string|undefined} [args.judgeBaseUrl] the judge row's endpoint
189
+ * @param {boolean} [args.judgeLegacyMaxTokens] the judge row's old max_tokens tick (defaults to the worker's when the judge shares its provider)
325
190
  */
326
191
  export function buildRunnerProviders({
327
- providerName, apiKey, model, tierModels, baseUrl, judgeApiKey, judgeModel,
328
- judgeProviderName = providerName, judgeBaseUrl = undefined,
192
+ providerName, apiKey, model, baseUrl, legacyMaxTokens = false, judgeApiKey, judgeModel,
193
+ judgeProviderName = providerName, judgeBaseUrl = undefined, judgeLegacyMaxTokens = undefined,
329
194
  }) {
330
- const provider = makeProvider(providerName, { apiKey, model, baseUrl });
331
- /** @type {Record<string, any>} */
332
- const tierCache = {};
333
- const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (
334
- tierModels[tier] === model
335
- ? provider
336
- : makeProvider(providerName, { apiKey, model: tierModels[tier], baseUrl })
337
- ));
338
- // the judge goes through the SAME table as the worker — one place names a
339
- // provider identity, which is the entire reason this factory exists. Reusing
340
- // the worker instance when the identity matches exactly is not an optimisation
341
- // for its own sake: a second instance against the same endpoint/model is a
342
- // second prompt-cache prefix, and cache writes are ~41% of the bill.
195
+ const provider = makeProvider(providerName, { apiKey, model, baseUrl, legacyMaxTokens });
196
+ // a plan step's `model` is the one schema token `sonnet` (STEP_MODELS, src/plan.js): it means THE JOB'S worker model
197
+ const providerFor = (/** @type {string} */ _tier) => provider;
198
+ // the judge goes through the SAME table as the worker. Reusing the worker instance when the identity matches exactly is not an
199
+ // optimisation for its own sake: a second instance against the same endpoint/model is a second prompt-cache prefix, and cache
200
+ // writes are ~41% of the bill.
201
+ const judgeLegacy = judgeLegacyMaxTokens ?? (judgeProviderName === providerName ? legacyMaxTokens : false);
343
202
  const judgeProvider = (judgeProviderName === providerName && judgeModel === model && judgeApiKey === apiKey
344
- && judgeBaseUrl === baseUrl)
203
+ && judgeBaseUrl === baseUrl && judgeLegacy === legacyMaxTokens)
345
204
  ? provider
346
- : makeProvider(judgeProviderName, { apiKey: judgeApiKey, model: judgeModel, baseUrl: judgeBaseUrl });
205
+ : makeProvider(judgeProviderName, { apiKey: judgeApiKey, model: judgeModel, baseUrl: judgeBaseUrl, legacyMaxTokens: judgeLegacy });
347
206
  return { provider, providerFor, judgeProvider };
348
207
  }
349
208
 
350
209
  /**
351
- * A `providerName`'s own models-list GET endpoint — the shape this
352
- * function's ONE caller (`checkProviderReachable`, below) needs and nothing
353
- * else reads: `anthropic-api` and `openai-api` (the panel's Model select,
354
- * PANEL-BUILD.md P3 build item 7) are the only two shapes this build knows
355
- * how to ask, both a plain unauthenticated-body GET with the key in a
356
- * header — never a completion/chat endpoint, so this never spends a token.
357
- * @param {string} providerName
358
- * @param {string} [baseUrl]
359
- * @returns {{url: string, headers: Record<string,string>}|null} `null` when
360
- * this provider's models-list shape is not known (never guessed at)
361
- */
362
- function modelsListRequest(providerName, baseUrl) {
363
- if (providerName === 'anthropic-api') {
364
- return {
365
- url: `${baseUrl || 'https://api.anthropic.com/v1'}/models`,
366
- headers: { 'x-api-key': '', 'anthropic-version': '2023-06-01' },
367
- };
368
- }
369
- if (providerName === 'openai-api') {
370
- return { url: `${baseUrl || 'https://api.openai.com/v1'}/models`, headers: {} };
371
- }
372
- return null;
373
- }
374
-
375
- /**
376
- * $0 reachability check: a GET against `providerName`'s own models-list
377
- * endpoint, server-side, with the key in a header — NEVER a completion
378
- * request, so it never spends a token (PANEL-BUILD.md P3 build item 7: "the
379
- * person must get feedback BEFORE drafting if the chosen model can't
380
- * work"). Reused, unmodified, by the panel's model-readiness route today
381
- * (`src/panel/authorroutes.js`) and by the future Settings providers
382
- * table's own "test" column (PANEL-BUILD.md P4) — ONE function, not two
383
- * hand-typed copies of the same probe.
210
+ * $0 reachability check: ONE GET of the provider's models list (`src/modellist.js`, the one owner of
211
+ * that call), server-side, with the key in a header — NEVER a completion request, so it never spends
212
+ * a token (PANEL-BUILD.md P3 build item 7: "the person must get feedback BEFORE drafting if the
213
+ * chosen model can't work"). Used by the panel's model-readiness route (`src/panel/authorroutes.js`);
214
+ * Settings > Providers' Test and Refresh call `listModels` directly. A blank `baseUrl` makes no call.
384
215
  *
385
- * NEVER returns the key, never logs it — the key only ever appears in a
386
- * request header sent straight to the provider's own host.
216
+ * NEVER returns the key, never logs it — the key only ever appears in a request header sent
217
+ * straight to the provider's own host.
387
218
  * @param {object} o
388
- * @param {string} o.providerName
219
+ * @param {string} o.providerName an API shape id
389
220
  * @param {string} o.apiKey
390
221
  * @param {string} [o.model] when given, the response is also checked for this model id
391
222
  * @param {string} [o.baseUrl]
@@ -396,39 +227,16 @@ function modelsListRequest(providerName, baseUrl) {
396
227
  export async function checkProviderReachable({
397
228
  providerName, apiKey, model, baseUrl, fetchImpl = fetch, timeoutMs = 4000,
398
229
  }) {
399
- const req = modelsListRequest(providerName, baseUrl);
400
- if (!req) {
230
+ if (!SHAPE_IDS.includes(providerName)) {
401
231
  return {
402
232
  reachable: false, modelListed: null, status: 'unsupported-provider',
403
233
  note: `no models-list check known for "${providerName}"`,
404
234
  };
405
235
  }
406
- const headers = { ...req.headers };
407
- if (providerName === 'anthropic-api') headers['x-api-key'] = apiKey;
408
- else headers.Authorization = `Bearer ${apiKey}`;
409
-
410
- const controller = new AbortController();
411
- const timer = setTimeout(() => controller.abort(), timeoutMs);
412
- try {
413
- // GET ONLY — no body, no completion/chat call, no tokens spent.
414
- const res = await fetchImpl(req.url, { method: 'GET', headers, signal: controller.signal });
415
- if (!res.ok) {
416
- return { reachable: false, modelListed: null, status: `HTTP ${res.status}`, note: `models list returned HTTP ${res.status}` };
417
- }
418
- /** @type {any} */
419
- let body = null;
420
- try { body = await res.json(); } catch { body = null; }
421
- const ids = Array.isArray(body?.data) ? body.data.map((/** @type {any} */ m) => m?.id).filter(Boolean) : [];
422
- const modelListed = model ? ids.includes(model) : null;
423
- return { reachable: true, modelListed, status: 'ok', note: null };
424
- } catch (/** @type {any} */ e) {
425
- const cls = e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'network-error');
426
- // connection refused surfaces as fetch's bare TypeError ("fetch failed") — say it plainly
427
- const refused = e?.name === 'TypeError' || e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED';
428
- return { reachable: false, modelListed: null, status: String(cls), note: refused ? `nothing answering at ${req.url}` : `request failed: ${cls}` };
429
- } finally {
430
- clearTimeout(timer);
431
- }
236
+ const r = await listModels({ shape: providerName, baseUrl: baseUrl ?? '', key: apiKey, fetchImpl, timeoutMs });
237
+ if (r.ok) return { reachable: true, modelListed: model ? r.models.includes(model) : null, status: 'ok', note: null };
238
+ const note = r.status.startsWith('HTTP ') ? `models list returned ${r.status}` : (r.detail ?? r.why);
239
+ return { reachable: false, modelListed: null, status: r.status, note };
432
240
  }
433
241
 
434
242
  /**
@@ -0,0 +1,18 @@
1
+ // rewritten-from: fwdloop src/providershapes.js@f9449e8
2
+ // PANEL-BUILD.md P8 — the API shapes the Settings > Providers dropdown offers, ONE owner.
3
+ // A shape is a wire protocol, never a place: no web address lives here (each row's Base URL
4
+ // is typed in Settings). Only a shape `makeProvider` (src/providers.js) can really build is listed.
5
+ // Kept in its own file so config.js can validate a shape without importing providers.js.
6
+
7
+ /**
8
+ * `id` is the value saved in config.json (and the signed spec's `provider`).
9
+ * @type {readonly {id: string, label: string}[]}
10
+ */
11
+ export const SHAPES = Object.freeze([
12
+ Object.freeze({ id: 'anthropic-api', label: 'Anthropic' }),
13
+ Object.freeze({ id: 'openai-api', label: 'OpenAI-compatible' }),
14
+ Object.freeze({ id: 'gemini-api', label: 'Gemini' }),
15
+ ]);
16
+
17
+ /** @type {readonly string[]} */
18
+ export const SHAPE_IDS = Object.freeze(SHAPES.map((s) => s.id));
package/src/replay.js CHANGED
@@ -1520,7 +1520,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1520
1520
  // never hidden. `null` when there is nothing to compare (a resumed run,
1521
1521
  // where a gap is expected and already explained; or `thisFileSpend`
1522
1522
  // itself unknown) or when the two already agree within $0.0001.
1523
- spendMismatch: (!resumed && spentUsd !== null && thisFileSpend.value !== null && Math.abs(spentUsd - thisFileSpend.value) > 0.0001)
1523
+ spendMismatch: (!resumed && spentUsd !== null && thisFileSpend.value !== null && Math.abs(spentUsd - thisFileSpend.value) > 1e-4)
1524
1524
  ? { spentUsd, thisFileUsd: thisFileSpend.value, diffUsd: spentUsd - thisFileSpend.value }
1525
1525
  : null,
1526
1526
  timelineKind,
package/src/reuse.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — the D7 ENVELOPE and the REUSE RUNNER (design record
2
- // docs/product/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
2
+ // docs/logs/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
  //
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/product/2026-07-19-layer-r-design.md). One state that survives attempts
2
+ // docs/logs/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/product/2026-07-12-n2-headless-loop-design.md.
6
+ // Design record: docs/logs/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
@@ -219,7 +219,7 @@ async function primitiveSmoke(workdir) {
219
219
  * frozen A0 baseline arm has to be exactly today, so a guard firing under a disabled
220
220
  * shim would make the baseline a treatment arm. The default-flip belongs to a
221
221
  * paid contrast nobody has approved yet (the `layerRoot` precedent, F41).
222
- * @param {boolean} [opts.scout=true] operator-only switch (docs/product/SCOUT-CONTRAST.md),
222
+ * @param {boolean} [opts.scout=true] operator-only switch (docs/logs/SCOUT-CONTRAST.md),
223
223
  * forwarded verbatim to the plan flow: `false` on a fresh run (no `resumeSeed`) skips the
224
224
  * read-only survey and records `scout-skipped {reason:'operator-off'}` instead — the
225
225
  * planner drafts from `(no scout notes)`. Runner territory exactly like `readShim`: the
@@ -282,7 +282,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
282
282
  // blind-instrument failure this programme keeps logging. Only the guard runs
283
283
  // here; the flag itself is threaded onward to the plan flow as written.
284
284
  readShimArm(readShim);
285
- // The scout switch's guard, same class and same door (docs/product/SCOUT-CONTRAST.md):
285
+ // The scout switch's guard, same class and same door (docs/logs/SCOUT-CONTRAST.md):
286
286
  // a mis-coerced value would run under the wrong label with no way to recover which arm
287
287
  // actually fired from the results afterwards.
288
288
  if (typeof scout !== 'boolean') throw new TypeError(`scout: expected boolean, got ${JSON.stringify(scout)}`);
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/product/2026-08-01-layer-3-reuse-design.md, D3).
2
+ // pick (design record docs/logs/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/source.js CHANGED
@@ -25,7 +25,7 @@
25
25
  import { createHash } from 'node:crypto';
26
26
  import { existsSync, lstatSync, readFileSync } from 'node:fs';
27
27
  import { mkdir, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
28
- import { dirname, join, relative, resolve, sep, basename } from 'node:path';
28
+ import { dirname, join, relative, resolve, sep, basename, isAbsolute } from 'node:path';
29
29
  import { git } from './kinds.js';
30
30
  import { addWorktree, hideBareloopDir } from './worktree.js';
31
31
  import { MAX_BUFFER } from './kinds.js';
@@ -1160,3 +1160,66 @@ export function nonRepoSourceMessage(kind) {
1160
1160
  if (kind === 'folder') return "Source is a plain folder, not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
1161
1161
  return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
1162
1162
  }
1163
+
1164
+ /**
1165
+ * P7 (docs/product/PANEL-BUILD.md, "Inputs"): prove the NUMBERED INPUTS at $0, before any token. Line 1 is the repo
1166
+ * (an absolute path to an existing folder); lines 2+ are files or folders INSIDE that repo, repo-relative. A line
1167
+ * that is not one of those is a named red naming its line number — never a throw, never a silent skip:
1168
+ * - a `search:` line or a URL, and a path that leaves the repo: "only files inside the repo for now" (search and
1169
+ * outside-the-repo inputs arrive with the plain-folder piece);
1170
+ * - a path that does not exist, or that git does not track (a repo job would not see it);
1171
+ * - the front door's own refusals for a file: an environment file by NAME, a symlink, a non-text file, an oversize
1172
+ * file. A known secret shape in its content is NOT a refusal (masked where recorded, as the P6 worktree door does).
1173
+ * A folder value is checked for existence and tracking only (its files are the repo's own, which the worktree door
1174
+ * already masks). Reads the tree; writes nothing.
1175
+ * @param {{n: number, label: string, value: string}[]} inputs the parsed Inputs (`parseInputs`)
1176
+ * @returns {Promise<{ok: true, repo: string, root: string|null}|{ok: false, error: string}>} `repo` = line 1's absolute path; `root` = the git root it sits in (null when line 1 alone was given and it is not in a git repo)
1177
+ */
1178
+ export async function proveInputFiles(inputs) {
1179
+ const first = inputs[0];
1180
+ if (!first) return { ok: false, error: 'Inputs, line 1: the repo is required' };
1181
+ /** @param {number} n @param {string} msg @returns {{ok: false, error: string}} */
1182
+ const bad = (n, msg) => ({ ok: false, error: `Inputs, line ${n}: ${msg}` });
1183
+ if (/^https?:\/\//i.test(first.value) || !isAbsolute(first.value)) return bad(1, 'the repo must be an absolute path, like /home/me/myrepo');
1184
+ const repo = resolve(first.value);
1185
+ let st;
1186
+ try { st = lstatSync(repo); } catch { return bad(1, `${repo} does not exist`); }
1187
+ // line 1 alone is the repo the job works on; whether it IS a code repo is the source door's own refusal (and
1188
+ // `nonRepoSourceMessage`) downstream — only lines 2+ need a git tree to be proven against
1189
+ const found = st.isDirectory() ? nearestGitAncestor(repo) : null;
1190
+ if (inputs.length === 1) return { ok: true, repo, root: found && !found.isFile ? found.dir : null };
1191
+ if (!st.isDirectory()) return bad(1, `${repo} is not a folder — line 1 is the repo`);
1192
+ if (found === null || found.isFile) return bad(1, `${repo} is not inside a git repo, so the other inputs cannot be checked`);
1193
+ const root = found.dir;
1194
+ const rootReal = await realpath(root);
1195
+ const repoReal = await realpath(repo);
1196
+ for (const inp of inputs.slice(1)) {
1197
+ const { n, label, value } = inp;
1198
+ if (/^search$/i.test(label) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return bad(n, 'only files inside the repo for now');
1199
+ // like-for-like: resolve against the repo as the REAL filesystem sees it, so a repo reached through a symlinked parent
1200
+ // compares against `rootReal` in the same spelling (a symlinked INPUT FILE is still refused below, by lstat)
1201
+ const abs = resolve(repoReal, value);
1202
+ const outside = (/** @type {string} */ p) => !(p === rootReal || p.startsWith(`${rootReal}${sep}`));
1203
+ if (outside(abs)) return bad(n, 'only files inside the repo for now');
1204
+ let ls;
1205
+ try { ls = await lstat(abs); } catch { return bad(n, `${value} does not exist in the repo`); }
1206
+ if (ls.isSymbolicLink()) return bad(n, `${value} is a symlink — refused, never followed`);
1207
+ let real;
1208
+ try { real = await realpath(abs); } catch { return bad(n, `${value} does not exist in the repo`); }
1209
+ if (outside(real)) return bad(n, 'only files inside the repo for now');
1210
+ const rel = relative(rootReal, abs).split(sep).join('/');
1211
+ const tracked = await git(root, ['ls-files', '-z', '--', rel === '' ? '.' : rel]);
1212
+ if (!tracked.ok || tracked.out.split('\0').filter(Boolean).length === 0) {
1213
+ return bad(n, `${value} is not tracked by git — a repo job only sees tracked files; commit it first`);
1214
+ }
1215
+ if (ls.isDirectory()) continue;
1216
+ if (ENV_FILE.test(basename(abs))) return bad(n, `${value} is an environment file — refused by NAME, whatever it contains (secrets never enter a job)`);
1217
+ if (ls.size > MAX_BUFFER) return bad(n, `${value} is over the ${MAX_BUFFER}B per-file ceiling`);
1218
+ const buf = await readFile(abs);
1219
+ if (hasNulByte(buf.subarray(0, 8192))) return bad(n, `${value} is not a text file — text only for now`);
1220
+ // a secret-shaped CONTENT is not refused (P6 worktree repo door, hamr 2026-10-05 Q2=A, same ruling for inputs): the file is
1221
+ // the person's own, left in place; bareloop masks a secret wherever IT records one (`redactSecrets`, the one inventory) and
1222
+ // never copies this file's text into a record — only its path travels. `.env` by name and symlinks stay refused above.
1223
+ }
1224
+ return { ok: true, repo, root };
1225
+ }
package/src/text.js CHANGED
@@ -191,6 +191,19 @@ export function callCasualty(err) {
191
191
  if (err instanceof HaltError) return null;
192
192
  const e = /** @type {any} */ (err);
193
193
  if (e?.code === 'ETIMEDOUT' || e?.name === 'TimeoutError') return String(e?.message ?? e);
194
+ // F217 — a DROPPED CONNECTION is the same class as an idle timeout: a call that produced nothing usable. Two REAL
195
+ // shapes reach this seam out of bare-agent 0.49.0's OpenAIProvider (read in node_modules/bare-agent/src/provider-
196
+ // openai.js `_request` + provider-http.js), both thrown straight through `Loop.run` (loop.js rethrows non-Halt errors):
197
+ // 1. `req.on('error', reject)` passes node's RAW socket error unwrapped: `Error{code:'ECONNRESET', message:'socket
198
+ // hang up'}` (the server closed before any response).
199
+ // 2. `guardResponseSettles` (BA-25), the connection cut after the headers: `ProviderError{code:'PROVIDER_ERROR',
200
+ // retryable:true, context:{bound:'transport'}}`, message "response stream aborted/error/close before the body
201
+ // completed". An HTTP-status ProviderError never carries `bound:'transport'`, so it is not admitted here.
202
+ // Other raw codes (ECONNREFUSED, ENOTFOUND, EPIPE...) reach the same raw path but name a wrong URL / no network, not a
203
+ // connection that DROPPED; they are deliberately not admitted (they re-raise loudly, as before).
204
+ if (e?.code === 'ECONNRESET' || e?.context?.bound === 'transport') {
205
+ return `the connection to the provider dropped (${String(e?.message ?? e)})`;
206
+ }
194
207
  return null;
195
208
  }
196
209