@polderlabs/bizar 10.17.1 → 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.
Files changed (59) hide show
  1. package/cli/bin.mjs +17 -6
  2. package/cli/commands/evidence-bundles.mjs +239 -0
  3. package/cli/commands/evidence.mjs +101 -0
  4. package/cli/commands/hook.mjs +1 -1
  5. package/cli/commands/improve-proposal.mjs +155 -0
  6. package/cli/commands/improve.mjs +385 -0
  7. package/cli/commands/learning-behavior.mjs +200 -0
  8. package/cli/commands/model.mjs +26 -8
  9. package/cli/commands/models.mjs +245 -26
  10. package/cli/commands/secure-dir.mjs +81 -0
  11. package/cli/commands/validate.mjs +12 -13
  12. package/cli/doctor.mjs +11 -8
  13. package/cli/provision.mjs +42 -16
  14. package/cli/worker-dispatcher.mjs +29 -0
  15. package/config/claude/agents/_shared/SKILLS.md +3 -1
  16. package/config/claude/agents/help-desk.md +1 -1
  17. package/config/claude/agents/principal-engineer.md +1 -1
  18. package/config/claude/agents/senior-engineer.md +1 -1
  19. package/config/claude/commands/tools.md +1 -1
  20. package/config/claude/commands/use-default.md +9 -8
  21. package/config/claude/commands/use-premium.md +12 -7
  22. package/config/claude/hooks/advisor-context.mjs +112 -30
  23. package/config/claude/hooks/git-workflow-guard.mjs +14 -0
  24. package/config/claude/hooks/worker-suggest.mjs +42 -7
  25. package/config/claude/model-router.json +3 -3
  26. package/config/claude/settings.json +5 -49
  27. package/config/trigger-patterns.json +285 -85
  28. package/package.json +1 -1
  29. package/packages/sdk/dist/autonomy/evidence-bundle.d.ts +117 -0
  30. package/packages/sdk/dist/autonomy/evidence-bundle.js +92 -0
  31. package/packages/sdk/dist/autonomy/index.d.ts +12 -0
  32. package/packages/sdk/dist/autonomy/index.js +12 -0
  33. package/packages/sdk/dist/autonomy/objective-run.d.ts +90 -0
  34. package/packages/sdk/dist/autonomy/objective-run.js +66 -0
  35. package/packages/sdk/dist/autonomy/outcome-record.d.ts +53 -0
  36. package/packages/sdk/dist/autonomy/outcome-record.js +54 -0
  37. package/packages/sdk/dist/index.d.ts +1 -0
  38. package/packages/sdk/dist/index.js +3 -0
  39. package/packages/sdk/dist/learning/behavior-capture.d.ts +105 -0
  40. package/packages/sdk/dist/learning/behavior-capture.js +195 -0
  41. package/packages/sdk/dist/learning/index.d.ts +1 -0
  42. package/packages/sdk/dist/learning/index.js +2 -0
  43. package/packages/sdk/dist/router/agent-model-registry.js +23 -3
  44. package/packages/sdk/dist/router/outcome-learner.js +13 -9
  45. package/packages/sdk/dist/version.d.ts +1 -1
  46. package/packages/sdk/dist/version.js +1 -1
  47. package/packages/sdk/package.json +1 -1
  48. package/scripts/git-hooks/pre-push +8 -2
  49. package/cli/commands/9router-picker-proxy.mjs +0 -100
  50. package/cli/commands/picker-proxy.mjs +0 -18
  51. package/config/claude/commands/picker.md +0 -20
  52. package/config/skills/9router/SKILL.md +0 -82
  53. package/config/skills/9router-chat/SKILL.md +0 -73
  54. package/config/skills/9router-embeddings/SKILL.md +0 -69
  55. package/config/skills/9router-image/SKILL.md +0 -86
  56. package/config/skills/9router-stt/SKILL.md +0 -79
  57. package/config/skills/9router-tts/SKILL.md +0 -80
  58. package/config/skills/9router-web-fetch/SKILL.md +0 -99
  59. 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
- case 'picker-proxy': {
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();
@@ -248,7 +248,7 @@ export function selectEventChain(eventKey, input = '') {
248
248
  if (eventKey === 'subagent-start') {
249
249
  return chain.filter((leaf) => {
250
250
  if (leaf === 'advisor-context') {
251
- return /^(linda|karen|carl|qa-reviewer|principal-engineer|debug-specialist)$/.test(agentType);
251
+ return /^(linda|carl)$/.test(agentType);
252
252
  }
253
253
  if (leaf === 'worktree-bootstrap') {
254
254
  return /^(brad|carl|pam|brenda|karen|todd|ria)$/.test(agentType);
@@ -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
+ }