@polderlabs/bizar 10.17.3 → 10.18.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/cli/bin.mjs +17 -6
- package/cli/commands/evidence-bundles.mjs +239 -0
- package/cli/commands/evidence.mjs +101 -0
- package/cli/commands/improve-proposal.mjs +155 -0
- package/cli/commands/improve.mjs +385 -0
- package/cli/commands/learning-behavior.mjs +200 -0
- package/cli/commands/model.mjs +26 -8
- package/cli/commands/models.mjs +66 -14
- package/cli/commands/secure-dir.mjs +81 -0
- package/cli/commands/validate.mjs +12 -13
- package/cli/doctor.mjs +11 -8
- package/cli/provision.mjs +42 -16
- package/cli/worker-dispatcher.mjs +29 -0
- package/config/claude/agents/_shared/SKILLS.md +3 -1
- package/config/claude/agents/help-desk.md +1 -1
- package/config/claude/agents/principal-engineer.md +1 -1
- package/config/claude/agents/senior-engineer.md +1 -1
- package/config/claude/commands/tools.md +1 -1
- package/config/claude/commands/use-default.md +9 -8
- package/config/claude/commands/use-premium.md +12 -7
- package/config/claude/hooks/git-workflow-guard.mjs +14 -0
- package/config/claude/hooks/worker-suggest.mjs +42 -7
- package/config/claude/model-router.json +3 -3
- package/config/claude/settings.json +5 -49
- package/config/trigger-patterns.json +285 -85
- package/package.json +1 -1
- package/packages/sdk/dist/autonomy/evidence-bundle.d.ts +117 -0
- package/packages/sdk/dist/autonomy/evidence-bundle.js +92 -0
- package/packages/sdk/dist/autonomy/index.d.ts +12 -0
- package/packages/sdk/dist/autonomy/index.js +12 -0
- package/packages/sdk/dist/autonomy/objective-run.d.ts +90 -0
- package/packages/sdk/dist/autonomy/objective-run.js +66 -0
- package/packages/sdk/dist/autonomy/outcome-record.d.ts +53 -0
- package/packages/sdk/dist/autonomy/outcome-record.js +54 -0
- package/packages/sdk/dist/index.d.ts +1 -0
- package/packages/sdk/dist/index.js +3 -0
- package/packages/sdk/dist/learning/behavior-capture.d.ts +105 -0
- package/packages/sdk/dist/learning/behavior-capture.js +195 -0
- package/packages/sdk/dist/learning/index.d.ts +1 -0
- package/packages/sdk/dist/learning/index.js +2 -0
- package/packages/sdk/dist/router/agent-model-registry.js +23 -3
- package/packages/sdk/dist/router/outcome-learner.js +13 -9
- package/packages/sdk/dist/version.d.ts +1 -1
- package/packages/sdk/dist/version.js +1 -1
- package/packages/sdk/package.json +1 -1
- package/scripts/git-hooks/pre-push +8 -2
- package/cli/commands/9router-picker-proxy.mjs +0 -100
- package/cli/commands/picker-proxy.mjs +0 -18
- package/config/claude/commands/picker.md +0 -20
- package/config/skills/9router/SKILL.md +0 -82
- package/config/skills/9router-chat/SKILL.md +0 -73
- package/config/skills/9router-embeddings/SKILL.md +0 -69
- package/config/skills/9router-image/SKILL.md +0 -86
- package/config/skills/9router-stt/SKILL.md +0 -79
- package/config/skills/9router-tts/SKILL.md +0 -80
- package/config/skills/9router-web-fetch/SKILL.md +0 -99
- package/config/skills/9router-web-search/SKILL.md +0 -91
package/cli/bin.mjs
CHANGED
|
@@ -117,7 +117,6 @@ function showHelp() {
|
|
|
117
117
|
claim <subcommand> GitHub-style claim protocol over feature_list.json
|
|
118
118
|
task <subcommand> Durable dependency/worktree/path task coordination
|
|
119
119
|
control <subcommand> Machine-readable agents/tasks/sessions/messages API
|
|
120
|
-
picker-proxy <start> Run the 9router picker proxy (default port 20129)
|
|
121
120
|
workflow <subcommand> Session-bound autopilot workflow state
|
|
122
121
|
hook <name> Run a portable Claude Code hook
|
|
123
122
|
worktree-merge <branch> Merge a feature branch with archive tag (no work lost)
|
|
@@ -448,6 +447,22 @@ async function main() {
|
|
|
448
447
|
break;
|
|
449
448
|
}
|
|
450
449
|
|
|
450
|
+
case 'improve': {
|
|
451
|
+
const mod = await importCommand('improve');
|
|
452
|
+
if (!mod) {
|
|
453
|
+
console.error(chalk.red(` ✗ Could not load improve command module`));
|
|
454
|
+
process.exit(EXIT_ERROR);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
dbg('loaded command module:', 'improve');
|
|
458
|
+
const found = await mod.run(cmd, cmdArgs, isHelpRequest);
|
|
459
|
+
if (found === false) {
|
|
460
|
+
console.error(chalk.red(` ✗ Usage: bizar improve <subcommand> — run 'bizar improve --help'`));
|
|
461
|
+
process.exit(EXIT_USAGE);
|
|
462
|
+
}
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
|
|
451
466
|
case 'model': {
|
|
452
467
|
// Deprecated alias. Routes to the original `model.mjs` so the
|
|
453
468
|
// legacy JSON shape (`{ providers: { ... }, total: N }`) and table
|
|
@@ -500,11 +515,7 @@ async function main() {
|
|
|
500
515
|
break;
|
|
501
516
|
}
|
|
502
517
|
|
|
503
|
-
|
|
504
|
-
await import('./commands/picker-proxy.mjs');
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
|
|
518
|
+
|
|
508
519
|
case 'worktree-merge': {
|
|
509
520
|
await import('./commands/worktree-merge.mjs');
|
|
510
521
|
return;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/commands/evidence-bundles.mjs — F-194 typed EvidenceBundle ledger.
|
|
3
|
+
*
|
|
4
|
+
* This is the durable on-disk store for typed `EvidenceBundle` records
|
|
5
|
+
* (the observation that fills in an `ObjectiveRun`). It lives next to
|
|
6
|
+
* the F-191 dispatch.jsonl ledger inside the same `~/.config/bizar/evidence/`
|
|
7
|
+
* directory but uses a distinct per-run filename:
|
|
8
|
+
*
|
|
9
|
+
* ~/.config/bizar/evidence/<objectiveRunId>.jsonl one EvidenceBundle per line
|
|
10
|
+
* ~/.config/bizar/evidence/signatures.bundle aggregate signature manifest
|
|
11
|
+
*
|
|
12
|
+
* Why a separate ledger file (and not the F-191 dispatch.jsonl):
|
|
13
|
+
* - F-191 is per-routing-decision (one row per dispatched model call).
|
|
14
|
+
* - F-194 is per-objective-run (typed observation after a real shell
|
|
15
|
+
* command ran, with command/cwd/exit/tests/revisions/sha256).
|
|
16
|
+
* - Same directory keeps mode=0o700 trivial; distinct filenames keep
|
|
17
|
+
* the two writers from racing on the same append handle.
|
|
18
|
+
*
|
|
19
|
+
* Why mode=0o700:
|
|
20
|
+
* - The bundle contents reveal operator-side git revisions,
|
|
21
|
+
* evaluator/rubric hashes, exit codes, token usage, and (via the
|
|
22
|
+
* eval digest) potentially sensitive environment fingerprints.
|
|
23
|
+
* - Only the operator should read them.
|
|
24
|
+
*
|
|
25
|
+
* Invariants:
|
|
26
|
+
* - `evidenceDir` is created lazily with mode `0o700`.
|
|
27
|
+
* - Each `appendBundle` call appends exactly one JSON line to the
|
|
28
|
+
* run's JSONL file and updates `signatures.bundle`.
|
|
29
|
+
* - `verifyBundles` MUST return `{ ok: true }` for any ledger produced
|
|
30
|
+
* by this module + the matching secret, and `{ ok: false, reason }`
|
|
31
|
+
* for any tampered row or missing manifest entry.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import {
|
|
36
|
+
existsSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
readdirSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
appendFileSync,
|
|
41
|
+
} from 'node:fs';
|
|
42
|
+
import { join } from 'node:path';
|
|
43
|
+
import { verifyBundleSignature } from '../../packages/sdk/dist/autonomy/evidence-bundle.js';
|
|
44
|
+
import {
|
|
45
|
+
ensureSecureDir,
|
|
46
|
+
resolveSecureSubdir,
|
|
47
|
+
SECURE_DIR_MODE,
|
|
48
|
+
} from './secure-dir.mjs';
|
|
49
|
+
|
|
50
|
+
/** Mode applied to the evidence directory and to every JSONL row file. */
|
|
51
|
+
export const EVIDENCE_DIR_MODE = SECURE_DIR_MODE;
|
|
52
|
+
|
|
53
|
+
/** Name of the per-run JSONL file when not provided. */
|
|
54
|
+
export const SIGNATURES_BUNDLE = 'signatures.bundle';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the evidence directory with the precedence:
|
|
58
|
+
* 1. `BIZAR_EVIDENCE_DIR` env (absolute or cwd-relative).
|
|
59
|
+
* 2. `BIZAR_HOME/evidence` (BIZAR_HOME resolved the same way as provision.mjs).
|
|
60
|
+
* 3. `~/.config/bizar/evidence` (XDG fallback).
|
|
61
|
+
*/
|
|
62
|
+
export function resolveEvidenceDir({ cwd = process.cwd(), env = process.env } = {}) {
|
|
63
|
+
return resolveSecureSubdir({
|
|
64
|
+
cwd, env,
|
|
65
|
+
envOverride: 'BIZAR_EVIDENCE_DIR',
|
|
66
|
+
envSubdir: 'BIZAR_HOME',
|
|
67
|
+
subdir: 'evidence',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Create the evidence dir if missing. Idempotent. Returns the path. */
|
|
72
|
+
export function ensureEvidenceDir({ cwd = process.cwd(), env = process.env } = {}) {
|
|
73
|
+
return ensureSecureDir({
|
|
74
|
+
cwd, env,
|
|
75
|
+
envOverride: 'BIZAR_EVIDENCE_DIR',
|
|
76
|
+
envSubdir: 'BIZAR_HOME',
|
|
77
|
+
subdir: 'evidence',
|
|
78
|
+
mode: EVIDENCE_DIR_MODE,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Path to a single run's JSONL file. */
|
|
83
|
+
export function bundleJsonlPath({ objectiveRunId, evidenceDir }) {
|
|
84
|
+
if (typeof objectiveRunId !== 'string' || objectiveRunId.length === 0) {
|
|
85
|
+
throw new TypeError('bundleJsonlPath: objectiveRunId must be a non-empty string');
|
|
86
|
+
}
|
|
87
|
+
// Sanitize: reject path separators + traversal.
|
|
88
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(objectiveRunId)) {
|
|
89
|
+
throw new TypeError(`bundleJsonlPath: objectiveRunId has unsafe characters: ${objectiveRunId}`);
|
|
90
|
+
}
|
|
91
|
+
return join(evidenceDir, `${objectiveRunId}.jsonl`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Path to the signatures.bundle aggregate file. */
|
|
95
|
+
export function signaturesBundlePath({ evidenceDir }) {
|
|
96
|
+
return join(evidenceDir, SIGNATURES_BUNDLE);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Append one signed EvidenceBundle to its run's JSONL file and
|
|
101
|
+
* record its signature into signatures.bundle.
|
|
102
|
+
*
|
|
103
|
+
* Returns `{ ok: true, path, signature, recordedAt }`.
|
|
104
|
+
*
|
|
105
|
+
* Throws if `verifyBundleSignature(bundle, secret)` is false.
|
|
106
|
+
*/
|
|
107
|
+
export function appendBundle({ bundle, secret, evidenceDir }) {
|
|
108
|
+
if (!bundle || typeof bundle !== 'object') {
|
|
109
|
+
throw new TypeError('appendBundle: bundle must be an object');
|
|
110
|
+
}
|
|
111
|
+
if (typeof secret !== 'string' || secret.length === 0) {
|
|
112
|
+
throw new TypeError('appendBundle: secret must be a non-empty string');
|
|
113
|
+
}
|
|
114
|
+
// Validate objectiveRunId first so path-traversal can't sneak a row
|
|
115
|
+
// past the signature gate via a different error message.
|
|
116
|
+
bundleJsonlPath({ objectiveRunId: bundle.objectiveRunId, evidenceDir: evidenceDir ?? resolveEvidenceDir() });
|
|
117
|
+
const dir = ensureEvidenceDir({ env: { ...process.env, BIZAR_EVIDENCE_DIR: evidenceDir } });
|
|
118
|
+
if (!verifyBundleSignature(bundle, secret)) {
|
|
119
|
+
throw new Error('appendBundle: bundle signature failed verification (wrong secret or tampered row)');
|
|
120
|
+
}
|
|
121
|
+
const path = bundleJsonlPath({ objectiveRunId: bundle.objectiveRunId, evidenceDir: dir });
|
|
122
|
+
const line = JSON.stringify(bundle) + '\n';
|
|
123
|
+
appendFileSync(path, line, { mode: EVIDENCE_DIR_MODE });
|
|
124
|
+
const recordedAt = new Date().toISOString();
|
|
125
|
+
const sigPath = signaturesBundlePath({ evidenceDir: dir });
|
|
126
|
+
let sigs = {};
|
|
127
|
+
if (existsSync(sigPath)) {
|
|
128
|
+
try { sigs = JSON.parse(readFileSync(sigPath, 'utf8')); } catch { sigs = {}; }
|
|
129
|
+
}
|
|
130
|
+
const runSigs = Array.isArray(sigs[bundle.objectiveRunId]) ? sigs[bundle.objectiveRunId] : [];
|
|
131
|
+
runSigs.push({
|
|
132
|
+
bundleId: bundle.bundleId,
|
|
133
|
+
signature: bundle.signature,
|
|
134
|
+
recordedAt,
|
|
135
|
+
sha256BundleLine: createHash('sha256').update(line).digest('hex'),
|
|
136
|
+
});
|
|
137
|
+
sigs[bundle.objectiveRunId] = runSigs;
|
|
138
|
+
writeFileSync(sigPath, JSON.stringify(sigs, null, 2) + '\n', { mode: EVIDENCE_DIR_MODE });
|
|
139
|
+
return { ok: true, path, signature: bundle.signature, recordedAt };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* List every per-run JSONL in the evidence dir, with row counts and
|
|
144
|
+
* last-appended timestamp. Excludes `signatures.bundle` itself.
|
|
145
|
+
*/
|
|
146
|
+
export function listBundles(opts = {}) {
|
|
147
|
+
const dir = opts.evidenceDir ?? resolveEvidenceDir();
|
|
148
|
+
if (!existsSync(dir)) return [];
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
151
|
+
if (!entry.isFile()) continue;
|
|
152
|
+
if (!entry.name.endsWith('.jsonl')) continue;
|
|
153
|
+
const path = join(dir, entry.name);
|
|
154
|
+
const objectiveRunId = entry.name.slice(0, -'.jsonl'.length);
|
|
155
|
+
const raw = readFileSync(path, 'utf8');
|
|
156
|
+
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
|
|
157
|
+
let lastAppendedAt = null;
|
|
158
|
+
if (lines.length > 0) {
|
|
159
|
+
try {
|
|
160
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
161
|
+
lastAppendedAt = last.createdAt ?? null;
|
|
162
|
+
} catch { /* ignore */ }
|
|
163
|
+
}
|
|
164
|
+
out.push({
|
|
165
|
+
objectiveRunId,
|
|
166
|
+
path,
|
|
167
|
+
rowCount: lines.length,
|
|
168
|
+
lastAppendedAt,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
out.sort((a, b) => a.objectiveRunId.localeCompare(b.objectiveRunId));
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Re-verify every signed bundle in every run's JSONL file, then
|
|
177
|
+
* cross-check the signatures.bundle manifest. Returns:
|
|
178
|
+
* - `{ ok: true, verifiedRuns, totalRows }` on success.
|
|
179
|
+
* - `{ ok: false, reason, runId?, bundleId? }` on any mismatch.
|
|
180
|
+
*
|
|
181
|
+
* `reason` is a stable machine-readable string for scripting.
|
|
182
|
+
*/
|
|
183
|
+
export function verifyBundles(opts = {}) {
|
|
184
|
+
const { secret, evidenceDir } = opts;
|
|
185
|
+
if (typeof secret !== 'string' || secret.length === 0) {
|
|
186
|
+
throw new TypeError('verifyBundles: secret must be a non-empty string');
|
|
187
|
+
}
|
|
188
|
+
const dir = evidenceDir ?? resolveEvidenceDir();
|
|
189
|
+
if (!existsSync(dir)) {
|
|
190
|
+
return { ok: false, reason: 'evidence-dir-missing' };
|
|
191
|
+
}
|
|
192
|
+
const entries = listBundles({ evidenceDir: dir });
|
|
193
|
+
let totalRows = 0;
|
|
194
|
+
for (const { objectiveRunId, path } of entries) {
|
|
195
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter((l) => l.trim().length > 0);
|
|
196
|
+
for (const line of lines) {
|
|
197
|
+
totalRows += 1;
|
|
198
|
+
let row;
|
|
199
|
+
try { row = JSON.parse(line); } catch {
|
|
200
|
+
return { ok: false, reason: 'malformed-jsonl-line', runId: objectiveRunId };
|
|
201
|
+
}
|
|
202
|
+
if (!verifyBundleSignature(row, secret)) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
reason: 'bundle-signature-mismatch',
|
|
206
|
+
runId: objectiveRunId,
|
|
207
|
+
bundleId: row.bundleId,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Cross-check the aggregate manifest if present.
|
|
213
|
+
const sigPath = signaturesBundlePath({ evidenceDir: dir });
|
|
214
|
+
if (existsSync(sigPath)) {
|
|
215
|
+
let manifest;
|
|
216
|
+
try { manifest = JSON.parse(readFileSync(sigPath, 'utf8')); }
|
|
217
|
+
catch { return { ok: false, reason: 'signatures-bundle-malformed' }; }
|
|
218
|
+
for (const [runId, sigs] of Object.entries(manifest)) {
|
|
219
|
+
if (!Array.isArray(sigs)) {
|
|
220
|
+
return { ok: false, reason: 'signatures-bundle-shape', runId };
|
|
221
|
+
}
|
|
222
|
+
const runFile = join(dir, `${runId}.jsonl`);
|
|
223
|
+
if (!existsSync(runFile)) {
|
|
224
|
+
return { ok: false, reason: 'signatures-bundle-orphan', runId };
|
|
225
|
+
}
|
|
226
|
+
const lines = readFileSync(runFile, 'utf8').split('\n').filter((l) => l.trim().length > 0);
|
|
227
|
+
if (sigs.length !== lines.length) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
reason: 'signatures-bundle-count-mismatch',
|
|
231
|
+
runId,
|
|
232
|
+
manifestCount: sigs.length,
|
|
233
|
+
jsonlCount: lines.length,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { ok: true, verifiedRuns: entries.length, totalRows };
|
|
239
|
+
}
|
|
@@ -316,6 +316,98 @@ function handleAudit(args) {
|
|
|
316
316
|
return report.missingOutcome.length === 0 && report.mismatched.length === 0 ? 0 : 1;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
// ── F-194 typed EvidenceBundle subcommands ───────────────────────────────────
|
|
320
|
+
//
|
|
321
|
+
// `bizar evidence append --file <bundle.json>` append a signed bundle to its run JSONL
|
|
322
|
+
// `bizar evidence list` list every per-run JSONL with row counts
|
|
323
|
+
// `bizar evidence verify-bundles` re-verify every signed bundle + manifest
|
|
324
|
+
//
|
|
325
|
+
// These commands consume the typed EvidenceBundle ledger kept under
|
|
326
|
+
// `~/.config/bizar/evidence/<objectiveRunId>.jsonl` + `signatures.bundle`.
|
|
327
|
+
// They are deliberately distinct from the F-191 subcommands above so the
|
|
328
|
+
// two ledgers do not race on the same append handle.
|
|
329
|
+
|
|
330
|
+
import {
|
|
331
|
+
appendBundle,
|
|
332
|
+
listBundles,
|
|
333
|
+
verifyBundles,
|
|
334
|
+
} from './evidence-bundles.mjs';
|
|
335
|
+
|
|
336
|
+
function readSecret() {
|
|
337
|
+
return process.env.BIZAR_EVIDENCE_SECRET
|
|
338
|
+
|| process.env.BIZAR_AUTONOMY_SECRET
|
|
339
|
+
|| '';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function handleAppend(args) {
|
|
343
|
+
const fileArg = args.find((a) => a.startsWith('--file='))?.slice('--file='.length);
|
|
344
|
+
const fileFlag = args.includes('--file');
|
|
345
|
+
const fileIdx = fileFlag ? args.indexOf('--file') + 1 : -1;
|
|
346
|
+
const filePath = fileArg || (fileIdx > 0 ? args[fileIdx] : null);
|
|
347
|
+
if (!filePath) {
|
|
348
|
+
console.error(chalk.red(' ✗ bizar evidence append requires --file <path-to-bundle.json>'));
|
|
349
|
+
return 2;
|
|
350
|
+
}
|
|
351
|
+
const secret = readSecret();
|
|
352
|
+
if (!secret) {
|
|
353
|
+
console.error(chalk.red(' ✗ set BIZAR_EVIDENCE_SECRET (or BIZAR_AUTONOMY_SECRET) before appending bundles'));
|
|
354
|
+
return 2;
|
|
355
|
+
}
|
|
356
|
+
let bundle;
|
|
357
|
+
try {
|
|
358
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
359
|
+
bundle = JSON.parse(raw);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error(chalk.red(` ✗ could not parse ${filePath}: ${err.message}`));
|
|
362
|
+
return 2;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const result = appendBundle({ bundle, secret });
|
|
366
|
+
console.log(chalk.green(` ✓ appended bundleId=${bundle.bundleId} → ${result.path}`));
|
|
367
|
+
return 0;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.error(chalk.red(` ✗ append failed: ${err.message}`));
|
|
370
|
+
return 1;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function handleList(args) {
|
|
375
|
+
const wantJson = args.includes('--json');
|
|
376
|
+
const rows = listBundles();
|
|
377
|
+
if (wantJson) {
|
|
378
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
if (rows.length === 0) {
|
|
382
|
+
console.log(chalk.yellow(' ! no per-run evidence JSONL files yet'));
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
for (const r of rows) {
|
|
386
|
+
console.log(`${chalk.cyan(r.objectiveRunId)} rows=${r.rowCount} last=${r.lastAppendedAt ?? '(unknown)'}`);
|
|
387
|
+
}
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function handleVerifyBundles(args) {
|
|
392
|
+
const wantJson = args.includes('--json');
|
|
393
|
+
const secret = readSecret();
|
|
394
|
+
if (!secret) {
|
|
395
|
+
console.error(chalk.red(' ✗ set BIZAR_EVIDENCE_SECRET (or BIZAR_AUTONOMY_SECRET) before verifying'));
|
|
396
|
+
return 2;
|
|
397
|
+
}
|
|
398
|
+
const result = verifyBundles({ secret });
|
|
399
|
+
if (wantJson) {
|
|
400
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
401
|
+
return result.ok ? 0 : 1;
|
|
402
|
+
}
|
|
403
|
+
if (!result.ok) {
|
|
404
|
+
console.error(chalk.red(` ✗ verify-bundles failed: ${result.reason}${result.runId ? ` (run=${result.runId})` : ''}`));
|
|
405
|
+
return 1;
|
|
406
|
+
}
|
|
407
|
+
console.log(chalk.green(` ✓ ${result.verifiedRuns} run(s), ${result.totalRows} bundle(s) verified`));
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
319
411
|
// ── run() entrypoint ─────────────────────────────────────────────────────────
|
|
320
412
|
|
|
321
413
|
export async function run(name, args, isHelpRequest) {
|
|
@@ -347,6 +439,15 @@ export async function run(name, args, isHelpRequest) {
|
|
|
347
439
|
case 'audit':
|
|
348
440
|
process.exit(handleAudit(rest));
|
|
349
441
|
return true;
|
|
442
|
+
case 'append':
|
|
443
|
+
process.exit(handleAppend(rest));
|
|
444
|
+
return true;
|
|
445
|
+
case 'list':
|
|
446
|
+
process.exit(handleList(rest));
|
|
447
|
+
return true;
|
|
448
|
+
case 'verify-bundles':
|
|
449
|
+
process.exit(handleVerifyBundles(rest));
|
|
450
|
+
return true;
|
|
350
451
|
default:
|
|
351
452
|
console.error(chalk.red(` ✗ unknown evidence subcommand: ${sub}`));
|
|
352
453
|
showHelp();
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/commands/improve-proposal.mjs — F-194 Phase C bounded self-edit
|
|
3
|
+
* proposal schema.
|
|
4
|
+
*
|
|
5
|
+
* A Proposal captures one shippable tweak Bizar can apply to itself
|
|
6
|
+
* (or to a config file the operator owns) with a verifiable audit trail.
|
|
7
|
+
*
|
|
8
|
+
* Why find/replace instead of a unified diff:
|
|
9
|
+
* - Unified diffs have format ambiguity (hunk counts, trailing whitespace,
|
|
10
|
+
* line-ending preservation). The hunk applies or doesn't.
|
|
11
|
+
* - `find` is the verbatim byte-substring that must exist in the file
|
|
12
|
+
* exactly once at apply time. If it appears zero times or more than
|
|
13
|
+
* once, the apply MUST refuse — silent multi-replace is the kind of
|
|
14
|
+
* regression that turns self-improvement into self-corruption.
|
|
15
|
+
* - Rollback is a one-line `replace(find, newText)` call: `find` was
|
|
16
|
+
* the pre-state, `newText` was the post-state, so reversing is the
|
|
17
|
+
* same find+replace in the opposite direction.
|
|
18
|
+
*
|
|
19
|
+
* `id` is server-stamped at `propose` time so two proposals never share
|
|
20
|
+
* an identifier. `createdAt` is server-stamped too, so a replayed proposal
|
|
21
|
+
* carries its origin timestamp.
|
|
22
|
+
*
|
|
23
|
+
* Mode:
|
|
24
|
+
* - `dryRun: true` → `bizar improve run` shows the diff + verification
|
|
25
|
+
* plan + asks for confirmation, but does NOT mutate the target.
|
|
26
|
+
* - `dryRun: false` → applies the change, runs the verification command,
|
|
27
|
+
* appends an EvidenceBundle row on success. On verification failure
|
|
28
|
+
* the apply is rolled back and a separate EvidenceBundle row records
|
|
29
|
+
* the failure.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
|
|
34
|
+
/** @typedef {{
|
|
35
|
+
* id: string,
|
|
36
|
+
* targetFile: string,
|
|
37
|
+
* originalSha256: string,
|
|
38
|
+
* find: string,
|
|
39
|
+
* newText: string,
|
|
40
|
+
* verification: { command: string, cwd?: string, timeoutMs?: number, expectedExitCode?: number },
|
|
41
|
+
* rollbackPlan: { kind: 'replace-back', note: string } | { kind: 'manual', note: string, manualCommand?: string },
|
|
42
|
+
* reason: string,
|
|
43
|
+
* createdAt: string,
|
|
44
|
+
* dryRun?: boolean,
|
|
45
|
+
* }} Proposal
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** Frozen FORBIDDEN proposal keys — anything that smells like prompt or
|
|
49
|
+
* raw bytes-of-the-target leaks operator content. Mirrors the F-194
|
|
50
|
+
* BehaviorRecord policy so a regression cannot start persisting
|
|
51
|
+
* decision-context to disk. */
|
|
52
|
+
export const FORBIDDEN_PROPOSAL_KEYS = ['prompt', 'promptRedacted', 'rawPrompt', 'promptText', 'userInput', 'rawInput', 'rawInputBytes'];
|
|
53
|
+
|
|
54
|
+
const PROPOSAL_KEYS = ['id', 'targetFile', 'originalSha256', 'find', 'newText', 'verification', 'rollbackPlan', 'reason', 'createdAt', 'dryRun'];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @returns {string} sha256 hex of `text`
|
|
59
|
+
*/
|
|
60
|
+
export function sha256Text(text) {
|
|
61
|
+
return createHash('sha256').update(Buffer.from(text, 'utf8')).digest('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} targetFile
|
|
66
|
+
* @param {string} cwd
|
|
67
|
+
* @returns {string} unique proposal id for this apply window
|
|
68
|
+
*/
|
|
69
|
+
export function newProposalId({ targetFile, cwd }) {
|
|
70
|
+
const seed = `${Date.now()}|${process.pid}|${targetFile}|${cwd}`;
|
|
71
|
+
return `imp-${sha256Text(seed).slice(0, 12)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {object} raw
|
|
76
|
+
* @returns {Proposal}
|
|
77
|
+
*/
|
|
78
|
+
export function validateProposal(raw) {
|
|
79
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
80
|
+
throw new TypeError('Proposal must be an object');
|
|
81
|
+
}
|
|
82
|
+
for (const k of Object.keys(raw)) {
|
|
83
|
+
if (FORBIDDEN_PROPOSAL_KEYS.includes(k)) {
|
|
84
|
+
throw new TypeError(`Proposal must not contain forbidden key: ${k}`);
|
|
85
|
+
}
|
|
86
|
+
if (!PROPOSAL_KEYS.includes(k)) {
|
|
87
|
+
throw new TypeError(`Proposal has unknown key: ${k}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const required of ['targetFile', 'originalSha256', 'find', 'newText', 'verification', 'rollbackPlan', 'reason']) {
|
|
91
|
+
if (typeof raw[required] !== 'string' && typeof raw[required] !== 'object') {
|
|
92
|
+
throw new TypeError(`Proposal.${required} is required and must be string or object`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (raw.find.length === 0) {
|
|
96
|
+
throw new TypeError('Proposal.find must be a non-empty substring');
|
|
97
|
+
}
|
|
98
|
+
if (raw.find === raw.newText) {
|
|
99
|
+
throw new TypeError('Proposal.newText must differ from Proposal.find');
|
|
100
|
+
}
|
|
101
|
+
if (typeof raw.verification !== 'object' || typeof raw.verification.command !== 'string' || raw.verification.command.length === 0) {
|
|
102
|
+
throw new TypeError('Proposal.verification.command must be a non-empty string');
|
|
103
|
+
}
|
|
104
|
+
if (typeof raw.rollbackPlan !== 'object' || (raw.rollbackPlan.kind !== 'replace-back' && raw.rollbackPlan.kind !== 'manual')) {
|
|
105
|
+
throw new TypeError('Proposal.rollbackPlan.kind must be "replace-back" or "manual"');
|
|
106
|
+
}
|
|
107
|
+
return /** @type {Proposal} */ (raw);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Apply a Proposal to the target file's current bytes. Refuses if:
|
|
112
|
+
* - `originalSha256` does not match the file's current sha256 (file changed since propose)
|
|
113
|
+
* - `find` does not appear exactly once in the file
|
|
114
|
+
*
|
|
115
|
+
* @param {Proposal} proposal
|
|
116
|
+
* @param {string} currentFileBytes UTF-8 text of the target file as it stands now
|
|
117
|
+
* @returns {{ ok: true, newBytes: string, originalBytes: string } | { ok: false, reason: string }}
|
|
118
|
+
*/
|
|
119
|
+
export function planApply(proposal, currentFileBytes) {
|
|
120
|
+
const currentSha = sha256Text(currentFileBytes);
|
|
121
|
+
if (currentSha !== proposal.originalSha256) {
|
|
122
|
+
return { ok: false, reason: `target sha256 drift: expected ${proposal.originalSha256}, got ${currentSha}` };
|
|
123
|
+
}
|
|
124
|
+
let count = 0;
|
|
125
|
+
let idx = 0;
|
|
126
|
+
while ((idx = currentFileBytes.indexOf(proposal.find, idx)) !== -1) {
|
|
127
|
+
count += 1;
|
|
128
|
+
idx += proposal.find.length;
|
|
129
|
+
}
|
|
130
|
+
if (count !== 1) {
|
|
131
|
+
return { ok: false, reason: `Proposal.find matched ${count} times in target; expected exactly 1` };
|
|
132
|
+
}
|
|
133
|
+
const newBytes = currentFileBytes.replace(proposal.find, proposal.newText);
|
|
134
|
+
const newSha = sha256Text(newBytes);
|
|
135
|
+
if (newSha === currentSha) {
|
|
136
|
+
return { ok: false, reason: 'Proposal.apply produced no change (find == newText after canonicalization)' };
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, newBytes, originalBytes: currentFileBytes };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Compute the rollback bytes for a Proposal. For `replace-back` this is
|
|
143
|
+
* the original bytes (already captured by planApply). For `manual`, the
|
|
144
|
+
* caller is responsible for executing `manualCommand`.
|
|
145
|
+
*
|
|
146
|
+
* @param {Proposal} proposal
|
|
147
|
+
* @param {string} currentBytes post-apply file bytes
|
|
148
|
+
* @returns {{ kind: 'replace-back', newBytes: string } | { kind: 'manual', manualCommand?: string }}
|
|
149
|
+
*/
|
|
150
|
+
export function planRollback(proposal, currentBytes) {
|
|
151
|
+
if (proposal.rollbackPlan.kind === 'replace-back') {
|
|
152
|
+
return { kind: 'replace-back', newBytes: currentBytes.replace(proposal.newText, proposal.find) };
|
|
153
|
+
}
|
|
154
|
+
return { kind: 'manual', manualCommand: proposal.rollbackPlan.manualCommand };
|
|
155
|
+
}
|