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.
- package/CHANGELOG.md +151 -0
- package/README.md +4 -1
- package/bareloop.context.md +232 -85
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +5 -3
- package/src/authorjob.js +3 -1
- package/src/authorrun.js +49 -11
- package/src/authorscout.js +10 -6
- package/src/bundlerun.js +266 -0
- package/src/cli.js +35 -357
- package/src/config.js +135 -0
- package/src/interviewrun.js +13 -4
- package/src/invoke.js +22 -0
- package/src/judged.js +5 -3
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +422 -0
- package/src/panel/authorroutes.js +61 -16
- package/src/panel/authorsession.js +46 -28
- package/src/panel/index.html +287 -13
- package/src/panel/server.js +58 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +7 -3
- package/src/providerrows.js +301 -0
- package/src/providers.js +3 -1
- package/src/run.js +7 -2
- package/src/runlist.js +92 -7
- package/src/source.js +8 -3
- package/src/u-watchdog.mjs +296 -0
- package/src/userrun.js +230 -54
- package/types/authorflow.d.ts +8 -2
- package/types/authorjob.d.ts +6 -1
- package/types/authorrun.d.ts +5 -0
- package/types/authorscout.d.ts +11 -2
- package/types/bundlerun.d.ts +26 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/invoke.d.ts +8 -0
- package/types/judged.d.ts +5 -1
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +227 -0
- package/types/panel/authorroutes.d.ts +4 -2
- package/types/panel/authorsession.d.ts +7 -15
- package/types/panel/server.d.ts +10 -3
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/planrun.d.ts +13 -1
- package/types/providerrows.d.ts +229 -0
- package/types/run.d.ts +14 -1
- package/types/runlist.d.ts +66 -3
- package/types/source.d.ts +8 -3
- package/types/userrun.d.ts +54 -0
package/src/interviewrun.js
CHANGED
|
@@ -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
|
-
|
|
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 ?? '
|
|
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'}=...
|
|
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
|
-
|
|
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
|
-
*
|
|
795
|
-
|
|
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 ─────────────────────────────────────────────────────
|
package/src/keysfile.js
ADDED
|
@@ -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
|
+
}
|