bareloop 0.31.0 → 0.33.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 (54) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -1
  3. package/bareloop.context.md +232 -85
  4. package/bin/bareloop.mjs +0 -1
  5. package/package.json +1 -1
  6. package/src/authorflow.js +5 -3
  7. package/src/authorjob.js +3 -1
  8. package/src/authorrun.js +49 -11
  9. package/src/authorscout.js +10 -6
  10. package/src/bundlerun.js +266 -0
  11. package/src/cli.js +35 -357
  12. package/src/config.js +135 -0
  13. package/src/interviewrun.js +13 -4
  14. package/src/invoke.js +22 -0
  15. package/src/judged.js +5 -3
  16. package/src/keysfile.js +156 -0
  17. package/src/ledger.js +26 -0
  18. package/src/monthly.js +422 -0
  19. package/src/panel/authorroutes.js +61 -16
  20. package/src/panel/authorsession.js +46 -28
  21. package/src/panel/index.html +287 -13
  22. package/src/panel/server.js +58 -51
  23. package/src/panel/settingsroutes.js +230 -0
  24. package/src/planrun.js +7 -3
  25. package/src/providerrows.js +301 -0
  26. package/src/providers.js +3 -1
  27. package/src/run.js +7 -2
  28. package/src/runlist.js +92 -7
  29. package/src/source.js +8 -3
  30. package/src/u-watchdog.mjs +296 -0
  31. package/src/userrun.js +230 -54
  32. package/types/authorflow.d.ts +8 -2
  33. package/types/authorjob.d.ts +6 -1
  34. package/types/authorrun.d.ts +5 -0
  35. package/types/authorscout.d.ts +11 -2
  36. package/types/bundlerun.d.ts +26 -0
  37. package/types/cli.d.ts +2 -1
  38. package/types/config.d.ts +48 -0
  39. package/types/interviewrun.d.ts +5 -0
  40. package/types/invoke.d.ts +8 -0
  41. package/types/judged.d.ts +5 -1
  42. package/types/keysfile.d.ts +90 -0
  43. package/types/ledger.d.ts +14 -0
  44. package/types/monthly.d.ts +227 -0
  45. package/types/panel/authorroutes.d.ts +4 -2
  46. package/types/panel/authorsession.d.ts +7 -15
  47. package/types/panel/server.d.ts +10 -3
  48. package/types/panel/settingsroutes.d.ts +14 -0
  49. package/types/planrun.d.ts +13 -1
  50. package/types/providerrows.d.ts +229 -0
  51. package/types/run.d.ts +14 -1
  52. package/types/runlist.d.ts +66 -3
  53. package/types/source.d.ts +8 -3
  54. package/types/userrun.d.ts +54 -0
@@ -86,6 +86,8 @@
86
86
  // question this used to ask separately is GONE, folded into Destination. A
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
+ import { keysForDoor } from './keysfile.js';
90
+ import { applyConfiguredKey, rowsForHome } from './providerrows.js';
89
91
  import {
90
92
  writeFileSync, mkdirSync, existsSync, readFileSync, statSync,
91
93
  } from 'node:fs';
@@ -107,6 +109,7 @@ import { scanSecrets, redactSecrets } from './validate.js';
107
109
  import { detectLanguage } from './detectlang.js';
108
110
  import { prepareSource, proveDestination, looksLikeRepoSource, missingDependencies } from './source.js';
109
111
  import { parseCeiling, ceilingLine } from './authorreadout.js';
112
+ import { commandFor } from './invoke.js';
110
113
 
111
114
  /** Thrown to unwind `main` to an exit code without ever calling
112
115
  * `process.exit()` (constraint: a library function returns a code / throws,
@@ -123,6 +126,7 @@ class ExitSignal extends Error {
123
126
  /**
124
127
  * @typedef {object} Deps
125
128
  * @property {Record<string,string|undefined>} [env]
129
+ * @property {string} [keysHome] P4a — test seam: load this home's `.env` keys file even when `env` is injected.
126
130
  * @property {NodeJS.ReadStream} [stdin]
127
131
  * @property {NodeJS.WritableStream} [stdout]
128
132
  * @property {NodeJS.WritableStream} [stderr]
@@ -146,12 +150,15 @@ class ExitSignal extends Error {
146
150
  * @returns {Promise<number>} an exit code — never calls process.exit
147
151
  */
148
152
  export async function main(argv, deps = {}) {
149
- const env = deps.env ?? process.env;
153
+ // P4a item 1 — the keys file fills what the shell leaves unset (shell wins).
154
+ const keys = keysForDoor(deps);
155
+ const env = keys.env;
150
156
  const stdin = deps.stdin ?? process.stdin;
151
157
  const stdout = deps.stdout ?? process.stdout;
152
158
  const stderr = deps.stderr ?? process.stderr;
159
+ if (keys.warning && deps.env === undefined) stderr.write(`WARNING: ${keys.warning}\n`);
153
160
  const spawnSyncFn = deps.spawnSync ?? realSpawnSync;
154
- const invokedAs = deps.invokedAs ?? 'node scripts/run-interview.mjs';
161
+ const invokedAs = deps.invokedAs ?? commandFor('interview', undefined);
155
162
  const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
156
163
  const err = (/** @type {string} */ s) => { stderr.write(`${s}\n`); };
157
164
  const arg = (/** @type {string} */ n) => { const i = argv.indexOf(`--${n}`); return i === -1 ? null : (argv[i + 1] ?? ''); };
@@ -748,7 +755,7 @@ export async function main(argv, deps = {}) {
748
755
  let providerEntry = null;
749
756
  try { providerEntry = resolveProvider(PROVIDER); } catch { providerEntry = null; }
750
757
  const providerEnvKey = providerEntry?.envKey ?? null;
751
- say(` ${providerEnvKey ?? 'YOUR_PROVIDER_API_KEY'}=... node scripts/run-author.mjs ${childArgs.join(' ')}`);
758
+ say(` ${providerEnvKey ?? 'YOUR_PROVIDER_API_KEY'}=... ${commandFor('author', deps.invokedAs)} ${childArgs.join(' ')}`);
752
759
  // An `openai-api` + `baseUrl` draft (DeepSeek, today's one secondary, reached
753
760
  // this way) still reads its key from `OPENAI_API_KEY` — there is no separate
754
761
  // "DeepSeek key" env var, and a person pointed only at the command above could
@@ -765,7 +772,9 @@ export async function main(argv, deps = {}) {
765
772
  // (a line break/control char/stray whitespace, e.g. a two-line secret-store
766
773
  // entry). A malformed key must not count as KEYED: this offer must read the
767
774
  // same "will it actually run" question run-author itself asks at its door.
768
- const rawKeyValue = providerEnvKey ? env[providerEnvKey] : undefined;
775
+ // P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
776
+ const keyEnv = keys.home !== undefined ? applyConfiguredKey(env, PROVIDER, draft.baseUrl, rowsForHome(keys.home)) : env;
777
+ const rawKeyValue = providerEnvKey ? keyEnv[providerEnvKey] : undefined;
769
778
  const keyProblem = rawKeyValue ? apiKeyProblem(rawKeyValue) : null;
770
779
  const KEYED = providerEnvKey !== null && Boolean(rawKeyValue) && !keyProblem;
771
780
  if (!KEYED) {
package/src/invoke.js ADDED
@@ -0,0 +1,22 @@
1
+ // The one owner of how a printed re-invocation is spelled. The package ships `src/` and `bin/`
2
+ // only, so a person who reached a flow through the `bareloop` CLI must be told
3
+ // `bareloop run-u ...`, never `node scripts/run-u.mjs ...` (a path their install does not have).
4
+ // Each flow's `main(argv, deps)` takes `deps.invokedAs` — the command it was actually reached
5
+ // through (`src/cli.js` passes `'bareloop <name>'`; the `scripts/*.mjs` adapters pass none).
6
+
7
+ /** @typedef {'run-u' | 'interview' | 'author'} Flow */
8
+
9
+ /** the script adapter each flow keeps for a source-tree run */
10
+ const SCRIPT = /** @type {const} */ ({ 'run-u': 'run-u', interview: 'run-interview', author: 'run-author' });
11
+
12
+ /**
13
+ * The command that runs `flow`, spelled the way the caller reached this flow: `bareloop <flow>`
14
+ * when `invokedAs` names the CLI, else the source-tree script.
15
+ * @param {Flow} flow @param {string|undefined} invokedAs
16
+ * @returns {string}
17
+ */
18
+ export function commandFor(flow, invokedAs) {
19
+ return invokedAs !== undefined && /^bareloop(\s|$)/.test(invokedAs)
20
+ ? `bareloop ${flow}`
21
+ : `node scripts/${SCRIPT[flow]}.mjs`;
22
+ }
package/src/judged.js CHANGED
@@ -791,10 +791,12 @@ function topLevelFunctionNames(artifactText) {
791
791
  * A `Loop`, NOT `provider.generate()`: the POC measured that a bare provider
792
792
  * result carries no `costUsd` at all, and under F6 an unpriced call is a red,
793
793
  * so the priced seam is the only legal one.
794
- * @param {{provider: any, system: string}} o */
795
- export const defaultJudgeLoop = ({ provider, system }) => {
794
+ * `rates` is the customer's own price for the judge's row (USD per 1K, `ratesFor`'s
795
+ * `rates`); null/absent = bare-agent's built-in guess, exactly as before.
796
+ * @param {{provider: any, system: string, rates?: {in: number, out: number}|null}} o */
797
+ export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
796
798
  const { Loop } = require('bare-agent');
797
- return new Loop({ provider, system });
799
+ return new Loop({ provider, system, ...(rates ? { rates } : {}) });
798
800
  };
799
801
 
800
802
  // ── LOCATE: one attempt ─────────────────────────────────────────────────────
@@ -0,0 +1,156 @@
1
+ // PANEL-BUILD.md P4a item 1 — the keys file loader (`~/.config/bareloop/.env`).
2
+ //
3
+ // Key VALUES live only in this file (chmod 600, outside any repo) and in the shell
4
+ // environment — never in config.json, the spine, runs.jsonl, a log, or a page.
5
+ // This is the ONE reader of that file. Plain `NAME=value` lines (an optional
6
+ // `export ` prefix, `#` full-line comments, one pair of matching quotes around the
7
+ // value); no dependency.
8
+ //
9
+ // Precedence: the SHELL wins. An explicit `export FOO=…` beats the file; the file
10
+ // only fills names the shell leaves unset (or empty). The merged env is a NEW
11
+ // object — `process.env` is never mutated.
12
+ //
13
+ // Values leave this module in exactly one place: the `env` of `loadKeysEnv`, which
14
+ // goes to provider construction. Everything else it returns (`names`, `warning`) is
15
+ // value-free by construction, and a parse problem never quotes the offending line.
16
+ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+
20
+ /**
21
+ * `~/.config/bareloop` (or the injected override) — same home every other
22
+ * per-person file (runs.jsonl, config.json) lives in.
23
+ * @param {string} [home] test seam; production never passes it
24
+ * @returns {string}
25
+ */
26
+ export function keysHome(home) {
27
+ return home ?? join(homedir(), '.config', 'bareloop');
28
+ }
29
+
30
+ /**
31
+ * @param {string} [home]
32
+ * @returns {string} `<home>/.env`
33
+ */
34
+ export function keysFilePath(home) {
35
+ return join(keysHome(home), '.env');
36
+ }
37
+
38
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
39
+
40
+ /**
41
+ * Parse the file's text. Lines that are not `NAME=value` are skipped silently
42
+ * (never echoed — a malformed line may hold a secret).
43
+ * @param {string} text
44
+ * @returns {Record<string,string>}
45
+ */
46
+ export function parseKeysText(text) {
47
+ /** @type {Record<string,string>} */
48
+ const out = {};
49
+ for (const raw of text.split(/\r?\n/)) {
50
+ const line = raw.trim();
51
+ if (line === '' || line.startsWith('#')) continue;
52
+ const body = line.startsWith('export ') ? line.slice(7).trimStart() : line;
53
+ const eq = body.indexOf('=');
54
+ if (eq <= 0) continue;
55
+ const name = body.slice(0, eq).trim();
56
+ if (!NAME_RE.test(name)) continue;
57
+ let value = body.slice(eq + 1).trim();
58
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) {
59
+ value = value.slice(1, -1);
60
+ }
61
+ out[name] = value;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * The NAMES in the file that carry a non-empty value, in file order — the Providers rows
68
+ * (an empty preset line is not a row). Names only; a missing/unreadable file = none.
69
+ * @param {string} [home]
70
+ * @returns {string[]}
71
+ */
72
+ export function filledKeyNames(home) {
73
+ try {
74
+ return Object.entries(parseKeysText(readFileSync(keysFilePath(home), 'utf8'))).filter(([, v]) => v !== '').map(([n]) => n);
75
+ } catch { return []; }
76
+ }
77
+
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
82
+ * @param {string} [home]
83
+ * @returns {boolean} true if it was created
84
+ */
85
+ export function ensureKeysFile(presetNames, home) {
86
+ const file = keysFilePath(home);
87
+ if (existsSync(file)) return false;
88
+ mkdirSync(keysHome(home), { recursive: true });
89
+ try {
90
+ writeFileSync(file, presetNames.map((n) => `${n}=\n`).join(''), { mode: 0o600, flag: 'wx' });
91
+ } catch (/** @type {any} */ e) {
92
+ if (e?.code === 'EEXIST') return false; // lost a race: the file is there, untouched
93
+ throw e;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * @typedef {object} KeysLoad
100
+ * @property {boolean} exists the file is there
101
+ * @property {string[]} names the NAMES the file defines (sorted; never values)
102
+ * @property {string|null} warning a plain-words warning (file readable by others, or
103
+ * unreadable), else null — a warning, never a refusal
104
+ * @property {Record<string,string|undefined>} env shell env with the file filling the
105
+ * names the shell does not set. THE ONLY place values leave this module.
106
+ */
107
+
108
+ /**
109
+ * Load the keys file over an env. Missing file is fine (no names, env unchanged).
110
+ * @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
111
+ * @returns {KeysLoad}
112
+ */
113
+ export function loadKeysEnv(opts = {}) {
114
+ const base = opts.env ?? process.env;
115
+ const file = keysFilePath(opts.home);
116
+ if (!existsSync(file)) return { exists: false, names: [], warning: null, env: { ...base } };
117
+ /** @type {string|null} */
118
+ let warning = null;
119
+ try {
120
+ const mode = statSync(file).mode & 0o777;
121
+ if ((mode & 0o077) !== 0) {
122
+ warning = `${file} is readable by other users (mode ${mode.toString(8)}) — run: chmod 600 ${file}`;
123
+ }
124
+ } catch { /* the read below reports it */ }
125
+ /** @type {Record<string,string>} */
126
+ let parsed = {};
127
+ try {
128
+ parsed = parseKeysText(readFileSync(file, 'utf8'));
129
+ } catch (/** @type {any} */ e) {
130
+ return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, env: { ...base } };
131
+ }
132
+ /** @type {Record<string,string|undefined>} */
133
+ const env = { ...base };
134
+ for (const [name, value] of Object.entries(parsed)) {
135
+ if (!env[name]) env[name] = value;
136
+ }
137
+ return { exists: true, names: Object.keys(parsed).sort(), warning, env };
138
+ }
139
+
140
+ /**
141
+ * The door helper the CLI and the panel call: load the file only when the caller is
142
+ * on the REAL environment (no injected env) or names a keys home explicitly — so a
143
+ * test that injects `env` alone never reads the real `~/.config/bareloop/.env`.
144
+ * @param {{ env?: Record<string,string|undefined>, keysHome?: string }} deps
145
+ * @returns {KeysLoad & { env: Record<string,string|undefined>, home: string|undefined }}
146
+ * `home` = the home the file was loaded from (the real `~/.config/bareloop` when none was
147
+ * injected) — or `undefined` when loading was skipped (injected env, no keysHome). The
148
+ * door passes it on so config.json (src/config.js) is read from the SAME place, and never
149
+ * from the real home in a test.
150
+ */
151
+ export function keysForDoor(deps) {
152
+ if (deps.env !== undefined && deps.keysHome === undefined) {
153
+ return { exists: false, names: [], warning: null, env: deps.env, home: undefined };
154
+ }
155
+ return { ...loadKeysEnv({ env: deps.env, home: deps.keysHome }), home: keysHome(deps.keysHome) };
156
+ }
package/src/ledger.js CHANGED
@@ -506,3 +506,29 @@ export function spendProvenance(events) {
506
506
  }
507
507
  return out;
508
508
  }
509
+
510
+ /**
511
+ * The priced-rounds spend floor + first-record→last-record wall floor,
512
+ * derived straight off the spine's raw records — the same derivation a
513
+ * died run's "at least …" figures use, factored out so build item 6
514
+ * (2026-09-28) can hand a genuinely-still-running spine the identical
515
+ * floor instead of "unknown" on the Run tab, without a second copy of this
516
+ * math.
517
+ * @param {any[]} records raw parsed spine records
518
+ * @returns {{spendFloorUsd: number|null, wallFloorMs: number|null}}
519
+ */
520
+ export function floorsFromRecords(records) {
521
+ let spendSum = 0;
522
+ let pricedCount = 0;
523
+ for (const r of records) {
524
+ if (!r || typeof r !== 'object' || !SPEND_RECORD_TYPES.includes(r.type)) continue; // worker-result echoes excluded by construction — not a spend type
525
+ if (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd)) { spendSum += r.costUsd; pricedCount += 1; }
526
+ }
527
+ const spendFloorUsd = pricedCount > 0 ? spendSum : null;
528
+
529
+ const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
530
+ const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
531
+ const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
532
+ const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
533
+ return { spendFloorUsd, wallFloorMs };
534
+ }