bareloop 0.38.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 (56) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/bareloop.context.md +87 -24
  3. package/package.json +3 -3
  4. package/src/authorflow.js +335 -59
  5. package/src/authoring.js +125 -3
  6. package/src/authorjob.js +93 -32
  7. package/src/authorreadout.js +498 -3
  8. package/src/authorrun.js +26 -6
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/calibrate.js +85 -11
  12. package/src/cardauthor.js +8 -2
  13. package/src/declaredclose.js +12 -5
  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 +323 -29
  19. package/src/kinds.js +13 -1
  20. package/src/ledger.js +1 -1
  21. package/src/panel/authorroutes.js +17 -11
  22. package/src/panel/authorsession.js +202 -75
  23. package/src/panel/index.html +302 -312
  24. package/src/panel/jobplan.js +107 -0
  25. package/src/panel/server.js +67 -13
  26. package/src/planrun.js +6 -5
  27. package/src/promptregisters.js +1 -1
  28. package/src/providers.js +111 -2
  29. package/src/reuse.js +1 -1
  30. package/src/root.js +1 -1
  31. package/src/run.js +3 -3
  32. package/src/selection.js +1 -1
  33. package/src/source.js +64 -1
  34. package/src/text.js +13 -0
  35. package/src/transport.js +1 -1
  36. package/src/trend.js +1 -0
  37. package/src/userrun.js +3 -3
  38. package/types/authorflow.d.ts +258 -39
  39. package/types/authoring.d.ts +30 -1
  40. package/types/authorjob.d.ts +52 -13
  41. package/types/authorreadout.d.ts +123 -1
  42. package/types/authorscout.d.ts +5 -3
  43. package/types/calibrate.d.ts +4 -1
  44. package/types/declaredclose.d.ts +5 -1
  45. package/types/draftspend.d.ts +8 -2
  46. package/types/job.d.ts +1 -1
  47. package/types/jobblock.d.ts +102 -0
  48. package/types/judged.d.ts +21 -2
  49. package/types/panel/authorsession.d.ts +57 -7
  50. package/types/panel/jobplan.d.ts +58 -0
  51. package/types/panel/server.d.ts +20 -0
  52. package/types/planrun.d.ts +1 -1
  53. package/types/providers.d.ts +52 -1
  54. package/types/run.d.ts +1 -1
  55. package/types/source.d.ts +26 -0
  56. package/types/trend.d.ts +1 -0
@@ -0,0 +1,107 @@
1
+ // The job plan MODEL (P7, docs/product/PANEL-BUILD.md "The job" block): ONE owner of the structure the page's single
2
+ // `renderJobPlan()` draws in two places — the drafting confirm card (the plan bubble) and the read-only Job tab. The
3
+ // server builds it; the page only draws it (so the two places can never read the same plan two ways).
4
+ //
5
+ // { lines: [{n, text, rule, checks: [{name, cls}], refused: [reason...]}],
6
+ // loose: [{name, cls}], checks that name no job line (an older caller, or a model slip)
7
+ // notChecked: [text...], "the AI's own reading" — what the drafter says nothing checks
8
+ // alwaysOn: [text...], the mandatory guards / protections, which serve no job line
9
+ // older: boolean } an older job: the numbered lines were not saved
10
+ //
11
+ // `cls` is 'machine check' | 'judge' | null (null = not known yet: the confirm card's checks are the model's plan, the
12
+ // stage kinds exist only after drafting). Pure; no file IO.
13
+ import { KIND_CATALOGUE } from '../authoring.js';
14
+
15
+ /** @typedef {{name: string, cls: 'machine check'|'judge'|null}} PlanCheck */
16
+ /** @typedef {{n: number, text: string, rule: string, checks: PlanCheck[], refused: string[]}} PlanLine */
17
+ /** @typedef {{lines: PlanLine[], loose: PlanCheck[], notChecked: string[], alwaysOn: string[], older: boolean, goal?: string}} JobPlan */
18
+
19
+ /**
20
+ * `machine check` for a kind a command decides, `judge` for one that needs judgement; `null` for a stage whose kind is
21
+ * unknown (a hand-written command close has no kind: it is a command, so a machine check).
22
+ * @param {unknown} kind
23
+ * @returns {'machine check'|'judge'|null}
24
+ */
25
+ export function checkClassOfKind(kind) {
26
+ if (typeof kind !== 'string') return null;
27
+ const spec = Object.hasOwn(KIND_CATALOGUE, kind) ? KIND_CATALOGUE[kind] : null;
28
+ if (!spec) return null;
29
+ return spec.verdictClass === 'green' ? 'machine check' : 'judge';
30
+ }
31
+
32
+ /**
33
+ * The plan of a SIGNED spec. With `jobLines`, each stage lands under EACH line its `fromLine` array names (a stage with none is
34
+ * a mandatory guard: "Always on"), `closeDecl.refused` reasons land under their line, and `closeDecl.notes` are the
35
+ * drafter's own "not checked" words. Without `jobLines` (a spec that predates the job block) the signed goal sentence is
36
+ * line 1 and every stage name is a check under it — `older: true`.
37
+ * @param {any} spec
38
+ * @returns {JobPlan}
39
+ */
40
+ export function planFromSpec(spec) {
41
+ /** @type {{name: string, cls: 'machine check'|'judge'|null, fromLine: number[]|null}[]} */
42
+ let stages = [];
43
+ if (spec && Array.isArray(spec.closeDecl?.stages)) {
44
+ stages = spec.closeDecl.stages.filter((/** @type {any} */ s) => s && typeof s.name === 'string' && s.name)
45
+ .map((/** @type {any} */ s) => ({ name: s.name, cls: checkClassOfKind(s.kind), fromLine: Array.isArray(s.fromLine) && s.fromLine.length ? s.fromLine.filter(Number.isInteger) : null }));
46
+ } else if (spec && Array.isArray(spec.close)) {
47
+ stages = spec.close.filter((/** @type {any} */ s) => s && typeof s.name === 'string' && s.name)
48
+ .map((/** @type {any} */ s) => ({ name: s.name, cls: /** @type {'machine check'} */ ('machine check'), fromLine: null }));
49
+ }
50
+ const notes = Array.isArray(spec?.closeDecl?.notes) ? spec.closeDecl.notes.filter((/** @type {any} */ n) => typeof n === 'string' && n) : [];
51
+ const jl = Array.isArray(spec?.jobLines) ? spec.jobLines.filter((/** @type {any} */ l) => l && Number.isInteger(l.n) && typeof l.text === 'string') : [];
52
+ if (jl.length === 0) {
53
+ return {
54
+ lines: [{
55
+ n: 1, text: typeof spec?.goal === 'string' && spec.goal ? spec.goal : '(no goal recorded)', rule: '',
56
+ checks: stages.map((s) => ({ name: s.name, cls: s.cls })), refused: [],
57
+ }],
58
+ loose: [], notChecked: notes, alwaysOn: [], older: true,
59
+ };
60
+ }
61
+ /** @type {PlanLine[]} */
62
+ const lines = jl.map((/** @type {any} */ l) => ({ n: l.n, text: l.text, rule: typeof l.rule === 'string' ? l.rule : '', checks: [], refused: [] }));
63
+ /** @type {PlanCheck[]} */
64
+ const loose = [];
65
+ /** @type {string[]} */
66
+ const alwaysOn = [];
67
+ for (const s of stages) {
68
+ if (s.fromLine === null) { alwaysOn.push(s.name); continue; }
69
+ // a check that serves several job lines is shown under EACH line it serves (no new element: the same check line, repeated)
70
+ const served = lines.filter((l) => /** @type {number[]} */ (s.fromLine).includes(l.n));
71
+ for (const line of served) line.checks.push({ name: s.name, cls: s.cls });
72
+ if (served.length === 0) loose.push({ name: s.name, cls: s.cls });
73
+ }
74
+ const refused = Array.isArray(spec?.closeDecl?.refused) ? spec.closeDecl.refused : [];
75
+ for (const r of refused) {
76
+ const line = lines.find((l) => l.n === r?.line);
77
+ if (line && typeof r.reason === 'string') line.refused.push(r.reason);
78
+ }
79
+ // the model's signed goal sentence rides along: the page draws it as the "AI's summary" row (an older job shows it as line 1 instead)
80
+ return { lines, loose, notChecked: notes, alwaysOn, older: false, ...(typeof spec?.goal === 'string' && spec.goal ? { goal: spec.goal } : {}) };
81
+ }
82
+
83
+ /**
84
+ * The plan of the confirm turn, before anything is drafted: the person's own job lines, the model's checks under the
85
+ * line each names, its own "not checked" words, and the real protections (code-owned) as "Always on".
86
+ * @param {{n: number, text: string, rule: string}[]|null|undefined} jobLines
87
+ * @param {{checkItems?: {text: string, fromLine: number[]|null}[], checks?: string[], notChecked?: string[], protections?: string[]}} plan
88
+ * @returns {JobPlan}
89
+ */
90
+ export function planFromConfirm(jobLines, plan) {
91
+ const items = Array.isArray(plan?.checkItems) && plan.checkItems.length
92
+ ? plan.checkItems
93
+ : (Array.isArray(plan?.checks) ? plan.checks.map((c) => ({ text: String(c), fromLine: null })) : []);
94
+ const jl = Array.isArray(jobLines) ? jobLines : [];
95
+ /** @type {PlanLine[]} */
96
+ const lines = jl.map((l) => ({ n: l.n, text: l.text, rule: l.rule, checks: [], refused: [] }));
97
+ /** @type {PlanCheck[]} */
98
+ const loose = [];
99
+ for (const c of items) {
100
+ const served = lines.filter((l) => Array.isArray(c.fromLine) && c.fromLine.includes(l.n));
101
+ for (const line of served) line.checks.push({ name: c.text, cls: null });
102
+ if (served.length === 0) loose.push({ name: c.text, cls: null });
103
+ }
104
+ return {
105
+ lines, loose, notChecked: [...(plan?.notChecked ?? [])], alwaysOn: [...(plan?.protections ?? [])], older: false,
106
+ };
107
+ }
@@ -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
@@ -36,6 +36,7 @@ import { createLadder, STRIKE_LIMIT } from './ladder.js';
36
36
  import { createTrend, FIX_STRIKE_LIMIT } from './trend.js';
37
37
  import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
38
38
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
39
+ import { anySuppressionWarning } from './authoring.js';
39
40
  import { createReadShim, readShimArm, readShimStrategy } from './readshim.js';
40
41
  import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
41
42
  import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
@@ -794,7 +795,7 @@ ${scoutBlob || '(no scout notes)'}`;
794
795
  * item 27/M3 Part B), forwarded to every close invocation as
795
796
  * `BARELOOP_CLOSE_DIR`
796
797
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
797
- * @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):
798
799
  * `false` on a FRESH run (no `resumeSeed`) skips the survey entirely — `scoutBlob`
799
800
  * stays `''`, no `scout-start`/`scout-result`/`scout-empty` is emitted, and a single
800
801
  * `scout-skipped {reason:'operator-off'}` records it (a RECORD, never silence — the
@@ -1000,7 +1001,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1000
1001
  // The scout switch's guard, same class and same door: a mis-typed value
1001
1002
  // coerced by truthiness would run the scout under an "off" label (or skip it
1002
1003
  // under an "on" one) and no reading of the results afterwards could recover
1003
- // which arm actually ran (docs/product/SCOUT-CONTRAST.md).
1004
+ // which arm actually ran (docs/logs/SCOUT-CONTRAST.md).
1004
1005
  if (typeof scout !== 'boolean') throw new TypeError(`scout: expected boolean, got ${JSON.stringify(scout)}`);
1005
1006
  // G1's OTHER half, refused at the same $0 door. `validatePlan` reds `read-blind`
1006
1007
  // when a step grants `read` without the retrieval pair — but a step cannot grant
@@ -2035,7 +2036,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2035
2036
  }
2036
2037
  const listed = await seedListing(workdir, s.seedRef);
2037
2038
  const v = listed.stop === null
2038
- ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType })
2039
+ ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope, jobLines: Array.isArray(job.jobLines) ? job.jobLines : null })
2039
2040
  : { ok: false, grounded: false, reds: [{ code: 'listing-unreadable', path: 'closeDecl', detail: listed.stop }] };
2040
2041
  emit('close-decl', {
2041
2042
  genre: job.closeDecl.genre,
@@ -2546,7 +2547,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2546
2547
  const readLines = grantedNames.has(TOOL_BY_VERB.read)
2547
2548
  ? (native && !shimArm.cap ? NATIVE_READ_STRATEGY : '') + readShimStrategy(shimArm)
2548
2549
  : '';
2549
- const system = PERSONA_TOOLS + strategyFor(granted) + readLines;
2550
+ const system = PERSONA_TOOLS + strategyFor(granted) + readLines + anySuppressionWarning(closeStagesOf(job));
2550
2551
  /** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
2551
2552
  const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
2552
2553
 
@@ -2938,7 +2939,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2938
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.',
2939
2940
  });
2940
2941
  } else if (scout === false) {
2941
- // 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
2942
2943
  // operator asked for no survey. Precedence already settled above — the
2943
2944
  // resume branch fires first and wins its own reason, so this arm is only
2944
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
@@ -115,6 +115,115 @@ const OPENAI_MODEL_OPTIONS = Object.freeze({
115
115
  'deepseek-flash': Object.freeze({ legacyMaxTokens: true }),
116
116
  });
117
117
 
118
+ /**
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).
128
+ */
129
+
130
+ /**
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).
166
+ * @param {any} provider a constructed bare-agent provider
167
+ * @returns {Record<string, any>}
168
+ */
169
+ export function judgeCallOptions(provider) {
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' } : {};
225
+ }
226
+
118
227
  /**
119
228
  * @typedef {object} ProviderTableEntry
120
229
  * @property {new (options: any) => any} ctor the bare-agent provider class
@@ -205,7 +314,7 @@ export function resolveProvider(providerName) {
205
314
  /**
206
315
  * Report whether a resolved API key VALUE (not its presence — every call
207
316
  * site already presence-checks separately) carries a shape an HTTP header
208
- * 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
209
318
  * entry put the key plus a trailing metadata line into `OPENAI_API_KEY`
210
319
  * (`"<key>\nmeta"`), the presence-only check at every door passed, and Node
211
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