bareloop 0.39.0 → 0.40.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 (51) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bareloop.context.md +63 -11
  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 +5 -2
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/calibrate.js +1 -1
  12. package/src/cardauthor.js +4 -1
  13. package/src/declaredclose.js +4 -3
  14. package/src/draftspend.js +6 -1
  15. package/src/interviewrun.js +56 -27
  16. package/src/job.js +38 -5
  17. package/src/jobblock.js +140 -0
  18. package/src/judged.js +1 -1
  19. package/src/ledger.js +1 -1
  20. package/src/panel/authorroutes.js +15 -5
  21. package/src/panel/authorsession.js +158 -47
  22. package/src/panel/index.html +179 -70
  23. package/src/panel/jobplan.js +107 -0
  24. package/src/panel/server.js +67 -13
  25. package/src/planrun.js +4 -4
  26. package/src/promptregisters.js +1 -1
  27. package/src/providers.js +101 -19
  28. package/src/reuse.js +1 -1
  29. package/src/root.js +1 -1
  30. package/src/run.js +3 -3
  31. package/src/selection.js +1 -1
  32. package/src/source.js +64 -1
  33. package/src/text.js +13 -0
  34. package/src/transport.js +1 -1
  35. package/src/userrun.js +3 -3
  36. package/types/authorflow.d.ts +258 -39
  37. package/types/authoring.d.ts +7 -1
  38. package/types/authorjob.d.ts +35 -3
  39. package/types/authorreadout.d.ts +17 -1
  40. package/types/authorscout.d.ts +5 -3
  41. package/types/declaredclose.d.ts +4 -1
  42. package/types/draftspend.d.ts +8 -2
  43. package/types/job.d.ts +1 -1
  44. package/types/jobblock.d.ts +102 -0
  45. package/types/panel/authorsession.d.ts +50 -14
  46. package/types/panel/jobplan.d.ts +58 -0
  47. package/types/panel/server.d.ts +20 -0
  48. package/types/planrun.d.ts +1 -1
  49. package/types/providers.d.ts +47 -3
  50. package/types/run.d.ts +1 -1
  51. package/types/source.d.ts +26 -0
@@ -36,7 +36,7 @@ import {
36
36
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
37
37
  } from '../replayio.js';
38
38
  import { summarizeForAllLine, auditWindowsOf } from '../replay.js';
39
- import { legsOf, legsWallMs, stopFilePath } from '../legs.js';
39
+ import { legsOf, legsWallMs, stopFilePath, capKindOf } from '../legs.js';
40
40
  import { chainSpend } from '../ledger.js';
41
41
  import { runBehaviour } from '../behaviour.js';
42
42
  import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
@@ -55,6 +55,7 @@ import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
55
55
  import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
56
56
  import { statusFor, partStatusFor, GOAL_MET_LINE } from './status.js';
57
57
  import { createSettingsRoutes } from './settingsroutes.js';
58
+ import { planFromSpec } from './jobplan.js';
58
59
 
59
60
  const HERE = dirname(fileURLToPath(import.meta.url));
60
61
 
@@ -479,7 +480,7 @@ function endedBase(summary, death, o) {
479
480
  if (outcome === 'escalated') {
480
481
  const strikes = esc?.spend?.strikes;
481
482
  const limit = esc?.spend?.strikeLimit;
482
- if (cat === 'cap-halt' && o.moneyHalt) {
483
+ if (capKindOf({ outcome, category: cat, moneyHalt: o.moneyHalt }) === 'money') {
483
484
  const money = typeof summary.spentUsd === 'number' && typeof summary.budgetUsd === 'number'
484
485
  ? ` (${panelMoney2(summary.spentUsd)} of ${panelMoney2(summary.budgetUsd)})` : '';
485
486
  return {
@@ -494,7 +495,7 @@ function endedBase(summary, death, o) {
494
495
  actions: [],
495
496
  };
496
497
  }
497
- if (cat === 'wall-halt') {
498
+ if (capKindOf({ outcome, category: cat, moneyHalt: o.moneyHalt }) === 'time') {
498
499
  return { reason: 'Time cap reached.', next: CHANGE, line: 'time cap', actions: [] };
499
500
  }
500
501
  if (cat === 'provider-red') {
@@ -2098,6 +2099,42 @@ function toolsFromSpec(spec) {
2098
2099
  return names ? names.join(' · ') : null;
2099
2100
  }
2100
2101
 
2102
+ /**
2103
+ * The Signed row of the Job tab: `YYYY-MM-DD HH:MM · <first 6 of specHash>`, the time being the run's FIRST `job-start`
2104
+ * in local time (hamr, P7). `null` when either part is not on record — never a made-up stamp.
2105
+ * @param {string|null|undefined} ts an ISO stamp @param {string|null|undefined} hash
2106
+ * @returns {string|null}
2107
+ */
2108
+ export function signedLine(ts, hash) {
2109
+ const t = typeof ts === 'string' ? new Date(ts) : null;
2110
+ if (!t || Number.isNaN(t.getTime()) || typeof hash !== 'string' || hash.length < 6) return null;
2111
+ const p2 = (/** @type {number} */ n) => String(n).padStart(2, '0');
2112
+ return `${t.getFullYear()}-${p2(t.getMonth() + 1)}-${p2(t.getDate())} ${p2(t.getHours())}:${p2(t.getMinutes())} · ${hash.slice(0, 6)}`;
2113
+ }
2114
+
2115
+ /**
2116
+ * The Job box's text for a signed spec: the person's own numbered lines (a `~` line per entry of `rules`, or — for a spec
2117
+ * signed before `rules` existed — per '; '-split piece of `rule`), or — for
2118
+ * an older job that saved no job lines — the signed goal sentence as the one line.
2119
+ * @param {any} spec @returns {string}
2120
+ */
2121
+ export function jobTextFromSpec(spec) {
2122
+ if (Array.isArray(spec?.jobLines) && spec.jobLines.length) {
2123
+ return spec.jobLines.map((/** @type {any} */ l) => [String(l.text), ...(Array.isArray(l.rules) ? l.rules.map((r) => `~ ${r}`) : l.rule ? String(l.rule).split('; ').map((r) => `~ ${r}`) : [])].join('\n')).join('\n');
2124
+ }
2125
+ return typeof spec?.goal === 'string' ? spec.goal : '';
2126
+ }
2127
+
2128
+ /**
2129
+ * The Inputs box's text for a signed spec: its numbered inputs, or — for an older job — `repo: <source>` from the
2130
+ * caller's best knowledge of the source (or empty).
2131
+ * @param {any} spec @param {string} source @returns {string}
2132
+ */
2133
+ export function inputsTextFromSpec(spec, source) {
2134
+ if (Array.isArray(spec?.inputs) && spec.inputs.length) return spec.inputs.map((/** @type {any} */ i) => `${i.label}: ${i.value}`).join('\n');
2135
+ return source ? `repo: ${source}` : '';
2136
+ }
2137
+
2101
2138
  /**
2102
2139
  * `<src/panel>/../../jobs` — the repo's `jobs/` directory of signed specs,
2103
2140
  * resolved from the bareloop PACKAGE ROOT (this module's own on-disk
@@ -2180,8 +2217,20 @@ export function getRunJob(runid, opts = {}) {
2180
2217
  : (typeof jobStart?.model === 'string' ? jobStart.model : null),
2181
2218
  );
2182
2219
 
2220
+ // the Signed row: the run's FIRST job-start, local time (P7)
2221
+ let firstStart = null;
2222
+ if (existsSync(row.spine)) {
2223
+ try { firstStart = parseJsonl(row.spine).records.find((r) => r && typeof r === 'object' && r.type === 'job-start') ?? null; } catch { firstStart = null; }
2224
+ }
2183
2225
  /** @param {any} spec @param {boolean} resolved @param {string} resolvedFrom @param {string|null} note */
2184
2226
  const fromSpec = (spec, resolved, resolvedFrom, note) => ({
2227
+ // P7: the same plan model the chat's plan bubble is drawn from (src/panel/jobplan.js), read from the SIGNED spec
2228
+ plan: planFromSpec(spec),
2229
+ jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null,
2230
+ inputs: Array.isArray(spec.inputs) && spec.inputs.length ? spec.inputs : [{ n: 1, label: 'repo', value: sourceDisplay() }],
2231
+ signedAt: typeof firstStart?.ts === 'string' ? firstStart.ts : null,
2232
+ specHash: typeof jobStart?.specHash === 'string' ? jobStart.specHash : null,
2233
+ signed: signedLine(firstStart?.ts, typeof jobStart?.specHash === 'string' ? jobStart.specHash : null),
2185
2234
  runid,
2186
2235
  job: typeof spec.job === 'string' ? spec.job : row.job,
2187
2236
  resolved,
@@ -2202,6 +2251,7 @@ export function getRunJob(runid, opts = {}) {
2202
2251
  note,
2203
2252
  });
2204
2253
  const none = () => ({
2254
+ plan: null, jobLines: null, inputs: [{ n: 1, label: 'repo', value: sourceDisplay() }], signedAt: null, specHash: null, signed: null,
2205
2255
  runid,
2206
2256
  job: row.job,
2207
2257
  resolved: false,
@@ -2263,6 +2313,10 @@ export function getRunJob(runid, opts = {}) {
2263
2313
  // (d) the run's own job-start record — narrower, unsigned.
2264
2314
  if (jobStart) {
2265
2315
  return {
2316
+ plan: null, jobLines: null, inputs: [{ n: 1, label: 'repo', value: sourceDisplay() }],
2317
+ signedAt: typeof jobStart.ts === 'string' ? jobStart.ts : null,
2318
+ specHash: typeof jobStart.specHash === 'string' ? jobStart.specHash : null,
2319
+ signed: signedLine(jobStart.ts, jobStart.specHash),
2266
2320
  runid,
2267
2321
  job: typeof jobStart.job === 'string' ? jobStart.job : row.job,
2268
2322
  resolved: false,
@@ -2456,7 +2510,8 @@ export function getStartFrom(runid, opts = {}) {
2456
2510
  : (findRow(rowsForHome(keysHome(opts.home)), { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name
2457
2511
  ?? (typeof jobStart?.model === 'string' ? jobStart.model : '')),
2458
2512
  source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
2459
- judgeExamples: typeof saved.judgeExamples === 'string' ? saved.judgeExamples : '',
2513
+ // a card saved by P7 or later keeps the Inputs text the person typed
2514
+ inputs: typeof saved.inputs === 'string' && saved.inputs ? saved.inputs : '',
2460
2515
  jobName: row.job,
2461
2516
  });
2462
2517
  const key = workflowKey(spec);
@@ -2470,7 +2525,7 @@ export function getStartFrom(runid, opts = {}) {
2470
2525
  from: 'signed job',
2471
2526
  locked: REUSE_LOCKED_FIELDS,
2472
2527
  open: REUSE_OPEN_FIELDS,
2473
- note: card.checkType === 'rubric' && card.judgeExamples === '' ? 'judge examples were not saved for this run' : null,
2528
+ note: Array.isArray(spec.jobLines) && spec.jobLines.length ? null : 'older job — numbered lines were not saved',
2474
2529
  specHash: signed.specHash,
2475
2530
  workflowKey: key,
2476
2531
  spec,
@@ -2518,7 +2573,7 @@ export function listReuseJobs(opts = {}) {
2518
2573
  * The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
2519
2574
  * the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
2520
2575
  * @param {any} spec
2521
- * @param {{model: string, source: string, judgeExamples: string, jobName: string}} extra
2576
+ * @param {{model: string, source: string, inputs?: string, jobName: string}} extra `inputs` = the Inputs text the person saved (else built from the spec's inputs, else `repo: <source>`)
2522
2577
  * @returns {Record<string, any>}
2523
2578
  */
2524
2579
  function reuseCardFromSpec(spec, extra) {
@@ -2527,12 +2582,9 @@ function reuseCardFromSpec(spec, extra) {
2527
2582
  jobName: typeof spec.job === 'string' && spec.job ? spec.job : extra.jobName,
2528
2583
  checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
2529
2584
  model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
2530
- goal: typeof spec.goal === 'string' ? spec.goal : '',
2531
- source: extra.source,
2585
+ jobText: jobTextFromSpec(spec),
2586
+ inputs: extra.inputs || inputsTextFromSpec(spec, extra.source),
2532
2587
  destination: writeScope.join(', '),
2533
- success: successFromSpec(spec) ?? '',
2534
- guardrails: guardrailsFromSpec(spec) ?? '',
2535
- judgeExamples: extra.judgeExamples,
2536
2588
  capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
2537
2589
  maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
2538
2590
  };
@@ -2542,7 +2594,7 @@ function reuseCardFromSpec(spec, extra) {
2542
2594
  * P5 item 4 — the facts of a spec for an IMPORTED job's view, read by the SAME readers the Job tab uses for a run
2543
2595
  * (so an imported job and a run read alike): check type, goal, success checks, guardrails, model, caps.
2544
2596
  * @param {any} spec
2545
- * @returns {{checkType: string, goal: string, success: string, guardrails: string, model: string, budgetUsd: number|null, maxWallMs: number|null}}
2597
+ * @returns {{checkType: string, goal: string, success: string, guardrails: string, plan: ReturnType<typeof planFromSpec>, inputs: any[]|null, model: string, budgetUsd: number|null, maxWallMs: number|null}}
2546
2598
  */
2547
2599
  function describeSpec(spec) {
2548
2600
  const nr = (/** @type {string|null} */ v) => (typeof v === 'string' && v.length > 0 ? v : 'not recorded');
@@ -2551,6 +2603,8 @@ function describeSpec(spec) {
2551
2603
  goal: nr(typeof spec.goal === 'string' ? spec.goal : null),
2552
2604
  success: nr(successFromSpec(spec)),
2553
2605
  guardrails: nr(guardrailsFromSpec(spec)),
2606
+ plan: planFromSpec(spec),
2607
+ inputs: Array.isArray(spec.inputs) && spec.inputs.length ? spec.inputs : null,
2554
2608
  model: nr(typeof spec.model === 'string' ? spec.model : null),
2555
2609
  budgetUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
2556
2610
  maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : null,
@@ -2587,7 +2641,7 @@ export function getStartFromImport(id, opts = {}) {
2587
2641
  // The card opens on the row matching the bundle's own provider when there is one, else blank (the page's menu picks).
2588
2642
  const rows = rowsForHome(keysHome(opts.home));
2589
2643
  const named = findRow(rows, { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name ?? '';
2590
- const card = reuseCardFromSpec(spec, { model: named, source: '', judgeExamples: '', jobName: row.job });
2644
+ const card = reuseCardFromSpec(spec, { model: named, source: '', jobName: row.job });
2591
2645
  const key = workflowKey(spec);
2592
2646
  const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
2593
2647
  return {
package/src/planrun.js CHANGED
@@ -795,7 +795,7 @@ ${scoutBlob || '(no scout notes)'}`;
795
795
  * item 27/M3 Part B), forwarded to every close invocation as
796
796
  * `BARELOOP_CLOSE_DIR`
797
797
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
798
- * @param {boolean} [opts.scout=true] operator-only switch (docs/product/SCOUT-CONTRAST.md):
798
+ * @param {boolean} [opts.scout=true] operator-only switch (docs/logs/SCOUT-CONTRAST.md):
799
799
  * `false` on a FRESH run (no `resumeSeed`) skips the survey entirely — `scoutBlob`
800
800
  * stays `''`, no `scout-start`/`scout-result`/`scout-empty` is emitted, and a single
801
801
  * `scout-skipped {reason:'operator-off'}` records it (a RECORD, never silence — the
@@ -1001,7 +1001,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1001
1001
  // The scout switch's guard, same class and same door: a mis-typed value
1002
1002
  // coerced by truthiness would run the scout under an "off" label (or skip it
1003
1003
  // under an "on" one) and no reading of the results afterwards could recover
1004
- // which arm actually ran (docs/product/SCOUT-CONTRAST.md).
1004
+ // which arm actually ran (docs/logs/SCOUT-CONTRAST.md).
1005
1005
  if (typeof scout !== 'boolean') throw new TypeError(`scout: expected boolean, got ${JSON.stringify(scout)}`);
1006
1006
  // G1's OTHER half, refused at the same $0 door. `validatePlan` reds `read-blind`
1007
1007
  // when a step grants `read` without the retrieval pair — but a step cannot grant
@@ -2036,7 +2036,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2036
2036
  }
2037
2037
  const listed = await seedListing(workdir, s.seedRef);
2038
2038
  const v = listed.stop === null
2039
- ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope })
2039
+ ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope, jobLines: Array.isArray(job.jobLines) ? job.jobLines : null })
2040
2040
  : { ok: false, grounded: false, reds: [{ code: 'listing-unreadable', path: 'closeDecl', detail: listed.stop }] };
2041
2041
  emit('close-decl', {
2042
2042
  genre: job.closeDecl.genre,
@@ -2939,7 +2939,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2939
2939
  meaning: 'the killed run already paid for this survey; its plan is reloaded, not re-drafted. A replan after this point drafts from an empty survey plus the failure brief.',
2940
2940
  });
2941
2941
  } else if (scout === false) {
2942
- // The operator-off arm (docs/product/SCOUT-CONTRAST.md): a fresh run whose
2942
+ // The operator-off arm (docs/logs/SCOUT-CONTRAST.md): a fresh run whose
2943
2943
  // operator asked for no survey. Precedence already settled above — the
2944
2944
  // resume branch fires first and wins its own reason, so this arm is only
2945
2945
  // ever reached when there is nothing to resume from.
@@ -2,7 +2,7 @@
2
2
  // (hamr, 2026-08-25): "a check". A commit that changes a prompt register must
3
3
  // say, in its message, the failure that caused the change, what it addresses,
4
4
  // and what it corrects — doctrine already stated once ("a frozen rule without
5
- // a wired detector is prose", docs/product/2026-08-23-agreed-build-list.md:187-189)
5
+ // a wired detector is prose", docs/logs/2026-08-23-agreed-build-list.md:187-189)
6
6
  // so convention-only was rejected. This module is the ONE place that inventory
7
7
  // lives: `scripts/prompt-commit-check.mjs` reads it to decide which commits the
8
8
  // rule applies to, and this file's own doc-comment is where a human reads it.
package/src/providers.js CHANGED
@@ -24,7 +24,7 @@
24
24
  import { createRequire } from 'node:module';
25
25
 
26
26
  const require = createRequire(import.meta.url);
27
- const { AnthropicProvider, OpenAIProvider, GeminiProvider } = require('bare-agent/providers');
27
+ const { AnthropicProvider, OpenAIProvider, GeminiProvider, thinkingSupport } = require('bare-agent/providers');
28
28
 
29
29
  /**
30
30
  * Today's anthropic-api tier table — EXACTLY the `DEFAULT_TIER_MODELS` both
@@ -116,30 +116,112 @@ const OPENAI_MODEL_OPTIONS = Object.freeze({
116
116
  });
117
117
 
118
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.
119
+ * THINKING (hamr 2026-10-11: no hardcoded per-model lists in bareloop — bare-agent >=0.51 owns the shapes). The drafter and the
120
+ * rubric judge run thinking OFF by default (deepseek-flash thinks by default and bills the reasoning as output: F192 run mv117wde
121
+ * truncated judge locates at the cap, and a drafter answer that runs to the output cap is a failed try). This module is the ONE
122
+ * owner of what a call sends: it asks bare-agent's `thinkingSupport(kind, model)` (pure lookup, no network) and sends the
123
+ * normalized per-call `thinkingMode: 'off'` ONLY when the model is measured switchable. A model measured NOT switchable
124
+ * (claude-opus-5-5 …) gets nothing — asking would throw before send — and a model bare-agent has not measured ('unverified') also
125
+ * gets nothing on this DEFAULT path, so a local/unknown model's drafting never breaks by default. PER-CALL, never constructor
126
+ * options (the drafter's provider is the worker's instance; the worker is deliberately left as it was). The raw `thinking`
127
+ * option is never sent (bare-agent throws if both are set).
129
128
  */
130
- const OPENAI_JUDGE_CALL_OPTIONS = Object.freeze({
131
- 'deepseek-flash': Object.freeze({ thinking: Object.freeze({ type: 'disabled' }) }),
132
- });
133
129
 
134
130
  /**
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.
131
+ * The thinking-ladder kind bare-agent's `thinkingSupport` is keyed by, from a constructed provider; null for anything that is
132
+ * not one of the three admitted constructors (a test double, a clipipe) — such a provider is never sent the switch.
133
+ * OpenAI-shaped providers are DeepSeek by model id `deepseek-*` or a `deepseek.com` baseUrl (bare-agent's documented rule,
134
+ * provider-openai.js:80), else OpenAI.
135
+ * @param {any} provider
136
+ * @returns {'anthropic'|'openai'|'deepseek'|'gemini'|null}
137
+ */
138
+ function thinkingKind(provider) {
139
+ if (provider instanceof AnthropicProvider) return 'anthropic';
140
+ if (provider instanceof GeminiProvider) return 'gemini';
141
+ if (provider instanceof OpenAIProvider) {
142
+ return /^deepseek-/i.test(String(provider.model)) || /deepseek\.com/i.test(String(provider.baseUrl)) ? 'deepseek' : 'openai';
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * What an 'off' request means for this provider instance, for the draft log and the panel row: `'off'` (measured switchable —
149
+ * the switch is sent), `'not-switchable'` (measured NOT switchable — nothing sent, the model thinks as it would),
150
+ * `'unverified'` (bare-agent has not measured this model — nothing sent on the default path).
151
+ * @param {any} provider a constructed bare-agent provider
152
+ * @returns {'off'|'not-switchable'|'unverified'}
153
+ */
154
+ function offOutcome(provider) {
155
+ const kind = thinkingKind(provider);
156
+ if (kind === null) return 'not-switchable';
157
+ const support = thinkingSupport(kind, provider.model);
158
+ if (support.switchable === true) return 'off';
159
+ return support.switchable === 'unverified' ? 'unverified' : 'not-switchable';
160
+ }
161
+
162
+ /**
163
+ * The per-call options the rubric JUDGE's model calls carry for this provider instance: `{ thinkingMode: 'off' }` when
164
+ * bare-agent measures the model switchable, else `{}` (F192: judge runs thinking off; a judge on a model that cannot turn it
165
+ * off must not throw).
137
166
  * @param {any} provider a constructed bare-agent provider
138
167
  * @returns {Record<string, any>}
139
168
  */
140
169
  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)] ?? {};
170
+ return offOutcome(provider) === 'off' ? { thinkingMode: 'off' } : {};
171
+ }
172
+
173
+ /**
174
+ * The drafter's THINKING knob — an enumerated OPERATOR param, never model output and never read from env inside the library.
175
+ * `'on'` sends nothing (the model's own thinking, byte-identical to a no-knob call); `'off'` sends `thinkingMode: 'off'` where
176
+ * bare-agent measures the model switchable. The DEFAULT is `'off'` (hamr 2026-10-11, decided by the frozen ON/OFF contrast,
177
+ * docs/logs/2026-10-10-drafter-thinking-contrast-prereg.md); `'on'` stays selectable for a later Settings checkbox.
178
+ */
179
+ export const DRAFT_THINKING_MODES = Object.freeze(['on', 'off']);
180
+
181
+ /**
182
+ * Throw unless `mode` is one of {@link DRAFT_THINKING_MODES} — an operator-param guard, so a typo never silently means a mode.
183
+ * @param {unknown} mode
184
+ * @returns {'on'|'off'}
185
+ */
186
+ export function assertDraftThinking(mode) {
187
+ if (mode === 'on' || mode === 'off') return mode;
188
+ throw new Error(`[providers] draftThinking must be one of ${DRAFT_THINKING_MODES.join(' | ')}, got ${JSON.stringify(mode)}`);
189
+ }
190
+
191
+ /**
192
+ * What the drafting calls on this provider ACTUALLY carry for thinking, for the draft log and the panel row: `'on'` (mode on,
193
+ * nothing sent), `'off'` (mode off and the switch is sent), `'not-switchable'` (mode off, model measured not switchable —
194
+ * nothing sent), `'unverified'` (mode off, model not measured by bare-agent — nothing sent). Never re-derives a model list.
195
+ * @param {any} provider a constructed bare-agent provider
196
+ * @param {unknown} [mode] 'on' | 'off' (default 'off')
197
+ * @returns {'on'|'off'|'not-switchable'|'unverified'}
198
+ */
199
+ export function draftThinkingSent(provider, mode = 'off') {
200
+ if (assertDraftThinking(mode) === 'on') return 'on';
201
+ return offOutcome(provider);
202
+ }
203
+
204
+ /**
205
+ * {@link draftThinkingSent} for a model the caller has only NAMED (provider shape, model id, baseUrl) — no key, no network. For
206
+ * the panel's first drafting row, which is shown before the key is read. Constructing the provider only makes the class and
207
+ * the model id available to `thinkingSupport`; it sends nothing.
208
+ * @param {{providerName: string, model: string, baseUrl?: string|null}} o
209
+ * @param {unknown} [mode] 'on' | 'off' (default 'off')
210
+ * @returns {'on'|'off'|'not-switchable'|'unverified'}
211
+ */
212
+ export function draftThinkingFor({ providerName, model, baseUrl }, mode = 'off') {
213
+ return draftThinkingSent(makeProvider(providerName, { apiKey: 'unset', model, ...(baseUrl ? { baseUrl } : {}) }), mode);
214
+ }
215
+
216
+ /**
217
+ * The ONE owner of the per-call options every DRAFTING model call carries (scout, confirm, author, revise, structure retries).
218
+ * `{ thinkingMode: 'off' }` exactly when {@link draftThinkingSent} says `'off'`, else `{}`.
219
+ * @param {any} provider a constructed bare-agent provider
220
+ * @param {unknown} [mode] 'on' | 'off' (default 'off')
221
+ * @returns {Record<string, any>}
222
+ */
223
+ export function draftCallOptions(provider, mode = 'off') {
224
+ return draftThinkingSent(provider, mode) === 'off' ? { thinkingMode: 'off' } : {};
143
225
  }
144
226
 
145
227
  /**
@@ -232,7 +314,7 @@ export function resolveProvider(providerName) {
232
314
  /**
233
315
  * Report whether a resolved API key VALUE (not its presence — every call
234
316
  * site already presence-checks separately) carries a shape an HTTP header
235
- * value cannot: F181 (`docs/logs/FINDINGS.md`) — a two-line secret-store
317
+ * value cannot: F181 (`docs/wiki/FINDINGS.md`) — a two-line secret-store
236
318
  * entry put the key plus a trailing metadata line into `OPENAI_API_KEY`
237
319
  * (`"<key>\nmeta"`), the presence-only check at every door passed, and Node
238
320
  * crashed `TypeError [ERR_INVALID_CHAR]` inside its own header-encode at the
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
 
package/src/transport.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // hamr's ruling, verbatim: "one retry for transport layer failure and
2
- // reporting with the rest end of gate." (F115, docs/logs/FINDINGS.md.)
2
+ // reporting with the rest end of gate." (F115, docs/wiki/FINDINGS.md.)
3
3
  //
4
4
  // A transport failure is fetch THROWING — the request never produced an HTTP
5
5
  // response at all (a TLS fault mid-record, a reset socket, a DNS/network
package/src/userrun.js CHANGED
@@ -111,7 +111,7 @@ const JOBS = {
111
111
  seed: '92d71a7c1253f8f2430e2d308ecfef01c826b5c2',
112
112
  },
113
113
  // Bench row G3 (the NEGATIVE row) — the planted contract contradiction
114
- // (docs/product/G3-SCOPING.md). A copy of the pulselog-u patient (patients are
114
+ // (docs/logs/G3-SCOPING.md). A copy of the pulselog-u patient (patients are
115
115
  // always copies) with one extra commit: two test/** files that pin
116
116
  // assembleEmail's subject to two different strings, unsatisfiable together
117
117
  // inside writeScope: src/**. Not frozen, not signed, no run — BUILT ON BRANCH
@@ -153,7 +153,7 @@ const JOBS = {
153
153
  // Bench row G2 (the NEGATIVE row) — TESTGEN cold start in plan-v1. The legacy
154
154
  // steps[] config went 0-of-43 (F38); this config's rate is UNKNOWN until its
155
155
  // establishing run. Same patient/seed/grader as the l2accept battery
156
- // (TESTGEN-PREREG); the spec is the cold goal. See docs/product/G2-SCOPING.md.
156
+ // (TESTGEN-PREREG); the spec is the cold goal. See docs/logs/G2-SCOPING.md.
157
157
  'aurora-testgen-cold': {
158
158
  spec: 'aurora-testgen-cold.json',
159
159
  workdir: '/home/hamr/PycharmProjects/bareloop-patients/aurora-soar',
@@ -445,7 +445,7 @@ async function execute(ctx) {
445
445
  const SHIM_TAIL = shimArg === null ? '' : ` --read-shim ${shimArg}`;
446
446
 
447
447
  // --scout picks whether the plan flow pays for the read-only survey at all
448
- // (docs/product/SCOUT-CONTRAST.md). Runner territory exactly like --read-shim
448
+ // (docs/logs/SCOUT-CONTRAST.md). Runner territory exactly like --read-shim
449
449
  // and --model: the spec names no scout, so the signed hash is unaffected. An
450
450
  // unrecognised value throws at argv, before the approval gate — the same
451
451
  // param-guard class as --read-shim above, for the same reason: a silently