bareloop 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bareloop.context.md +137 -112
  3. package/package.json +2 -2
  4. package/src/authorflow.js +333 -58
  5. package/src/authoring.js +65 -2
  6. package/src/authorjob.js +57 -9
  7. package/src/authorreadout.js +50 -2
  8. package/src/authorrun.js +30 -27
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/calibrate.js +1 -1
  14. package/src/cardauthor.js +4 -1
  15. package/src/cli.js +1 -1
  16. package/src/config.js +129 -29
  17. package/src/declaredclose.js +4 -3
  18. package/src/draftspend.js +6 -1
  19. package/src/index.js +2 -4
  20. package/src/interviewrun.js +73 -52
  21. package/src/job.js +50 -11
  22. package/src/jobblock.js +140 -0
  23. package/src/judged.js +12 -44
  24. package/src/keysfile.js +15 -7
  25. package/src/kinds.js +2 -2
  26. package/src/ledger.js +1 -1
  27. package/src/modellist.js +84 -0
  28. package/src/monthly.js +23 -43
  29. package/src/panel/authorroutes.js +34 -19
  30. package/src/panel/authorsession.js +187 -72
  31. package/src/panel/index.html +699 -215
  32. package/src/panel/jobplan.js +107 -0
  33. package/src/panel/runroutes.js +3 -0
  34. package/src/panel/server.js +102 -28
  35. package/src/panel/settingsroutes.js +232 -173
  36. package/src/planrun.js +6 -6
  37. package/src/promptregisters.js +1 -1
  38. package/src/providerrows.js +226 -141
  39. package/src/providers.js +58 -250
  40. package/src/providershapes.js +18 -0
  41. package/src/replay.js +1 -1
  42. package/src/reuse.js +1 -1
  43. package/src/root.js +1 -1
  44. package/src/run.js +3 -3
  45. package/src/selection.js +1 -1
  46. package/src/source.js +64 -1
  47. package/src/text.js +13 -0
  48. package/src/thinking.js +130 -0
  49. package/src/transport.js +1 -1
  50. package/src/userrun.js +53 -76
  51. package/types/authorflow.d.ts +258 -39
  52. package/types/authoring.d.ts +7 -1
  53. package/types/authorjob.d.ts +35 -3
  54. package/types/authorreadout.d.ts +17 -1
  55. package/types/authorscout.d.ts +5 -3
  56. package/types/config.d.ts +23 -6
  57. package/types/declaredclose.d.ts +4 -1
  58. package/types/draftspend.d.ts +8 -2
  59. package/types/index.d.ts +1 -1
  60. package/types/job.d.ts +6 -6
  61. package/types/jobblock.d.ts +102 -0
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/modellist.d.ts +50 -0
  65. package/types/monthly.d.ts +4 -3
  66. package/types/panel/authorsession.d.ts +44 -15
  67. package/types/panel/jobplan.d.ts +58 -0
  68. package/types/panel/server.d.ts +34 -4
  69. package/types/planrun.d.ts +1 -1
  70. package/types/providerrows.d.ts +192 -99
  71. package/types/providers.d.ts +35 -116
  72. package/types/providershapes.d.ts +10 -0
  73. package/types/run.d.ts +1 -1
  74. package/types/source.d.ts +26 -0
  75. package/types/thinking.d.ts +90 -0
  76. package/types/userrun.d.ts +6 -1
package/src/config.js CHANGED
@@ -1,17 +1,22 @@
1
- // PANEL-BUILD.md P4a item 2 — the ONE reader/writer of `~/.config/bareloop/config.json`.
1
+ // rewritten-from: fwdloop src/config.js@f9449e8 (validators `modelsProblem`, `providerFieldProblem`, `cleanBaseUrl`)
2
+ // PANEL-BUILD.md P4a item 2 / P8 — the ONE reader/writer of `~/.config/bareloop/config.json`.
2
3
  //
3
- // Every panel/CLI setting lives in this one file (R5): the monthly $ limit, the
4
- // Anthropic balance note, and the Providers rows' Name / API shape / Base URL, per key variable.
4
+ // Every panel/CLI setting lives in this one file (R5): the monthly $ limit and the Providers
5
+ // rows' API shape / Base URL / old-max_tokens tick (`keys`) and each row's model lines (`models`).
5
6
  // Key VALUES never live here — they live in the keys file (`src/keysfile.js`) and the
6
7
  // shell env. The write path refuses a document that carries a secret-shaped string
7
8
  // (the ONE inventory: `sweepSecretLiterals`, src/validate.js).
8
9
  //
9
10
  // Shape (unknown fields are kept, never dropped):
10
- // { "monthlyLimitUsd": <number>, // absent = no limit, no check
11
- // "anthropicBalanceNote": <number>, // a typed note; no check ever reads it
12
- // "keys": { "<ENV NAME>": { "name": "<model id>", "shape": "<api shape>", "baseUrl": "<url or blank>" } } }
11
+ // { "monthlyLimitUsd": <number > 0>, // absent = no limit, no check
12
+ // "keys": { "<ENV NAME>": { "shape": "<shape id>", "baseUrl": "http(s)://…", "legacyMaxTokens": true|false } },
13
+ // "models": { "<ENV NAME>": [ { "model": "<id>", "use": true|false, "inPerM": n, "outPerM": n } ] } }
14
+ // An older file's `keys.<ENV>.{name,priceInPerM,priceOutPerM}` is tolerated and read as ONE model
15
+ // line (`effectiveModels`, src/providerrows.js); the first write of that row's `models` list removes them.
16
+ // `modelsProblem` is the ONE rule for a row's model lines: a price is a PAIR or neither, a ticked
17
+ // line needs both. Nothing here sniffs a host or a model id.
13
18
  //
14
- // A missing file = defaults. An UNREADABLE file is reported (`problem`), never
19
+ // A missing file = defaults. An UNREADABLE or INVALID file is reported (`problem`), never
15
20
  // papered over — the monthly check REFUSES on it rather than silently running with
16
21
  // no limit. Written atomically (tmp + rename), mode 600.
17
22
  import {
@@ -20,6 +25,7 @@ import {
20
25
  import { join, dirname } from 'node:path';
21
26
  import { homedir } from 'node:os';
22
27
  import { sweepSecretLiterals } from './validate.js';
28
+ import { SHAPE_IDS } from './providershapes.js';
23
29
 
24
30
  /** A plain-words config failure (bad value, secret-shaped string, unreadable file). */
25
31
  export class ConfigError extends Error {}
@@ -55,22 +61,106 @@ export function readConfig(opts = {}) {
55
61
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
56
62
  return { config: {}, exists: true, problem: `${file} must hold a JSON object — fix or remove it` };
57
63
  }
64
+ const bad = docProblem(parsed);
65
+ if (bad) return { config: {}, exists: true, problem: `${file}: ${bad} — fix or remove it` };
58
66
  return { config: parsed, exists: true, problem: null };
59
67
  }
60
68
 
61
69
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
62
- /** the API shapes a keys row may name (the `src/providers.js` table names) */
63
- const SHAPE_IDS = ['anthropic-api', 'openai-api', 'gemini-api'];
70
+
71
+ /** The fields of a key row this module validates (others are kept untouched). */
72
+ const KEY_FIELDS = Object.freeze(['shape', 'baseUrl', 'legacyMaxTokens']);
73
+
74
+ /**
75
+ * One key-row field's value problem in plain words, or null when fine.
76
+ * @param {string} field `model` | `shape` | `baseUrl` | `legacyMaxTokens`
77
+ * @param {unknown} v
78
+ * @returns {string|null}
79
+ */
80
+ export function providerFieldProblem(field, v) {
81
+ if (field === 'model') {
82
+ if (typeof v !== 'string' || v.length === 0 || v.length > 200 || /\s/.test(v)) return 'must be a model id: 1 to 200 characters, no spaces';
83
+ } else if (field === 'shape') {
84
+ if (typeof v !== 'string' || !SHAPE_IDS.includes(v)) return `must be one of ${SHAPE_IDS.join(', ')}`;
85
+ } else if (field === 'legacyMaxTokens') {
86
+ if (typeof v !== 'boolean') return 'must be true or false';
87
+ } else if (field === 'baseUrl') {
88
+ if (typeof v !== 'string' || !(v === '' || /^https?:\/\/\S+$/.test(v))) return 'must be empty or an http(s) address';
89
+ } else return 'is not a provider field';
90
+ return null;
91
+ }
92
+
93
+ /** A Base URL as stored: trailing slashes dropped. @param {string} v @returns {string} */
94
+ export const cleanBaseUrl = (v) => v.replace(/\/+$/, '');
95
+
96
+ /**
97
+ * THE one rule for a row's model lines, used by config validation and the Settings routes. A price
98
+ * is a PAIR (`inPerM` and `outPerM`, each a finite number >= 0 — 0 / 0 typed is a chosen price) or
99
+ * neither (null / absent). A ticked line (`use`) needs both. Plain words, or null.
100
+ * @param {unknown} list
101
+ * @returns {string|null}
102
+ */
103
+ export function modelsProblem(list) {
104
+ if (!Array.isArray(list)) return 'The model list must be a list of lines.';
105
+ const seen = new Set();
106
+ for (const line of list) {
107
+ if (line === null || typeof line !== 'object' || Array.isArray(line)) return 'Each model line must be an object.';
108
+ const l = /** @type {Record<string, any>} */ (line);
109
+ const bad = providerFieldProblem('model', l.model);
110
+ if (bad) return `The model name ${bad}.`;
111
+ if (seen.has(l.model)) return `The model "${l.model}" is listed twice.`;
112
+ seen.add(l.model);
113
+ if (typeof l.use !== 'boolean') return 'Use must be on or off.';
114
+ const has = (/** @type {string} */ f) => l[f] !== undefined && l[f] !== null;
115
+ const isNum = (/** @type {unknown} */ v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
116
+ if (has('inPerM') !== has('outPerM')) return 'Fill both price boxes or clear both.';
117
+ if (has('inPerM') && !(isNum(l.inPerM) && isNum(l.outPerM))) return 'A price must be a number, 0 or more.';
118
+ if (l.use && !has('inPerM')) return 'Fill both prices to tick this model.';
119
+ for (const k of Object.keys(l)) if (!['model', 'use', 'inPerM', 'outPerM'].includes(k)) return `A model line has no "${k}" field.`;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * The first problem in a document's known fields, in plain words, or null.
126
+ * @param {Record<string, any>} doc
127
+ * @returns {string|null}
128
+ */
129
+ function docProblem(doc) {
130
+ // monthlyLimitUsd is NOT judged here: `monthlyLimitOf` (src/monthly.js) owns its read-time sentence
131
+ if (doc.keys !== undefined) {
132
+ if (doc.keys === null || typeof doc.keys !== 'object' || Array.isArray(doc.keys)) return 'keys must be an object of key variable names';
133
+ for (const [env, row] of Object.entries(doc.keys)) {
134
+ if (!ENV_NAME_RE.test(env)) return `"${env}" is not a key variable name`;
135
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) return `keys.${env} must be an object`;
136
+ for (const f of KEY_FIELDS) {
137
+ if (!Object.hasOwn(row, f)) continue;
138
+ const bad = providerFieldProblem(f, /** @type {Record<string, any>} */ (row)[f]);
139
+ if (bad) return `keys.${env}.${f} ${bad}`;
140
+ }
141
+ }
142
+ }
143
+ if (doc.models !== undefined) {
144
+ if (doc.models === null || typeof doc.models !== 'object' || Array.isArray(doc.models)) return 'models must be an object of key variable names';
145
+ for (const [env, list] of Object.entries(doc.models)) {
146
+ if (!ENV_NAME_RE.test(env)) return `models: "${env}" is not a key variable name`;
147
+ const bad = modelsProblem(list);
148
+ if (bad) return `models.${env}: ${bad}`;
149
+ }
150
+ }
151
+ return null;
152
+ }
64
153
 
65
154
  /**
66
155
  * Apply a patch and write. `null` deletes a field. Top-level fields shallow-merge;
67
- * `keys` merges per key variable name, then per field. Validated before anything is
68
- * written; nothing is written on a refusal.
156
+ * `keys` merges per key variable name, then per field; `models.<ENV>` is REPLACED WHOLE (`null`
157
+ * clears it). Validated before anything is written; nothing is written on a refusal.
69
158
  *
70
- * Patch fields understood: `monthlyLimitUsd` (finite > 0), `anthropicBalanceNote`
71
- * (finite >= 0), `keys.<ENV NAME>.{name,shape,baseUrl}` (Name = a model id, shape = a provider table name, baseUrl = blank or http(s)). Any other
72
- * field is passed through untouched (kept), but the whole document is swept for
73
- * secret-shaped strings first.
159
+ * Patch fields understood: `monthlyLimitUsd` (finite > 0), `keys.<ENV>.{shape,baseUrl,legacyMaxTokens}`,
160
+ * `models.<ENV>` (a list judged by {@link modelsProblem}). The first write of a row's `models` list
161
+ * removes that row's older `name` / `priceInPerM` / `priceOutPerM` in the SAME write (never two
162
+ * sources); a retired top-level `anthropicBalanceNote` is dropped on any write. Any other field is
163
+ * kept, but the whole document is swept for secret-shaped strings first.
74
164
  * @param {Record<string, any>} patch
75
165
  * @param {{ home?: string }} [opts]
76
166
  * @returns {Record<string, any>} the document as written
@@ -81,16 +171,14 @@ export function updateConfig(patch, opts = {}) {
81
171
  /** @type {Record<string, any>} */
82
172
  const next = { ...cur.config };
83
173
  for (const [k, v] of Object.entries(patch)) {
84
- if (k === 'keys') continue;
174
+ if (k === 'keys' || k === 'models') continue;
85
175
  if (v === null) { delete next[k]; continue; }
86
176
  if (k === 'monthlyLimitUsd' && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
87
177
  throw new ConfigError('monthlyLimitUsd must be a number above 0 (remove the limit to have none)');
88
178
  }
89
- if (k === 'anthropicBalanceNote' && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
90
- throw new ConfigError('anthropicBalanceNote must be a number, 0 or more');
91
- }
92
179
  next[k] = v;
93
180
  }
181
+ delete next.anthropicBalanceNote;
94
182
  if (patch.keys !== undefined) {
95
183
  if (patch.keys === null || typeof patch.keys !== 'object' || Array.isArray(patch.keys)) {
96
184
  throw new ConfigError('keys must be an object of key variable names');
@@ -104,21 +192,33 @@ export function updateConfig(patch, opts = {}) {
104
192
  const merged = { ...(keys[envName] && typeof keys[envName] === 'object' ? keys[envName] : {}) };
105
193
  for (const [fk, fv] of Object.entries(/** @type {Record<string, any>} */ (fields))) {
106
194
  if (fv === null) { delete merged[fk]; continue; }
107
- if (fk === 'name' && !(typeof fv === 'string' && fv.length <= 100 && !/\s/.test(fv))) {
108
- throw new ConfigError(`keys.${envName}.name must be a model id (no spaces, up to 100 characters)`);
109
- }
110
- if (fk === 'shape' && !SHAPE_IDS.includes(fv)) {
111
- throw new ConfigError(`keys.${envName}.shape must be one of ${SHAPE_IDS.join(', ')}`);
112
- }
113
- if (fk === 'baseUrl' && !(typeof fv === 'string' && (fv === '' || /^https?:\/\/\S+$/.test(fv)))) {
114
- throw new ConfigError(`keys.${envName}.baseUrl must be blank or an http(s) URL`);
115
- }
116
- merged[fk] = fv;
195
+ merged[fk] = fk === 'baseUrl' && typeof fv === 'string' ? cleanBaseUrl(fv) : fv;
117
196
  }
118
197
  keys[envName] = merged;
119
198
  }
120
199
  next.keys = keys;
121
200
  }
201
+ if (patch.models !== undefined) {
202
+ if (patch.models === null || typeof patch.models !== 'object' || Array.isArray(patch.models)) {
203
+ throw new ConfigError('models must be an object of key variable names');
204
+ }
205
+ /** @type {Record<string, any>} */
206
+ const models = { ...(next.models && typeof next.models === 'object' ? next.models : {}) };
207
+ for (const [envName, list] of Object.entries(patch.models)) {
208
+ if (!ENV_NAME_RE.test(envName)) throw new ConfigError(`"${envName}" is not a key variable name`);
209
+ if (list === null) { delete models[envName]; continue; }
210
+ models[envName] = list;
211
+ const row = next.keys?.[envName];
212
+ if (row && typeof row === 'object') {
213
+ // one source: the older single-model fields leave with the first saved list
214
+ const { name: _n, priceInPerM: _i, priceOutPerM: _o, ...rest } = row;
215
+ next.keys = { ...next.keys, [envName]: rest };
216
+ }
217
+ }
218
+ next.models = models;
219
+ }
220
+ const problem = docProblem(next);
221
+ if (problem) throw new ConfigError(problem);
122
222
  /** @type {string[]} */
123
223
  const secretPaths = [];
124
224
  sweepSecretLiterals(next, (_code, path) => { secretPaths.push(path); });
@@ -102,7 +102,7 @@ export const DECLARED_GENRES = Object.freeze([TYPES_GENRE.name]);
102
102
  * constrained to the names the genre OWNS, so it can never become an environment
103
103
  * channel of its own.
104
104
  */
105
- export const CLOSE_DECL_FIELDS = Object.freeze(['genre', 'lang', 'stages', 'notes', 'genreEnv', 'calibration']);
105
+ export const CLOSE_DECL_FIELDS = Object.freeze(['genre', 'lang', 'stages', 'notes', 'refused', 'genreEnv', 'calibration']);
106
106
 
107
107
  /** what a stored calibration set carries, ENUMERATED: the frozen cases and the judge
108
108
  * tier that graded them. Both are inside the spec, so `jobSpecHash` covers both — which
@@ -272,13 +272,13 @@ export function declaredStages(closeDecl) {
272
272
  *
273
273
  * @param {any} closeDecl
274
274
  * @param {{at?: string, listing?: string[]|null, deferListing?: boolean,
275
- * catalogue?: Record<string, any>, verdictType?: string|null, writeScope?: string[]|null}} [opts]
275
+ * catalogue?: Record<string, any>, verdictType?: string|null, writeScope?: string[]|null, jobLines?: {n: number}[]|null}} [opts]
276
276
  * @returns {{ok: boolean, reds: Red[], closeDecl: any, grounded: boolean,
277
277
  * scoped: {scoped: boolean, via: string|null},
278
278
  * ceiling: {class: string, kind: string, stage: string|null, locked: boolean}|null}}
279
279
  */
280
280
  export function validateCloseDecl(closeDecl, opts = {}) {
281
- const { at = 'closeDecl', listing = null, catalogue, verdictType = null, writeScope = null } = opts;
281
+ const { at = 'closeDecl', listing = null, catalogue, verdictType = null, writeScope = null, jobLines = null } = opts;
282
282
  const deferListing = opts.deferListing === true;
283
283
  /** @type {Red[]} */
284
284
  const reds = [];
@@ -402,6 +402,7 @@ export function validateCloseDecl(closeDecl, opts = {}) {
402
402
  guards: classGuards({ verdictType: picked, lang: closeDecl.lang }),
403
403
  verdictType: picked,
404
404
  writeScope,
405
+ jobLines,
405
406
  envOwned: owned,
406
407
  // POST-injection: an owned name is accepted only when its value is EXACTLY
407
408
  // the recorded one. `{}` (nothing recorded) keeps the pre-injection rule, so
package/src/draftspend.js CHANGED
@@ -24,7 +24,10 @@ export const DRAFT_LOG_FILE = 'draft-log.jsonl';
24
24
  * One line of the drafting log. `no` = the step's position in the session's progress list (a step id can repeat).
25
25
  * - `step-start` {no, id, label, at}
26
26
  * - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
27
- * - `call` {no, step, label, model, costUsd (null stays null), unpricedRounds, at}: one METERED model call
27
+ * - `step-label` {no, label, at} (the step reads `label` from now on, status and times untouched: the thinking row's "the model thought anyway" / "can't change it" / "not confirmed")
28
+ * - `call` {no, step, label, model, thinking, thinkingSupport, thinkingStatus?, costUsd (null stays null), unpricedRounds, at}: one METERED model call
29
+ * (`thinking` = the tick's mode: off | on; `thinkingSupport` = bare-agent's lookup for the model: true | false | 'unverified';
30
+ * `thinkingStatus` = bare-agent's own verdict for the call's run: applied | unsupported | ignored | unknown, present only when thinkingMode was sent)
28
31
  * (no token counts: the call path does not carry them without reworking the library, so none are recorded)
29
32
  * @typedef {{kind: string, no: number, at: string, [k: string]: any}} DraftEvent
30
33
  */
@@ -148,6 +151,8 @@ export function readDraftSteps(dir) {
148
151
  x.status = ev.status === 'failed' ? 'failed' : 'done';
149
152
  x.endedAt = ev.at;
150
153
  if (typeof ev.label === 'string') x.label = ev.label;
154
+ } else if (ev.kind === 'step-label' && byNo.has(ev.no)) {
155
+ if (typeof ev.label === 'string') byNo.get(ev.no).label = ev.label;
151
156
  } else if (ev.kind === 'step-reopen' && byNo.has(ev.no)) {
152
157
  const x = byNo.get(ev.no);
153
158
  x.status = 'running';
package/src/index.js CHANGED
@@ -148,16 +148,14 @@ export { answerReviewDoor } from './reviewdoor.js';
148
148
  // SOFTGREEN module 1 — the judged floor's core. The pieces an adopter (and an
149
149
  // integrating UI) genuinely needs: `resolveJudge` (PRD item 32.1 — WHICH model
150
150
  // grades a job: the spec's signed `judge` override, else the job's own worker
151
- // model, and no library fall-back), `JUDGE_MODEL` (the pre-item-32 pin, kept
152
- // exported for adopters and still the right default for an anthropic-api job,
153
- // but no longer read by any grading path), the rulebook a card SELECTS from,
151
+ // model, and no library fall-back; the old pinned-model constant is gone), the rulebook a card SELECTS from,
154
152
  // the card gate, and the two halves themselves so a calibration harness can
155
153
  // grade the whole pipe without running a close.
156
154
  // `defaultJudgeLoop` (src/judged.js) is the one spelling of how this repo drives a
157
155
  // judge; the runner reaches it through `runPlan`'s `judgeProvider`. It is internal:
158
156
  // the package root does not export it.
159
157
  export {
160
- JUDGE_MODEL, resolveJudge, JUDGE_RULES, JUDGE_RULE_IDS,
158
+ resolveJudge, JUDGE_RULES, JUDGE_RULE_IDS,
161
159
  runLocate, decide,
162
160
  // SOFTGREEN module 4 — what a legal SIGNED calibration set is. The SIZE is
163
161
  // hamr's own ruling and a size change is a spec-level threshold change;
@@ -31,7 +31,7 @@
31
31
  // form (PRD item 33 M3 piece 3): Goal / Success / Guardrails, soft-green
32
32
  // adding Judge Examples. Source and Destination are MECHANICAL fields —
33
33
  // proven against the machine rather than typed — and their wording is
34
- // ALSO the library's (`SOURCE_FIELD`, `destinationFieldFor`,
34
+ // ALSO the library's (`INPUTS_FIELD`, `destinationFieldFor`,
35
35
  // `src/authorflow.js`), so this module prints what the library hands it
36
36
  // for all six fields, never re-worded here, never re-ordered, never stored;
37
37
  // - the REFUSALS are the library's (`runInterview`): a locked class refuses at
@@ -87,9 +87,9 @@
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
- writeFileSync, mkdirSync, existsSync, readFileSync, statSync,
92
+ writeFileSync, mkdirSync, readFileSync, statSync,
93
93
  } from 'node:fs';
94
94
  import { createInterface } from 'node:readline';
95
95
  import { spawnSync as realSpawnSync } from 'node:child_process';
@@ -101,13 +101,18 @@ import {
101
101
  PLAIN_FOLDER_DEFERRED_FIELDS,
102
102
  } from './authorjob.js';
103
103
  import {
104
- SOURCE_FIELD, destinationFieldFor, labelsFor,
104
+ INPUTS_FIELD, JOB_BLOCK_HELP, destinationFieldFor, labelsFor, SOFTGREEN_JUDGE_EXAMPLES_KEY,
105
105
  } from './authorflow.js';
106
+ import {
107
+ parseJobBlock, parseInputs, renderJobLines, renderExamples, jobBlockFitsCheck, signedJobLines,
108
+ } from './jobblock.js';
106
109
  import { validateJob, validateBaseUrl, PROVIDERS } from './job.js';
107
110
  import { resolveProvider, probeWarningLines, apiKeyProblem } from './providers.js';
108
111
  import { scanSecrets, redactSecrets } from './validate.js';
109
112
  import { detectLanguage } from './detectlang.js';
110
- import { prepareSource, proveDestination, looksLikeRepoSource, missingDependencies } from './source.js';
113
+ import {
114
+ prepareSource, proveDestination, looksLikeRepoSource, missingDependencies, proveInputFiles,
115
+ } from './source.js';
111
116
  import { parseCeiling, ceilingLine } from './authorreadout.js';
112
117
  import { commandFor } from './invoke.js';
113
118
 
@@ -187,6 +192,9 @@ export async function main(argv, deps = {}) {
187
192
  // way an empty `--verdict`/`--provider` value already does elsewhere in this
188
193
  // file.
189
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');
190
198
  // `--lang` IS GONE (PRD item 33 M3, ruling 3): language is a FACT of the
191
199
  // repository, read off its own manifest, never a flag a person sets. A
192
200
  // `--lang` on the command line now is an operator error, stopped loud rather
@@ -353,18 +361,26 @@ export async function main(argv, deps = {}) {
353
361
  throw new ExitSignal(1);
354
362
  }
355
363
 
356
- // ── 1. SOURCE — the form's first field (PRD item 33 M3, ruling 2) ────────────
364
+ // ── 1. INPUTS — the form's first field (P7, docs/product/PANEL-BUILD.md; replaces Source) ───────────────
365
+ // Line 1 is the repo (the Source this door has always taken); lines 2+ are tracked files inside it, proven at $0
366
+ // by `proveInputFiles` — the same function the panel calls. A bad line re-asks, naming the line.
357
367
  say('');
358
- say('── SOURCE ' + '─'.repeat(58));
359
- // LIBRARY WORDING (PRD item 33 M3 piece 3, ruling 1) — this script prints what
360
- // `src/authorflow.js` hands it and writes none of its own.
361
- say(SOURCE_FIELD.prompt);
362
- const sourceRaw = await readAnswer('the source',
363
- 'Source is required — this is what the job reads. Again:');
364
- const isUrl = /^https?:\/\//i.test(sourceRaw);
365
- const SOURCE = isUrl ? sourceRaw : resolve(sourceRaw);
366
- if (!isUrl && !existsSync(SOURCE)) {
367
- die(`${SOURCE} does not exist — Source is a folder, subfolder, file, or URL bareloop can actually read.`);
368
+ say('── INPUTS ' + '─'.repeat(58));
369
+ // LIBRARY WORDING — this script prints what `src/authorflow.js` hands it and writes none of its own.
370
+ say(INPUTS_FIELD.prompt);
371
+ /** @type {import('./jobblock.js').JobInput[]} */
372
+ let INPUTS = [];
373
+ /** @type {string} */
374
+ let SOURCE = '';
375
+ for (;;) {
376
+ const raw = await readAnswer('the inputs', 'Inputs is required — line 1 is the repo this job reads. Again:');
377
+ const parsed = parseInputs(raw);
378
+ if (!parsed.ok) { say(` ${parsed.error}`); continue; }
379
+ const proof = await proveInputFiles(parsed.inputs);
380
+ if (!proof.ok) { say(` ${proof.error}`); continue; }
381
+ INPUTS = parsed.inputs;
382
+ SOURCE = proof.repo;
383
+ break;
368
384
  }
369
385
  // the SAME rule `prepareSource` uses to route its own destination check
370
386
  // (`looksLikeRepoSource`, `src/source.js`) — never a second, hand-typed copy
@@ -401,7 +417,7 @@ export async function main(argv, deps = {}) {
401
417
  // ruling 7): a plain-folder job still gets the form; it
402
418
  // only gets an honest "no checks yet" stop later (M4).
403
419
  let LANG = 'none-detected';
404
- if (!isUrl && statSync(SOURCE).isDirectory()) {
420
+ if (statSync(SOURCE).isDirectory()) {
405
421
  const langResult = detectLanguage(SOURCE);
406
422
  if (langResult.kind === 'ambiguous') {
407
423
  // NO LONGER DIES (PRD item 33 M3 piece 4, step S5): `LANG` takes the
@@ -560,25 +576,38 @@ export async function main(argv, deps = {}) {
560
576
  say(` asks ${REQUIRED.length} frozen question(s) for this class, then the numbers and names the job spec needs`);
561
577
 
562
578
  // ── 3. the class's own frozen questions, one at a time ───────────────────────
579
+ // P7: "The job" is ONE multi-line answer — numbered lines with their `~` rules, parsed by `parseJobBlock` (the same
580
+ // parser the panel uses) and filed under key 1 as the numbered text the drafter reads. A rubric's second answer
581
+ // (the judge examples) is DERIVED from the `~ PASS:`/`~ FAIL:` lines of that block, never asked a second time.
563
582
  /** @type {Record<string, string>} */
564
583
  const answers = {};
584
+ /** @type {import('./jobblock.js').JobLine[]} */
585
+ let JOB_LINES = [];
586
+ const ASKED = REQUIRED.filter((n) => !(VERDICT === 'soft-green' && n === SOFTGREEN_JUDGE_EXAMPLES_KEY));
565
587
  let asked = 0;
566
- for (const n of REQUIRED) {
588
+ for (const n of ASKED) {
567
589
  asked += 1;
568
590
  say('');
569
- say(`── ${asked} of ${REQUIRED.length} ${'─'.repeat(Math.max(0, 56 - String(asked).length))}`);
570
- // the FIELD LABEL, from the signed table (PRD item 33 M3 piece 3's wording
571
- // fix) — printed on its own line so the literal `${n}. ${QUESTIONS[n]}` below
572
- // stays byte-identical to what the library holds, which is what the wizard
573
- // test suite (`tests/run-interview.test.js`) asserts verbatim and in order.
574
- // A key with no signed-table row prints no label.
591
+ say(`── ${asked} of ${ASKED.length} ${'─'.repeat(Math.max(0, 56 - String(asked).length))}`);
592
+ // the FIELD LABEL, from the signed table — printed on its own line so the literal `${n}. ${QUESTIONS[n]}` below
593
+ // stays byte-identical to what the library holds. A key with no signed-table row prints no label.
575
594
  if (LABELS[n]) say(LABELS[n]);
576
- // the frozen wording, printed as the library holds it. Numbered by the library's
577
- // own key, so the number a person sees is the number their answer is filed under.
595
+ // the frozen wording, printed as the library holds it. Numbered by the library's own key.
578
596
  say(`${n}. ${QUESTIONS[n]}`);
579
- answers[n] = await readAnswer(`question ${n}`,
580
- 'that one is required — every question in this class\'s set has to be answered before a close can be authored from it. Again:');
597
+ for (const h of JOB_BLOCK_HELP) say(` ${h}`);
598
+ for (;;) {
599
+ const raw = await readAnswer(`question ${n}`,
600
+ 'that one is required — every question in this class\'s set has to be answered before a close can be authored from it. Again:');
601
+ const parsed = parseJobBlock(raw);
602
+ if (!parsed.ok) { say(` ${parsed.error}`); continue; }
603
+ const misfit = jobBlockFitsCheck(parsed.lines, VERDICT === 'soft-green');
604
+ if (misfit) { say(` ${misfit}`); continue; }
605
+ JOB_LINES = parsed.lines;
606
+ answers[n] = renderJobLines(parsed.lines);
607
+ break;
608
+ }
581
609
  }
610
+ if (VERDICT === 'soft-green') answers[SOFTGREEN_JUDGE_EXAMPLES_KEY] = renderExamples(JOB_LINES);
582
611
 
583
612
  // ── 4. the OPERATOR's half of the spec — the part nothing authors for you ────
584
613
  say('');
@@ -660,6 +689,7 @@ export async function main(argv, deps = {}) {
660
689
  // written as `null`/`''`; every provider constructor defaults its own
661
690
  // endpoint on its own in that case (PRD item 28 ruling (d)).
662
691
  ...(baseUrlArg === null ? {} : { baseUrl: baseUrlArg }),
692
+ ...(modelArg === null ? {} : { model: modelArg }),
663
693
  cadence: { unit: 'day', every: 1 },
664
694
  budgetUsd,
665
695
  ...(wallMin === null ? {} : { maxWallMs: Math.round(wallMin * 60_000) }),
@@ -667,6 +697,9 @@ export async function main(argv, deps = {}) {
667
697
  // meaningless fence derived from an output directory.
668
698
  ...(writeScope === null ? {} : { writeScope }),
669
699
  escalation: { mode: 'decision-ready' },
700
+ // P7: the person's own words, signed beside the model's goal sentence — what the Job tab and Reuse show back
701
+ jobLines: signedJobLines(JOB_LINES),
702
+ inputs: INPUTS,
670
703
  // `tools` is deliberately OMITTED: an omitted menu hashes as the concrete current
671
704
  // TOOL_MENU (MED-1), which pins WHICH menu was signed and makes a widening flip the
672
705
  // hash. Naming one here would freeze today's list into the operator's own half.
@@ -753,29 +786,17 @@ export async function main(argv, deps = {}) {
753
786
  // anything this script itself let through; the try/catch is only for the
754
787
  // off-menu case `validateJob` reds but does not stop the write for (an
755
788
  // admitted-but-uncrafted table entry would otherwise crash this print).
756
- let providerEntry = null;
757
- try { providerEntry = resolveProvider(PROVIDER); } catch { providerEntry = null; }
758
- const providerEnvKey = providerEntry?.envKey ?? null;
759
- say(` ${providerEnvKey ?? 'YOUR_PROVIDER_API_KEY'}=... ${commandFor('author', deps.invokedAs)} ${childArgs.join(' ')}`);
760
- // An `openai-api` + `baseUrl` draft (DeepSeek, today's one secondary, reached
761
- // this way) still reads its key from `OPENAI_API_KEY` — there is no separate
762
- // "DeepSeek key" env var, and a person pointed only at the command above could
763
- // reasonably miss that. Said once, plainly, never a key VALUE.
764
- if (draft.baseUrl !== undefined && providerEnvKey) {
765
- 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)`);
766
- }
767
- // NO KEY, NO OFFER. `run-author.mjs` refuses without one and exits 2 at its own
768
- // door, before a spine exists — so with the shell unkeyed this question has
769
- // exactly one possible outcome for the person, and putting it anyway spends
770
- // their attention on a choice they do not have. What is actionable instead is
771
- // the command above and the one line that says how to make it work.
772
- // F181 — a key that IS set can still carry a shape run-author will refuse
773
- // (a line break/control char/stray whitespace, e.g. a two-line secret-store
774
- // entry). A malformed key must not count as KEYED: this offer must read the
775
- // same "will it actually run" question run-author itself asks at its door.
776
- // P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
777
- const keyEnv = keys.home !== undefined ? applyConfiguredKey(env, PROVIDER, draft.baseUrl, rowsForHome(keys.home)) : env;
778
- 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;
779
800
  const keyProblem = rawKeyValue ? apiKeyProblem(rawKeyValue) : null;
780
801
  const KEYED = providerEnvKey !== null && Boolean(rawKeyValue) && !keyProblem;
781
802
  if (!KEYED) {
@@ -785,7 +806,7 @@ export async function main(argv, deps = {}) {
785
806
  } else {
786
807
  say(providerEnvKey
787
808
  ? ` (${providerEnvKey} is not set in this shell — bareloop author refuses without it; secrets load from the environment, never the tree)`
788
- : ` (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)');
789
810
  }
790
811
  // The repair, in plain words and WITHOUT a command: which secret store a person
791
812
  // keeps their key in is theirs, and printing one specific incantation would be