bareloop 0.40.0 → 0.42.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 (77) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +141 -128
  4. package/package.json +5 -4
  5. package/src/authorflow.js +346 -38
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +82 -20
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +39 -30
  10. package/src/authorscout.js +112 -4
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/cli.js +1 -1
  14. package/src/config.js +129 -29
  15. package/src/declaredclose.js +23 -12
  16. package/src/draftspend.js +4 -4
  17. package/src/facts.js +317 -0
  18. package/src/factwords.js +129 -0
  19. package/src/index.js +2 -4
  20. package/src/inputlook.js +301 -0
  21. package/src/interviewrun.js +17 -25
  22. package/src/job.js +15 -8
  23. package/src/jobblock.js +7 -2
  24. package/src/judged.js +11 -43
  25. package/src/keysfile.js +15 -7
  26. package/src/kinds.js +86 -5
  27. package/src/labelscan.js +170 -0
  28. package/src/modellist.js +84 -0
  29. package/src/monthly.js +23 -43
  30. package/src/pagefetch.js +125 -0
  31. package/src/panel/authorroutes.js +34 -16
  32. package/src/panel/authorsession.js +161 -80
  33. package/src/panel/index.html +553 -152
  34. package/src/panel/jobplan.js +34 -11
  35. package/src/panel/runroutes.js +3 -0
  36. package/src/panel/server.js +54 -18
  37. package/src/panel/settingsroutes.js +232 -173
  38. package/src/planrun.js +5 -4
  39. package/src/providerrows.js +226 -141
  40. package/src/providers.js +58 -332
  41. package/src/providershapes.js +18 -0
  42. package/src/replay.js +1 -1
  43. package/src/rubricstep.js +804 -0
  44. package/src/source.js +166 -1
  45. package/src/thinking.js +130 -0
  46. package/src/u-readout.js +1 -1
  47. package/src/userrun.js +64 -75
  48. package/types/authorflow.d.ts +272 -18
  49. package/types/authoring.d.ts +29 -1
  50. package/types/authorjob.d.ts +9 -7
  51. package/types/authorreadout.d.ts +27 -2
  52. package/types/authorscout.d.ts +72 -2
  53. package/types/config.d.ts +23 -6
  54. package/types/declaredclose.d.ts +4 -2
  55. package/types/draftspend.d.ts +8 -8
  56. package/types/facts.d.ts +71 -0
  57. package/types/factwords.d.ts +42 -0
  58. package/types/index.d.ts +1 -1
  59. package/types/inputlook.d.ts +238 -0
  60. package/types/job.d.ts +5 -5
  61. package/types/jobblock.d.ts +8 -1
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/labelscan.d.ts +38 -0
  65. package/types/modellist.d.ts +50 -0
  66. package/types/monthly.d.ts +4 -3
  67. package/types/pagefetch.d.ts +71 -0
  68. package/types/panel/authorsession.d.ts +20 -11
  69. package/types/panel/jobplan.d.ts +12 -4
  70. package/types/panel/server.d.ts +18 -6
  71. package/types/providerrows.d.ts +192 -99
  72. package/types/providers.d.ts +34 -159
  73. package/types/providershapes.d.ts +10 -0
  74. package/types/rubricstep.d.ts +286 -0
  75. package/types/source.d.ts +94 -0
  76. package/types/thinking.d.ts +90 -0
  77. package/types/userrun.d.ts +17 -1
@@ -0,0 +1,301 @@
1
+ // THE LOOK (PART 1c, docs/product/ITEM33-BUILD.md "3. The look", PRD item 33 RF19/RF20): before the drafter writes a single check, the AI
2
+ // LOOKS at the person's input - read only, bounded, with tools that can do nothing but search and read the input's own text - and
3
+ // reports, per job line, the facts it can check (name, shape, a label PICKED from the code-made list, one example value it saw) and
4
+ // what it could not find. This module holds the pure parts: the two read-only tools, the strict answer form, the prompt, and the
5
+ // code that turns the AI's report into a verified one. The paid loop around them is `runInputLook` in src/authorscout.js.
6
+ //
7
+ // LABELS ARE PICKED, NEVER TYPED: the AI names a label by its id on the list (src/labelscan.js) or by its exact words; a label that is
8
+ // not on the list is NOT carried into the report - it is moved to "could not check", named. Code never decides two words mean the
9
+ // same: the AI proposes, the list and the source prove.
10
+
11
+ import { scanLabels, labelOnList, renderLabelList, normLabel, LABEL_SHAPES } from './labelscan.js';
12
+ import { FACT_SHAPES, FACT_OPS } from './facts.js';
13
+ import { lineRules } from './rubricstep.js';
14
+
15
+ /** the name of the answer tool - the one place the AI's report enters */
16
+ export const LOOK_REPORT_TOOL = 'report_look';
17
+ /** how many hits one search returns at most, and how long a snippet or a read line is */
18
+ export const LOOK_MAX_HITS = 40;
19
+ const SNIPPET = 220;
20
+ const READ_LINE = 320;
21
+ const READ_LINES = 40;
22
+ const TAG = /\[[A-Za-z]+=[^\]\s]*\]/g;
23
+ const WS = /[\s   ]+/g;
24
+
25
+ /**
26
+ * The input as the AI sees it: lines with any run of white space (U+202F included) made one space and the page reader's bracketed tags
27
+ * (`[ref=549]`) removed - an id that is only the page reader's, never part of a label.
28
+ * @param {string} text @returns {string[]}
29
+ */
30
+ export function lookLines(text) {
31
+ return String(text ?? '').split('\n').map((l) => l.replace(TAG, ' ').replace(WS, ' ').trim());
32
+ }
33
+
34
+ /**
35
+ * The two read-only tools: search the input text, read a slice around a hit. They are closures over in-memory text and touch nothing
36
+ * else - read-only BY CONSTRUCTION, not by a fence. Several inputs: the `input` argument names which (its label).
37
+ * @param {Record<string, string>} texts label -> frozen text
38
+ * @returns {{name: string, description: string, parameters: any, execute: (a: any) => Promise<any>}[]}
39
+ */
40
+ export function lookReadTools(texts) {
41
+ const labels = Object.keys(texts);
42
+ /** @type {Record<string, string[]>} */
43
+ const lines = Object.fromEntries(labels.map((l) => [l, lookLines(texts[l])]));
44
+ const which = (/** @type {any} */ a) => (typeof a?.input === 'string' && lines[a.input] ? a.input : (labels.length === 1 ? labels[0] : null));
45
+ const inputProp = { type: 'string', description: `the input's label - one of: ${labels.join(', ')}` };
46
+ return [
47
+ {
48
+ name: 'search_input',
49
+ description: 'Case-insensitive search of one input for a word or phrase. Returns the line numbers and the start of each line that holds it. Search the person\'s own words AND other words that mean the same (nonstop / direct, San Francisco / SFO).',
50
+ parameters: { type: 'object', properties: { input: inputProp, text: { type: 'string', minLength: 1 }, max: { type: 'integer', minimum: 1, maximum: LOOK_MAX_HITS } }, required: ['text'] },
51
+ execute: async (a) => {
52
+ const name = which(a);
53
+ if (name === null) return { error: `name the input: ${labels.join(', ')}` };
54
+ const q = String(a?.text ?? '').toLowerCase().replace(WS, ' ').trim();
55
+ if (q === '') return { error: 'give the words to search for' };
56
+ const max = Math.min(Number.isInteger(a?.max) ? a.max : 20, LOOK_MAX_HITS);
57
+ /** @type {{line: number, text: string}[]} */
58
+ const hits = [];
59
+ let total = 0;
60
+ lines[name].forEach((l, i) => { if (l.toLowerCase().includes(q)) { total += 1; if (hits.length < max) hits.push({ line: i + 1, text: l.slice(0, SNIPPET) }); } });
61
+ return { input: name, total, hits };
62
+ },
63
+ },
64
+ {
65
+ name: 'read_input',
66
+ description: 'Read some lines of one input, starting at a line number (from a search).',
67
+ parameters: { type: 'object', properties: { input: inputProp, line: { type: 'integer', minimum: 1 }, count: { type: 'integer', minimum: 1, maximum: READ_LINES } }, required: ['line'] },
68
+ execute: async (a) => {
69
+ const name = which(a);
70
+ if (name === null) return { error: `name the input: ${labels.join(', ')}` };
71
+ const from = Math.max(1, Number.isInteger(a?.line) ? a.line : 1);
72
+ const n = Math.min(Number.isInteger(a?.count) ? a.count : 12, READ_LINES);
73
+ return { input: name, lines: lines[name].slice(from - 1, from - 1 + n).map((l, i) => ({ line: from + i, text: l.slice(0, READ_LINE) })) };
74
+ },
75
+ },
76
+ ];
77
+ }
78
+
79
+ /** the strict answer form (a JSON schema - never free prose with JSON inside) */
80
+ export function lookReportSchema() {
81
+ return {
82
+ type: 'object',
83
+ additionalProperties: false,
84
+ required: ['facts', 'prose', 'notFound'],
85
+ properties: {
86
+ facts: {
87
+ type: 'array',
88
+ items: {
89
+ type: 'object',
90
+ additionalProperties: false,
91
+ required: ['line', 'name', 'shape', 'source', 'example'],
92
+ properties: {
93
+ line: { type: 'integer', minimum: 1, description: 'the job line this fact belongs to' },
94
+ name: { type: 'string', minLength: 1, description: 'a short name, letters only' },
95
+ shape: { enum: [...FACT_SHAPES] },
96
+ source: { type: 'string', minLength: 1, description: 'the label of the input it is read from' },
97
+ label: { type: 'string', description: 'the words right before the value: the id (like t1) or the exact words of a label on the LIST for this input and shape - never a word you typed that is not on the list' },
98
+ value: { type: 'string', description: 'a phrase fact only: the fixed words, copied from the input exactly as it writes them (a place, a name, a word)' },
99
+ example: { type: 'string', description: 'ONE value you really saw in the input for this fact' },
100
+ rule: {
101
+ type: 'object',
102
+ additionalProperties: false,
103
+ required: ['op'],
104
+ properties: { op: { enum: [...FACT_OPS] }, value: {}, lo: {}, hi: {} },
105
+ description: 'only where the person asked for a limit (or you made one out of their words and set aiProposal)',
106
+ },
107
+ aiProposal: { type: 'boolean', description: 'true when YOU made this number out of the person\'s words (for example a night window for "not a red-eye")' },
108
+ forRule: { type: 'integer', minimum: 1, description: 'the number of the ~ rule under that line this fact carries out (1 = the first), when it carries one - a rule you turned into a number or range yourself ("not a red-eye") is carried by a fact with aiProposal, never reported as not found' },
109
+ },
110
+ },
111
+ },
112
+ prose: {
113
+ type: 'array',
114
+ description: 'job lines (and their ~ limits) that ask for written text - a summary, a report. They are not looked up.',
115
+ items: { type: 'object', additionalProperties: false, required: ['line'], properties: { line: { type: 'integer', minimum: 1 }, text: { type: 'string' } } },
116
+ },
117
+ notFound: {
118
+ type: 'array',
119
+ description: 'what you could not find in the input, and, when it is one ~ rule, which one',
120
+ items: {
121
+ type: 'object',
122
+ additionalProperties: false,
123
+ required: ['line', 'why'],
124
+ properties: { line: { type: 'integer', minimum: 1 }, rule: { type: 'integer', minimum: 1, description: 'the number of the ~ rule under that line (1 = the first), when the thing not found is one rule' }, words: { type: 'string', description: 'the person\'s own words that you could not find' }, why: { type: 'string', minLength: 1 } },
125
+ },
126
+ },
127
+ },
128
+ };
129
+ }
130
+
131
+ /**
132
+ * What the AI is told: the numbered job, the inputs and their label lists, and what to do.
133
+ * @param {{jobLines: {n: number, text: string, rule?: string, rules?: string[]}[], labels: string[], lists: Record<string, string>}} o
134
+ * @returns {string}
135
+ */
136
+ export function lookPrompt({ jobLines, labels, lists }) {
137
+ const job = jobLines.map((l) => `${l.n}. ${l.text}${(l.rules ?? (l.rule ? l.rule.split('; ') : [])).map((r, i) => `\n ~ ${i + 1}. ${r}`).join('')}`).join('\n');
138
+ return `The person's job (line numbers start at 1; "~" lines are the person's rules for the line above, numbered under it):\n${job}\n\n`
139
+ + `The inputs you can look at: ${labels.map((l) => JSON.stringify(l)).join(', ')}. You can only use search_input and read_input, then you MUST finish by calling ${LOOK_REPORT_TOOL} once.\n\n`
140
+ + `${labels.map((l) => lists[l]).join('\n\n')}\n\n`
141
+ + `Shapes a value can have: ${FACT_SHAPES.join(', ')} (phrase = fixed words the quote must contain; text = the words after a label).\n`
142
+ + 'For each job line, find the facts the person asked for and report ONLY what you can check in the input:\n'
143
+ + ' - Search the input with the person\'s words AND other words that mean the same, read the spots you find, and use what you know (for example what a red-eye is: propose a night time window and set aiProposal true).\n'
144
+ + ' - A ~ rule whose meaning you can turn into a number or a range from what you know (a red-eye is a night departure; a weekend; business hours) is NOT something to ask the person about and NOT "notFound": report it as a fact on the time, date or amount the input does hold, with a between (or other) rule, aiProposal true and forRule set to that rule\'s number. Use notFound with a rule number only for intent you cannot know (for example "one per week" when the input has no weekly grouping).\n'
145
+ + ' - Every place, date or name written in the job line itself is a fact too. Bind it to the label that sits right before it in the input - a direction like "from X to Y" is the label before the origin (for example Leaves) and the label before the destination (for example arrives at): report each as a phrase fact whose label is that label and whose value is the place EXACTLY as the input writes it.\n'
146
+ + ' - A label is PICKED from the LIST above (its id or its exact words) - never typed. A label that is not on the list is refused. Never use the person\'s own words as a label unless the list holds them, never a one-time id like [ref=...], and never one result\'s own value.\n'
147
+ + ' - Lines that ask for written prose (a summary, a short text) and the ~ limits on them (word count, paragraphs) are NOT looked up: put them in "prose" with their line number.\n'
148
+ + ' - Whatever you cannot find in the input goes to "notFound" with the person\'s own words; when it is one ~ rule, give that rule\'s number.\n'
149
+ + `Report only through ${LOOK_REPORT_TOOL}.`;
150
+ }
151
+
152
+ /** @typedef {{line: number, name: string, shape: string, source: string, label?: string, labelId?: string, value?: string, phrase?: string, example: string, rule?: any, aiProposal?: boolean, forRule?: number}} LookFact */
153
+ /** @typedef {{line: number, rule?: number, words?: string, why: string}} LookNotFound */
154
+ /** @typedef {{facts: LookFact[], prose: {line: number, text?: string}[], notFound: LookNotFound[]}} LookReport */
155
+
156
+ /** @param {unknown} v @returns {v is Record<string, any>} */
157
+ const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
158
+
159
+ /**
160
+ * Turn the AI's raw report into a VERIFIED one, by code, at $0. A fact is carried only when: its shape is a real shape, its source is
161
+ * a real input, and its label is ON THE LIST for that input and shape (an id is resolved to its exact words). Anything else is moved
162
+ * to `notFound` with a plain reason naming the word - never dropped silently. `prove` is the "found in the source" test of the
163
+ * phrase a phrase fact builds (label + value), supplied by the caller.
164
+ * @param {unknown} raw
165
+ * @param {{scans: Record<string, import('./labelscan.js').LabelScan>, jobLines: {n: number}[], prove?: (source: string, phrase: string) => number}} o
166
+ * @returns {LookReport}
167
+ */
168
+ export function verifyLookReport(raw, { scans, jobLines, prove }) {
169
+ const rep = isRec(raw) ? raw : {};
170
+ const lineNos = new Set(jobLines.map((l) => l.n));
171
+ /** @type {LookFact[]} */
172
+ const facts = [];
173
+ /** @type {LookNotFound[]} */
174
+ const notFound = [];
175
+ const dropped = (/** @type {any} */ f, /** @type {string} */ why) => notFound.push({
176
+ line: Number.isInteger(f?.line) && lineNos.has(f.line) ? f.line : (jobLines[0]?.n ?? 1),
177
+ words: typeof f?.name === 'string' ? f.name : undefined,
178
+ why,
179
+ });
180
+ for (const f of Array.isArray(rep.facts) ? rep.facts : []) {
181
+ if (!isRec(f) || !Number.isInteger(f.line) || !lineNos.has(f.line)) { dropped(f, 'the look named a job line that does not exist'); continue; }
182
+ const name = typeof f.name === 'string' ? f.name.replace(/[^A-Za-z _-]/g, '').trim() : '';
183
+ if (name === '') { dropped(f, 'the look gave a fact no usable name'); continue; }
184
+ if (!FACT_SHAPES.includes(f.shape)) { dropped({ ...f, name }, `the look gave "${name}" a shape (${String(f.shape)}) that does not exist`); continue; }
185
+ const scan = typeof f.source === 'string' ? scans[f.source] : undefined;
186
+ if (!scan) { dropped({ ...f, name }, `the look read "${name}" from an input (${String(f.source)}) that is not one of yours`); continue; }
187
+ /** @type {LookFact} */
188
+ const out = { line: f.line, name, shape: f.shape, source: f.source, example: typeof f.example === 'string' ? f.example : '' };
189
+ if (typeof f.label === 'string' && f.label.trim() !== '') {
190
+ const row = labelOnList(scan, f.shape === 'phrase' ? 'text' : f.shape, f.label);
191
+ if (!row) { dropped({ ...f, name }, `the label "${normLabel(f.label)}" for "${name}" is not one of the labels found in "${f.source}", so it was not used`); continue; }
192
+ out.label = row.label;
193
+ out.labelId = row.id;
194
+ }
195
+ if (f.shape === 'phrase') {
196
+ const value = typeof f.value === 'string' ? normLabel(f.value) : '';
197
+ if (value === '') { dropped({ ...f, name }, `the look gave the phrase fact "${name}" no words`); continue; }
198
+ out.value = value;
199
+ out.phrase = out.label ? `${out.label} ${value}` : value;
200
+ if (prove && prove(f.source, out.phrase) < 1) { dropped({ ...f, name }, `"${out.phrase}" is not in the input "${f.source}"`); continue; }
201
+ }
202
+ if (isRec(f.rule)) out.rule = f.rule;
203
+ if (f.aiProposal === true) out.aiProposal = true;
204
+ if (Number.isInteger(f.forRule) && f.forRule >= 1) out.forRule = f.forRule;
205
+ facts.push(out);
206
+ }
207
+ for (const n of Array.isArray(rep.notFound) ? rep.notFound : []) {
208
+ if (!isRec(n) || !Number.isInteger(n.line) || !lineNos.has(n.line) || typeof n.why !== 'string') continue;
209
+ // a rule that a verified fact carries is checked, so it is not also "not found" (L3: a ~ rule is one or the other)
210
+ if (Number.isInteger(n.rule) && facts.some((x) => x.line === n.line && x.forRule === n.rule)) continue;
211
+ notFound.push({ line: n.line, ...(Number.isInteger(n.rule) ? { rule: n.rule } : {}), ...(typeof n.words === 'string' && n.words ? { words: n.words } : {}), why: n.why });
212
+ }
213
+ const prose = (Array.isArray(rep.prose) ? rep.prose : []).filter((p) => isRec(p) && Number.isInteger(p.line) && lineNos.has(p.line))
214
+ .map((p) => ({ line: p.line, ...(typeof p.text === 'string' ? { text: p.text.slice(0, 400) } : {}) }));
215
+ return { facts, prose, notFound };
216
+ }
217
+
218
+ /**
219
+ * The look's report as the DRAFTER reads it: one block, per job line, of what can be checked (with the exact words to copy) and what
220
+ * could not be found. Written by code from the verified report.
221
+ * @param {LookReport} report @returns {string}
222
+ */
223
+ export function renderLookReport(report) {
224
+ const byLine = new Map();
225
+ const add = (/** @type {number} */ n, /** @type {string} */ s) => byLine.set(n, [...(byLine.get(n) ?? []), s]);
226
+ for (const f of report.facts) {
227
+ const bits = [`${f.name} - ${f.shape}`, `from "${f.source}"`];
228
+ if (f.phrase !== undefined) bits.push(`the quote must say ${JSON.stringify(f.phrase)}`);
229
+ else if (f.label !== undefined) bits.push(`label ${JSON.stringify(f.label)} (id ${f.labelId})`);
230
+ else bits.push('no label (the value as the quote writes it)');
231
+ if (f.example) bits.push(`seen: ${JSON.stringify(f.example)}`);
232
+ if (f.rule) bits.push(`rule ${JSON.stringify(f.rule)}`);
233
+ if (f.aiProposal) bits.push("AI's proposal");
234
+ if (f.forRule !== undefined) bits.push(`carries ~ rule ${f.forRule}`);
235
+ add(f.line, `CAN CHECK: ${bits.join(', ')}`);
236
+ }
237
+ for (const p of report.prose) add(p.line, 'WRITTEN TEXT (not looked up; keep its word/section counts)');
238
+ for (const n of report.notFound) add(n.line, `NOT FOUND${n.rule ? ` (rule ${n.rule})` : ''}: ${n.words ? `${JSON.stringify(n.words)} - ` : ''}${n.why}`);
239
+ const lines = [...byLine.keys()].sort((a, b) => a - b).map((n) => `line ${n}:\n${byLine.get(n).map((/** @type {string} */ s) => ` ${s}`).join('\n')}`);
240
+ return `WHAT THE LOOK AT THE INPUTS FOUND - write the facts from this; copy the label and phrase words exactly\n\n${lines.join('\n') || '(the look reported nothing)'}`;
241
+ }
242
+
243
+ /**
244
+ * The label scans and the rendered lists for the inputs the look will read.
245
+ * @param {Record<string, string>} texts
246
+ * @returns {{scans: Record<string, import('./labelscan.js').LabelScan>, lists: Record<string, string>}}
247
+ */
248
+ export function labelListsOf(texts) {
249
+ /** @type {Record<string, import('./labelscan.js').LabelScan>} */
250
+ const scans = {};
251
+ /** @type {Record<string, string>} */
252
+ const lists = {};
253
+ for (const [label, text] of Object.entries(texts)) { scans[label] = scanLabels(text); lists[label] = renderLabelList(label, scans[label]); }
254
+ return { scans, lists };
255
+ }
256
+
257
+ export { LABEL_SHAPES };
258
+
259
+ /**
260
+ * L3: a reason the AI gave, cut only at a sentence end (never mid-sentence): whole sentences while they fit in `max` characters,
261
+ * and nothing at all if the first one alone does not fit. A trailing full stop is dropped (the caller closes the clause).
262
+ * @param {string} text @param {number} max @returns {string}
263
+ */
264
+ export function wholeSentences(text, max) {
265
+ const sentences = text.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s+/).filter(Boolean);
266
+ let out = '';
267
+ for (const s of sentences) {
268
+ const next = out === '' ? s : `${out} ${s}`;
269
+ if (next.length > max) break;
270
+ out = next;
271
+ }
272
+ return out.replace(/[.!?]+$/, '');
273
+ }
274
+
275
+ /**
276
+ * I1 (hamr, 2026-10-13, "Ruled by hamr" 2): when the look shows the person's intent on one `~` rule was NOT captured - its words or
277
+ * meaning are not in the source - the person is asked about THAT rule at once, named by its line and rule. Only a look entry that names
278
+ * a rule number counts; anything else the look could not find is not asked piecemeal (it is Not checked, or the whole job is reviewed).
279
+ * The wording is the code's own, never the model's. The keep-or-drop and the cap are `normalizeQuestions` (src/authorflow.js).
280
+ * @param {LookNotFound[]} notFound @param {{n: number, text?: string, rule?: string, rules?: string[]}[]} jobLines
281
+ * @returns {{line: number, question: string}[]} every candidate question (the caller applies the machine's caps)
282
+ */
283
+ export function ruleQuestions(notFound, jobLines) {
284
+ /** @type {{line: number, question: string}[]} */
285
+ const out = [];
286
+ /** @type {Set<string>} */
287
+ const seen = new Set();
288
+ for (const n of Array.isArray(notFound) ? notFound : []) {
289
+ if (!Number.isInteger(n.rule)) continue;
290
+ const line = jobLines.find((l) => l.n === n.line);
291
+ const rule = line ? lineRules(line)[/** @type {number} */ (n.rule) - 1] : undefined;
292
+ if (!line || rule === undefined) continue;
293
+ const key = `${n.line}:${n.rule}`;
294
+ if (seen.has(key)) continue;
295
+ seen.add(key);
296
+ const words = n.words ? `${JSON.stringify(n.words)} (from your words)` : 'what this rule asks for';
297
+ const why = wholeSentences(String(n.why), 200);
298
+ out.push({ line: n.line, question: `Line ${n.line}, rule ${n.rule} - "${rule}": I could not find ${words} in your input${why ? ` (${why})` : ''}. What do you mean by it? Your answer is added as a new ~ rule on line ${n.line}.` });
299
+ }
300
+ return out;
301
+ }
@@ -87,7 +87,7 @@
87
87
  // non-repo source gets the form up to here and then an honest named stop:
88
88
  // bareloop has no checks for that kind of job yet (ruling 7 → M4).
89
89
  import { keysForDoor } from './keysfile.js';
90
- import { applyConfiguredKey, rowsForHome } from './providerrows.js';
90
+ import { keyValueOf, namedRowsForHome, resolveWorker } from './providerrows.js';
91
91
  import {
92
92
  writeFileSync, mkdirSync, readFileSync, statSync,
93
93
  } from 'node:fs';
@@ -192,6 +192,9 @@ export async function main(argv, deps = {}) {
192
192
  // way an empty `--verdict`/`--provider` value already does elsewhere in this
193
193
  // file.
194
194
  const baseUrlArg = arg('base-url');
195
+ // `--model <id>`: the model id of a line ticked in Settings > Providers (with both prices). Written into the draft as `model`, so the
196
+ // job carries its own model; absent when never given (run-author then stops at $0 asking for one).
197
+ const modelArg = arg('model');
195
198
  // `--lang` IS GONE (PRD item 33 M3, ruling 3): language is a FACT of the
196
199
  // repository, read off its own manifest, never a flag a person sets. A
197
200
  // `--lang` on the command line now is an operator error, stopped loud rather
@@ -686,6 +689,7 @@ export async function main(argv, deps = {}) {
686
689
  // written as `null`/`''`; every provider constructor defaults its own
687
690
  // endpoint on its own in that case (PRD item 28 ruling (d)).
688
691
  ...(baseUrlArg === null ? {} : { baseUrl: baseUrlArg }),
692
+ ...(modelArg === null ? {} : { model: modelArg }),
689
693
  cadence: { unit: 'day', every: 1 },
690
694
  budgetUsd,
691
695
  ...(wallMin === null ? {} : { maxWallMs: Math.round(wallMin * 60_000) }),
@@ -782,29 +786,17 @@ export async function main(argv, deps = {}) {
782
786
  // anything this script itself let through; the try/catch is only for the
783
787
  // off-menu case `validateJob` reds but does not stop the write for (an
784
788
  // admitted-but-uncrafted table entry would otherwise crash this print).
785
- let providerEntry = null;
786
- try { providerEntry = resolveProvider(PROVIDER); } catch { providerEntry = null; }
787
- const providerEnvKey = providerEntry?.envKey ?? null;
788
- say(` ${providerEnvKey ?? 'YOUR_PROVIDER_API_KEY'}=... ${commandFor('author', deps.invokedAs)} ${childArgs.join(' ')}`);
789
- // An `openai-api` + `baseUrl` draft (DeepSeek, today's one secondary, reached
790
- // this way) still reads its key from `OPENAI_API_KEY` — there is no separate
791
- // "DeepSeek key" env var, and a person pointed only at the command above could
792
- // reasonably miss that. Said once, plainly, never a key VALUE.
793
- if (draft.baseUrl !== undefined && providerEnvKey) {
794
- say(` (the endpoint above is reached through the "${PROVIDER}" table entry, so its key still goes in ${providerEnvKey} — there is no separate endpoint-specific key variable)`);
795
- }
796
- // NO KEY, NO OFFER. `run-author.mjs` refuses without one and exits 2 at its own
797
- // door, before a spine exists — so with the shell unkeyed this question has
798
- // exactly one possible outcome for the person, and putting it anyway spends
799
- // their attention on a choice they do not have. What is actionable instead is
800
- // the command above and the one line that says how to make it work.
801
- // F181 — a key that IS set can still carry a shape run-author will refuse
802
- // (a line break/control char/stray whitespace, e.g. a two-line secret-store
803
- // entry). A malformed key must not count as KEYED: this offer must read the
804
- // same "will it actually run" question run-author itself asks at its door.
805
- // P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
806
- const keyEnv = keys.home !== undefined ? applyConfiguredKey(env, PROVIDER, draft.baseUrl, rowsForHome(keys.home)) : env;
807
- const rawKeyValue = providerEnvKey ? keyEnv[providerEnvKey] : undefined;
789
+ // The key is the Settings row's own variable (P8: no built-in name). The worker is the draft's API shape + Base URL + model line.
790
+ const keyRowsNow = keys.home !== undefined ? namedRowsForHome(keys.home) : [];
791
+ const worker = resolveWorker(keyRowsNow, { provider: PROVIDER, baseUrl: draft.baseUrl, model: modelArg });
792
+ const providerEnvKey = worker.ok ? worker.row.envName : null;
793
+ say(` ${providerEnvKey ?? 'YOUR_KEY_NAME'}=... ${commandFor('author', deps.invokedAs)} ${childArgs.join(' ')}`);
794
+ if (!worker.ok) say(` (${worker.say.replace(' Nothing was spent.', '')} — bareloop author stops at $0 until it is)`);
795
+ // NO KEY, NO OFFER. `run-author.mjs` refuses without one and exits 2 at its own door, before a spine exists — so with the shell
796
+ // unkeyed this question has exactly one possible outcome for the person, and putting it anyway spends their attention on a choice
797
+ // they do not have. F181 — a key that IS set can still carry a shape run-author will refuse (a line break/control char/stray
798
+ // whitespace). A malformed key must not count as KEYED: this offer reads the same "will it actually run" question run-author asks.
799
+ const rawKeyValue = providerEnvKey ? keyValueOf(env, providerEnvKey) : undefined;
808
800
  const keyProblem = rawKeyValue ? apiKeyProblem(rawKeyValue) : null;
809
801
  const KEYED = providerEnvKey !== null && Boolean(rawKeyValue) && !keyProblem;
810
802
  if (!KEYED) {
@@ -814,7 +806,7 @@ export async function main(argv, deps = {}) {
814
806
  } else {
815
807
  say(providerEnvKey
816
808
  ? ` (${providerEnvKey} is not set in this shell — bareloop author refuses without it; secrets load from the environment, never the tree)`
817
- : ` (provider "${PROVIDER}" is not in the runnable table — bareloop author will refuse it loud; this is not a key problem)`);
809
+ : ' (no key row serves this model yet — bareloop author refuses it at $0; this is not a key problem)');
818
810
  }
819
811
  // The repair, in plain words and WITHOUT a command: which secret store a person
820
812
  // keeps their key in is theirs, and printing one specific incantation would be
package/src/job.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { createHash } from 'node:crypto';
17
17
  import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier, closeScriptCandidateToken } from './validate.js';
18
18
  import { validateCloseDecl, DECLARED_CLOSE_CLASSES } from './declaredclose.js';
19
+ import { inputLabelsOf } from './rubricstep.js';
19
20
  import { CLOSE_TIMEOUT_FLOOR_MS } from './closetimeout.js';
20
21
 
21
22
  // The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
@@ -167,7 +168,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
167
168
  // `steps` stays in the field list ONLY so a retired spec reds by name
168
169
  // (`shape-retired`) instead of falling through to a generic unknown-field —
169
170
  // the operator gets told what happened, not just that something is wrong.
170
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
171
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'thinking', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
171
172
  /** the exact fields of a signed `judge` override — nested objects red unknown
172
173
  * keys too, because no smuggling level exists in a signed spec */
173
174
  const JUDGE_FIELDS = ['provider', 'model'];
@@ -329,7 +330,12 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
329
330
  // on THAT model, not merely on the goal/close it runs against. The judge
330
331
  // model stays library-pinned pending recalibration — out of scope here.
331
332
  if (spec.model !== undefined && !isNonEmptyString(spec.model)) {
332
- red('invalid-value', 'model', 'non-empty string — exact provider model id (e.g. "claude-sonnet-5")');
333
+ red('invalid-value', 'model', 'non-empty string — the exact model id of a line ticked in Settings > Providers');
334
+ }
335
+ // THINKING (P8) — the job's "Thinking on" tick, OPTIONAL: absent means off (and an old spec keeps the hash it always had); present it is
336
+ // signed like `model`. It covers DRAFTING and the rubric JUDGE only — the worker is never handed a thinking option. A boolean, nothing else.
337
+ if (spec.thinking !== undefined && typeof spec.thinking !== 'boolean') {
338
+ red('invalid-value', 'thinking', 'true or false — the job\'s "Thinking on" tick (absent = off)');
333
339
  }
334
340
 
335
341
  // JUDGE — PRD item 32.1 (hamr, 2026-09-10: the judge "defaults to whatever
@@ -790,7 +796,7 @@ function validatePlanShape(spec, red, reds) {
790
796
  // class the user picked (PRD v1.57 §2), so the gate cannot check D5 without
791
797
  // it. An unknown or locked class is refused there, on its own axis, beside
792
798
  // the counted `request-red` above.
793
- const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null });
799
+ const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null, inputLabels: inputLabelsOf(spec.inputs) });
794
800
  for (const r of cd.reds) reds.push(r);
795
801
  if (demanded !== undefined && !CLASS_BY_CLOSE.declared.includes(demanded)) {
796
802
  // the same laundering guard as the close-type hierarchy, one level up: a
@@ -984,15 +990,15 @@ export function checkApproval(job, approvals) {
984
990
  * mismatched flag is a mistake worth stopping on, never a silent swap); the
985
991
  * same id from both is not a conflict. Extracted from scripts/run-u.mjs so
986
992
  * the decision is testable without running the script or touching a provider.
987
- * @param {{ specModel?: string, flagModel?: string, defaultModel: string }} args
993
+ * @param {{ specModel?: string, flagModel?: string, suppliedModel?: string }} args
988
994
  * `specModel` — `job.model` if the signed spec carries one, else undefined.
989
995
  * `flagModel` — the model id an explicit `--model <tier>` resolved to, else
990
996
  * undefined (a flag that was never passed, not merely a default tier name).
991
- * `defaultModel` — the runner's own default when neither spec nor flag says.
997
+ * `suppliedModel` — a model the CALLER supplies when neither spec nor flag says (bareloop keeps none of its own).
992
998
  * @returns {{ model: string, source: 'spec'|'flag'|'default' }}
993
- * @throws {Error} when the spec names a model and the flag names a different one
999
+ * @throws {Error} when the spec names a model and the flag names a different one, or when no model is named at all
994
1000
  */
995
- export function resolveWorkerModel({ specModel, flagModel, defaultModel }) {
1001
+ export function resolveWorkerModel({ specModel, flagModel, suppliedModel }) {
996
1002
  if (specModel !== undefined) {
997
1003
  if (flagModel !== undefined && flagModel !== specModel) {
998
1004
  throw new Error(`spec names model "${specModel}" but --model resolved to "${flagModel}" — the signed spec wins; a mismatched --model flag is refused, never silently overridden. Drop --model or point it at the same model.`);
@@ -1000,5 +1006,6 @@ export function resolveWorkerModel({ specModel, flagModel, defaultModel }) {
1000
1006
  return { model: specModel, source: 'spec' };
1001
1007
  }
1002
1008
  if (flagModel !== undefined) return { model: flagModel, source: 'flag' };
1003
- return { model: defaultModel, source: 'default' };
1009
+ if (suppliedModel === undefined) throw new Error('no model is named — a job carries its own model, or the run is given --model <id> (one ticked in Settings > Providers with both prices)');
1010
+ return { model: suppliedModel, source: 'default' };
1004
1011
  }
package/src/jobblock.js CHANGED
@@ -113,16 +113,21 @@ export function renderInputs(inputs) {
113
113
  * The two cross-checks between a parsed job block and the Check type: a RUBRIC job needs at least one `PASS:` and one
114
114
  * `FAIL:` example (the calibration compile reads them); a DETERMINISTIC job has no judge, so an example line would be
115
115
  * silently dropped — a red instead.
116
+ * PART 1a (hamr's ruling P1=B, docs/product/ITEM33-BUILD.md "Addendum 2026-10-12"): the examples are required of a rubric
117
+ * job ONLY when it holds a judged part. A rubric job on the person's own local files holds none in this build (its
118
+ * parts are counted or checked against a quote), so the caller says `judged: false` and a block without examples fits.
119
+ * Examples written anyway are never a red there — they simply have no judge to read them yet.
116
120
  * @param {JobLine[]} lines
117
121
  * @param {boolean} rubric
122
+ * @param {{judged?: boolean}} [o] `judged` (default true): does this job hold a judged part, so that it needs examples?
118
123
  * @returns {string|null} the plain red, or null when the block fits the Check type
119
124
  */
120
- export function jobBlockFitsCheck(lines, rubric) {
125
+ export function jobBlockFitsCheck(lines, rubric, { judged = true } = {}) {
121
126
  const all = lines.flatMap((l) => l.examples.map((e) => ({ n: l.n, ...e })));
122
127
  if (!rubric) {
123
128
  return all.length ? `The job, line ${all[0].n}: PASS:/FAIL: examples are for a Rubric check — a Deterministic job has no judge to read them` : null;
124
129
  }
125
- if (!all.some((e) => e.verdict === 'PASS') || !all.some((e) => e.verdict === 'FAIL')) {
130
+ if (judged && (!all.some((e) => e.verdict === 'PASS') || !all.some((e) => e.verdict === 'FAIL'))) {
126
131
  return 'The job: a Rubric check needs at least one "~ PASS:" line and one "~ FAIL:" line under a job line';
127
132
  }
128
133
  return null;
package/src/judged.js CHANGED
@@ -66,43 +66,11 @@
66
66
  import { quoteIn } from 'bareguard';
67
67
  import { createRequire } from 'node:module';
68
68
  import { extractArtifact, priceOf, scrubRaw, rateSourceFields } from './text.js';
69
- import { resolveProvider, judgeCallOptions } from './providers.js';
69
+ import { resolveProvider } from './providers.js';
70
+ import { thinkingCallOptions } from './thinking.js';
70
71
 
71
72
  const require = createRequire(import.meta.url);
72
73
 
73
- /**
74
- * THE JUDGE'S HISTORICAL PIN. Until PRD item 32 (2026-09-10) this constant WAS
75
- * the judge: every grading path imported it, so bareloop could run a worker on
76
- * any provider and still judge only on Claude.
77
- *
78
- * It is no longer read by any grading path. The judge identity is now RESOLVED
79
- * per job (`resolveJudge` below) and travels as data — `ctx.judgeModel` into
80
- * `runJudgedFloor`, an explicit argument into the calibration gate, and
81
- * `closeDecl.calibration.judgeModel` into the signed spec, exactly as before.
82
- * Keeping the export is deliberate: it is part of the public API
83
- * (`src/index.js`) and an adopter importing it should not break on a minor.
84
- * What it means now is "the judge bareloop pinned before item 32", which is
85
- * still the right DEFAULT for an anthropic-api job and still the only tier with
86
- * established injection resistance upstream (BA-20's `INJECTION_BATTERY` is
87
- * haiku-4.5 only).
88
- *
89
- * THE ENFORCEMENT IS UNCHANGED, and still lives in three wired places because a
90
- * rule with no detector is prose (F45) — only the value being compared moved
91
- * from this constant to the resolved identity:
92
- * 1. the identity is STORED with the set it graded — `foldJudgedArtifacts`
93
- * writes it as `closeDecl.calibration.judgeModel`, inside the spec, so
94
- * `jobSpecHash` covers it by construction. Change the judge and every
95
- * signed judged spec hashes differently: the signature dies, and re-signing
96
- * is what re-runs the calibration gate.
97
- * 2. `validateCloseDecl` REQUIRES it on any stored set — a set nobody can
98
- * attribute to a judge is a floor nobody can tell apart from a moved one.
99
- * 3. `runJudgedFloor` refuses to grade when the stored identity is not the one
100
- * about to grade, naming both. It does not degrade in either direction:
101
- * grading on the new judge judges against a floor nobody calibrated, and
102
- * the old judge is not on offer.
103
- */
104
- export const JUDGE_MODEL = 'claude-haiku-4-5';
105
-
106
74
  /**
107
75
  * RESOLVE WHICH MODEL GRADES THIS JOB (PRD item 32.1).
108
76
  *
@@ -122,7 +90,7 @@ export const JUDGE_MODEL = 'claude-haiku-4-5';
122
90
  * item 31.4 a green job runs on its own key alone, and this is what lets a
123
91
  * JUDGED one do the same.
124
92
  *
125
- * There is deliberately NO third fallback to `JUDGE_MODEL`. A caller that cannot
93
+ * There is deliberately NO third fallback to a library-pinned model. A caller that cannot
126
94
  * say which model will grade has not wired a judge, and the grading path stops
127
95
  * as a wiring gap rather than grading under an identity nobody chose — the same
128
96
  * answer an absent `judgeLoop` already gets. A silent default here would be the
@@ -198,15 +166,13 @@ export function resolveJudge({ specJudge, workerProvider, workerModel } = {}) {
198
166
  *
199
167
  * @param {{provider?: string, model?: string, judge?: any}} source draft or spec
200
168
  * @param {string} fallbackProviderName used only when `source.provider` is absent
201
- * @param {(o: {specModel?: string, flagModel?: string, defaultModel: string}) => {model: string, source: string}} resolveWorkerModelFn `resolveWorkerModel` from `./job.js`
169
+ * @param {(o: {specModel?: string, flagModel?: string, suppliedModel?: string}) => {model: string, source: string}} resolveWorkerModelFn `resolveWorkerModel` from `./job.js`
202
170
  * @returns {{provider: string, model: string}}
203
171
  */
204
172
  export function resolveJobJudge(source, fallbackProviderName, resolveWorkerModelFn) {
205
173
  const providerName = source?.provider ?? fallbackProviderName;
206
- const providerEntry = resolveProvider(providerName);
207
- const { model } = resolveWorkerModelFn({
208
- specModel: source?.model, flagModel: undefined, defaultModel: providerEntry.tiers.sonnet,
209
- });
174
+ resolveProvider(providerName); // an unknown shape is the named throw, never a silent default
175
+ const { model } = resolveWorkerModelFn({ specModel: source?.model, flagModel: undefined });
210
176
  return resolveJudge({ specJudge: source?.judge, workerProvider: providerName, workerModel: model });
211
177
  }
212
178
 
@@ -1089,9 +1055,11 @@ function topLevelFunctionNames(artifactText) {
1089
1055
  * so the priced seam is the only legal one.
1090
1056
  * `rates` is the customer's own price for the judge's row (USD per 1K, `ratesFor`'s
1091
1057
  * `rates`); null/absent = bare-agent's built-in guess, exactly as before.
1092
- * @param {{provider: any, system: string, rates?: {in: number, out: number}|null}} o
1058
+ * `thinking` is the job's SIGNED "Thinking on" tick (false/absent = off): the judge follows the signed value — so the calibration gate
1059
+ * (10/10) and the later grading run with the SAME tick — and bare-agent decides what each model can switch (src/thinking.js).
1060
+ * @param {{provider: any, system: string, rates?: {in: number, out: number}|null, thinking?: boolean}} o
1093
1061
  * @returns {{run: (msgs: any[], tools: any[], opts?: any) => Promise<any>}} */
1094
- export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
1062
+ export const defaultJudgeLoop = ({ provider, system, rates = null, thinking = false }) => {
1095
1063
  const { Loop } = require('bare-agent');
1096
1064
  // BA-21: the Loop's run RESULT carries no `rateSource` — only the per-call metering event
1097
1065
  // does (`onLlmResult`). Capture it there and ride it on the result, so `runLocate` can
@@ -1107,7 +1075,7 @@ export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
1107
1075
  return {
1108
1076
  run: async (/** @type {any[]} */ msgs, /** @type {any[]} */ tools, /** @type {any} */ opts) => {
1109
1077
  seen = null;
1110
- const res = await loop.run(msgs, tools, { ...judgeCallOptions(provider), ...opts });
1078
+ const res = await loop.run(msgs, tools, { ...thinkingCallOptions(provider, thinking === true ? 'on' : 'off'), ...opts });
1111
1079
  return res && typeof res === 'object' && seen ? { ...res, rateSource: /** @type {any} */ (seen).rateSource } : res;
1112
1080
  },
1113
1081
  };
package/src/keysfile.js CHANGED
@@ -76,18 +76,24 @@ export function filledKeyNames(home) {
76
76
  }
77
77
 
78
78
  /**
79
- * Create the keys file with the five empty preset lines when — and only when — it does not
80
- * exist (mode 600). An existing file is never touched.
81
- * @param {readonly string[]} presetNames the names to seed, in order
79
+ * The two comment lines a fresh keys file is created with — no provider name, no preset key.
80
+ * Each key the person types becomes a row in Settings > Providers.
81
+ */
82
+ export const KEYS_FILE_BODY = '# bareloop keys — type NAME=value by hand; this file is never shown or written by bareloop.\n'
83
+ + '# One line per provider key, e.g. MY_PROVIDER_API_KEY=... ; each key becomes a row in Settings > Providers.\n';
84
+
85
+ /**
86
+ * Create the keys file with {@link KEYS_FILE_BODY} when — and only when — it does not
87
+ * exist (mode 600, dir 700). An existing file is never touched.
82
88
  * @param {string} [home]
83
89
  * @returns {boolean} true if it was created
84
90
  */
85
- export function ensureKeysFile(presetNames, home) {
91
+ export function ensureKeysFile(home) {
86
92
  const file = keysFilePath(home);
87
93
  if (existsSync(file)) return false;
88
- mkdirSync(keysHome(home), { recursive: true });
94
+ mkdirSync(keysHome(home), { recursive: true, mode: 0o700 });
89
95
  try {
90
- writeFileSync(file, presetNames.map((n) => `${n}=\n`).join(''), { mode: 0o600, flag: 'wx' });
96
+ writeFileSync(file, KEYS_FILE_BODY, { mode: 0o600, flag: 'wx' });
91
97
  } catch (/** @type {any} */ e) {
92
98
  if (e?.code === 'EEXIST') return false; // lost a race: the file is there, untouched
93
99
  throw e;
@@ -101,6 +107,8 @@ export function ensureKeysFile(presetNames, home) {
101
107
  * @property {string[]} names the NAMES the file defines (sorted; never values)
102
108
  * @property {string|null} warning a plain-words warning (file readable by others, or
103
109
  * unreadable), else null — a warning, never a refusal
110
+ * @property {boolean} [unreadable] true only when the file is there but could not be read (the Settings row then
111
+ * reads "refused — see above"); absent otherwise
104
112
  * @property {Record<string,string|undefined>} env shell env with the file filling the
105
113
  * names the shell does not set. THE ONLY place values leave this module.
106
114
  */
@@ -127,7 +135,7 @@ export function loadKeysEnv(opts = {}) {
127
135
  try {
128
136
  parsed = parseKeysText(readFileSync(file, 'utf8'));
129
137
  } catch (/** @type {any} */ e) {
130
- return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, env: { ...base } };
138
+ return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, unreadable: true, env: { ...base } };
131
139
  }
132
140
  /** @type {Record<string,string|undefined>} */
133
141
  const env = { ...base };