@polderlabs/bizar-omp 0.7.7 → 0.7.9

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 (73) hide show
  1. package/README.md +15 -11
  2. package/dist/cli/agents-ink.d.ts +9 -0
  3. package/dist/cli/agents-ink.d.ts.map +1 -1
  4. package/dist/cli/agents-ink.js +57 -15
  5. package/dist/cli/agents-ink.js.map +1 -1
  6. package/dist/cli/agents.d.ts +1 -1
  7. package/dist/cli/agents.d.ts.map +1 -1
  8. package/dist/cli/agents.js +23 -4
  9. package/dist/cli/agents.js.map +1 -1
  10. package/dist/cli/doctor.d.ts +4 -4
  11. package/dist/cli/omb-update.d.ts.map +1 -1
  12. package/dist/cli/omb-update.js +17 -2
  13. package/dist/cli/omb-update.js.map +1 -1
  14. package/dist/core/acceptance.d.ts.map +1 -1
  15. package/dist/core/acceptance.js +22 -2
  16. package/dist/core/acceptance.js.map +1 -1
  17. package/dist/core/transitions.d.ts.map +1 -1
  18. package/dist/core/transitions.js +17 -1
  19. package/dist/core/transitions.js.map +1 -1
  20. package/dist/core/types.d.ts +13 -0
  21. package/dist/core/types.d.ts.map +1 -1
  22. package/dist/core/types.js +15 -1
  23. package/dist/core/types.js.map +1 -1
  24. package/dist/extension.d.ts +2 -0
  25. package/dist/extension.d.ts.map +1 -1
  26. package/dist/extension.js +220 -79
  27. package/dist/extension.js.map +1 -1
  28. package/dist/omp/compatibility.d.ts +5 -5
  29. package/dist/omp/compatibility.d.ts.map +1 -1
  30. package/dist/omp/compatibility.js +2 -2
  31. package/dist/omp/compatibility.js.map +1 -1
  32. package/dist/omp/daemon-service.d.ts +2 -0
  33. package/dist/omp/daemon-service.d.ts.map +1 -1
  34. package/dist/omp/daemon-service.js +38 -7
  35. package/dist/omp/daemon-service.js.map +1 -1
  36. package/dist/omp/lifecycle.d.ts +1 -1
  37. package/dist/omp/lifecycle.d.ts.map +1 -1
  38. package/dist/omp/lifecycle.js +2 -1
  39. package/dist/omp/lifecycle.js.map +1 -1
  40. package/dist/omp/omb-sessions.d.ts +54 -5
  41. package/dist/omp/omb-sessions.d.ts.map +1 -1
  42. package/dist/omp/omb-sessions.js +167 -22
  43. package/dist/omp/omb-sessions.js.map +1 -1
  44. package/dist/omp/omb-settings.d.ts +9 -0
  45. package/dist/omp/omb-settings.d.ts.map +1 -1
  46. package/dist/omp/omb-settings.js +7 -0
  47. package/dist/omp/omb-settings.js.map +1 -1
  48. package/dist/omp/terminal-title.d.ts +70 -0
  49. package/dist/omp/terminal-title.d.ts.map +1 -0
  50. package/dist/omp/terminal-title.js +123 -0
  51. package/dist/omp/terminal-title.js.map +1 -0
  52. package/dist/omp/tmux.d.ts +69 -0
  53. package/dist/omp/tmux.d.ts.map +1 -1
  54. package/dist/omp/tmux.js +193 -14
  55. package/dist/omp/tmux.js.map +1 -1
  56. package/dist/storage/journal.d.ts +1 -1
  57. package/dist/storage/journal.d.ts.map +1 -1
  58. package/dist/storage/journal.js +2 -1
  59. package/dist/storage/journal.js.map +1 -1
  60. package/dist/tools/workflow.d.ts.map +1 -1
  61. package/dist/tools/workflow.js +11 -1
  62. package/dist/tools/workflow.js.map +1 -1
  63. package/docs/compatibility/baseline.json +8 -7
  64. package/docs/compatibility/supported-surfaces.json +2 -1
  65. package/docs/development/ci-release-implementation-guide.md +3 -3
  66. package/docs/releases/0.7.8.md +21 -0
  67. package/docs/releases/0.7.9.md +18 -0
  68. package/docs/releases/npm-publishing.md +12 -12
  69. package/docs/releases/support-matrix.md +1 -1
  70. package/package.json +1 -1
  71. package/skills/omp-native-development/SKILL.md +1 -1
  72. package/skills/omp-native-development/references/accuracy-and-versioning.md +1 -1
  73. package/skills/omp-native-development/references/source-manifest.json +3 -3
package/dist/extension.js CHANGED
@@ -20,10 +20,14 @@ import { repositoryIdentity, statePath, workingTreeDigest } from "./storage/repo
20
20
  import { Journal } from "./storage/journal.js";
21
21
  import { nativeSessionReference } from "./omp/context.js";
22
22
  import { findOnPath } from "./omp/tmux.js";
23
- import { acquireOmpSessionLease, indexOmbSessionFile, isOmbSessionName, sameOmpSessionFile } from "./omp/omb-sessions.js";
23
+ import { acquireOmpSessionLease, clearOmbSessionReference, indexOmbSessionFile, isOmbSessionName, readOmbJournalNativeId, sameOmpSessionFile } from "./omp/omb-sessions.js";
24
+ import { clearOmbHostGeneration, rebindOmbHostGeneration } from "./omp/tmux.js";
25
+ import { createSessionTitleController, resolveSessionTitleLabel } from "./omp/terminal-title.js";
24
26
  import { bindSession } from "./omp/lifecycle.js";
25
27
  import { BIZAR_MODEL_ROLE_AGENT_PROMPT, BIZAR_MODEL_ROLE_TOOL, BIZAR_MODEL_ROLES, BIZAR_MODEL_TAGS, buildAgentEvaluationRequest, clipOperatorNotice, commitRolePlan, diffCatalog, formatCatalogDelta, formatRoleAssignmentPlan, planBizarModelRoles, planFromRecommendations, recordOf, reviewRoleRecommendations, roleAssignmentRoute, snapshotCatalog, isOpaqueOmniRouteCombo, omniRouteFailureGuidance, } from "./omp/model-role-assignment.js";
28
+ import { AGENT_ROLES } from "./omp/model-roles.js";
26
29
  import { loadOmbSettings, recordCatalogObservation } from "./omp/omb-settings.js";
30
+ import { isRunPhaseActive } from "./core/types.js";
27
31
  const objectiveSchema = (z) => z.object({
28
32
  objective: z.string(),
29
33
  nonGoals: z.array(z.string()),
@@ -81,6 +85,10 @@ function objectiveOf(value) {
81
85
  checkSpecs: raw.checkSpecs,
82
86
  reviewSpec: raw.reviewSpec,
83
87
  policyRevision: raw.policyRevision,
88
+ // `integrate()` is fail-closed without an admitted scope, so dropping this
89
+ // here made controlled integration unreachable through the public tool even
90
+ // when the caller supplied one.
91
+ integrationScope: raw.integrationScope,
84
92
  authorizationRef: raw.authorizationRef,
85
93
  revision: raw.revision,
86
94
  });
@@ -97,6 +105,14 @@ function bindingFor(ctx) {
97
105
  }
98
106
  return bindSession(new Journal(path), ctx);
99
107
  }
108
+ /** Readable tab label for the current session; see `resolveSessionTitleLabel`. */
109
+ function sessionTitleLabel(ctx) {
110
+ return resolveSessionTitleLabel({
111
+ sessionName: ctx.sessionManager?.getSessionName?.(),
112
+ ombSessionName: process.env.BIZAR_OMP_SESSION_NAME,
113
+ cwd: ctx.cwd ?? process.cwd(),
114
+ });
115
+ }
100
116
  function bindingMatches(binding, ctx) {
101
117
  const cwd = ctx.cwd ?? process.cwd();
102
118
  const owner = nativeSessionReference(ctx) ?? `session:${cwd}`;
@@ -416,34 +432,39 @@ export default function bizarExtension(pi) {
416
432
  let binding;
417
433
  // Main orchestrator role is opt-in: task children must NEVER see it.
418
434
  let orchestratorActive = false;
435
+ // The tab title is owned here because an extension override is rendered
436
+ // verbatim: OMP's own run-state spinner would never animate it. A Bizar host
437
+ // would otherwise show the bare binary name in every tab, with nothing to
438
+ // tell a working session from an idle one.
439
+ const sessionTitle = createSessionTitleController({
440
+ write: title => extensionUi?.setTitle?.(title),
441
+ label: process.env.BIZAR_OMP_SESSION_NAME,
442
+ });
443
+ let extensionUi;
419
444
  let currentNativeFile;
420
445
  let nativeLease;
421
- let pendingNativeFile;
422
- let pendingNativeLease;
423
446
  const omniRouteResponses = new Map();
424
447
  const releaseNativeLease = () => {
425
- try {
426
- pendingNativeLease?.release();
427
- }
428
- catch { /* teardown remains best-effort */ }
429
448
  try {
430
449
  nativeLease?.release();
431
450
  }
432
451
  catch { /* teardown remains best-effort */ }
433
- pendingNativeLease = undefined;
434
452
  nativeLease = undefined;
435
- pendingNativeFile = undefined;
436
453
  currentNativeFile = undefined;
437
454
  };
455
+ /** Records which journal this host writes. Returns false when it could not. */
438
456
  const registerNativeFile = async (ctx, file) => {
439
457
  if (!file || !existsSync(file))
440
- return;
458
+ return false;
441
459
  const ombName = process.env.BIZAR_OMP_SESSION_NAME;
442
- if (ombName && isOmbSessionName(ombName)) {
443
- const indexed = await indexOmbSessionFile(ombName, file, ctx.cwd ?? process.cwd());
444
- if (!indexed.ok)
445
- ctx.ui?.notify(`Bizar could not expose this conversation in OMP /resume: ${indexed.reason}. The private journal remains available through OMB.`, "warning");
460
+ if (!ombName || !isOmbSessionName(ombName))
461
+ return true;
462
+ const indexed = await indexOmbSessionFile(ombName, file, ctx.cwd ?? process.cwd());
463
+ if (!indexed.ok) {
464
+ ctx.ui?.notify(`Bizar could not expose this conversation in OMP /resume: ${indexed.reason}. The private journal remains available through OMB.`, "warning");
465
+ return false;
446
466
  }
467
+ return true;
447
468
  };
448
469
  const currentBinding = (ctx) => {
449
470
  if (!binding || !bindingMatches(binding, ctx))
@@ -468,6 +489,12 @@ export default function bizarExtension(pi) {
468
489
  binding = undefined;
469
490
  orchestratorActive = false;
470
491
  releaseNativeLease();
492
+ extensionUi = ctx.ui;
493
+ // The tab should name the conversation. OMP's own session name is the bare
494
+ // binary and the Bizar host name is a hashed directory id, so neither is
495
+ // readable; OMP's session name is its generated title once one exists, and
496
+ // the project directory is the sensible fallback before that.
497
+ sessionTitle.setLabel(sessionTitleLabel(ctx));
471
498
  const file = ctx.sessionManager?.getSessionFile?.();
472
499
  if (file && existsSync(file)) {
473
500
  try {
@@ -494,15 +521,18 @@ export default function bizarExtension(pi) {
494
521
  return;
495
522
  if (currentNativeFile && sameOmpSessionFile(currentNativeFile, target))
496
523
  return;
497
- const ombName = process.env.BIZAR_OMP_SESSION_NAME;
498
- if (ombName && isOmbSessionName(ombName)) {
499
- ctx.ui?.notify("This OMB host is bound to its original conversation. Close the host before resuming that journal from standalone OMP.", "warning");
500
- return { cancel: true };
501
- }
524
+ // Point-in-time conflict probe, not ownership. OMP 18.4.2 emits
525
+ // `session_switch` only when the switch commits, and offers no cancel or
526
+ // failure event for it: a rejected cwd change or a failed load returns or
527
+ // throws with nothing to observe. A lease retained across that gap would
528
+ // therefore lock the target journal until shutdown, and a timer is not an
529
+ // option — a slow but valid load could outlive it and reopen the very
530
+ // two-writer race the lease exists to prevent. So the probe is taken and
531
+ // dropped immediately, and exclusivity is re-established for real in
532
+ // `session_switch`. This proves the target was free a moment ago; it does
533
+ // not hold it for the duration of the switch.
502
534
  try {
503
- pendingNativeLease?.release();
504
- pendingNativeLease = acquireOmpSessionLease(target);
505
- pendingNativeFile = target;
535
+ acquireOmpSessionLease(target).release();
506
536
  }
507
537
  catch (error) {
508
538
  ctx.ui?.notify(`Cannot resume this conversation while another OMP/OMB process owns it: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -518,47 +548,146 @@ export default function bizarExtension(pi) {
518
548
  catch { /* best-effort */ }
519
549
  nativeLease = undefined;
520
550
  currentNativeFile = undefined;
521
- pendingNativeLease = undefined;
522
- pendingNativeFile = undefined;
523
551
  return;
524
552
  }
525
- if (pendingNativeLease && pendingNativeFile && sameOmpSessionFile(pendingNativeFile, file)) {
526
- try {
527
- nativeLease?.release();
528
- }
529
- catch { /* best-effort */ }
530
- nativeLease = pendingNativeLease;
531
- pendingNativeLease = undefined;
532
- pendingNativeFile = undefined;
553
+ // Exclusivity is re-established here rather than held from the preflight:
554
+ // OMP offers no switch-cancel event, so a lease kept across that gap would
555
+ // lock the target until shutdown. The new journal is claimed *before* the
556
+ // old one is let go, so this host is never briefly unowned, and a race
557
+ // that another process won between the two events fails here instead of
558
+ // leaving two writers on one journal.
559
+ let claimed;
560
+ try {
561
+ claimed = acquireOmpSessionLease(file);
533
562
  }
534
- else if (!currentNativeFile || !sameOmpSessionFile(currentNativeFile, file)) {
535
- try {
536
- const nextLease = acquireOmpSessionLease(file);
537
- try {
538
- nativeLease?.release();
539
- }
540
- catch { /* best-effort */ }
541
- nativeLease = nextLease;
542
- }
543
- catch (error) {
544
- ctx.ui?.notify(`This OMP session could not claim its conversation journal: ${error instanceof Error ? error.message : String(error)}`, "error");
545
- ctx.shutdown?.();
563
+ catch (error) {
564
+ currentNativeFile = file;
565
+ ctx.ui?.notify(`This OMP session could not claim its conversation journal: ${error instanceof Error ? error.message : String(error)}`, "error");
566
+ ctx.shutdown?.();
567
+ return;
568
+ }
569
+ try {
570
+ nativeLease?.release();
571
+ }
572
+ catch { /* best-effort */ }
573
+ nativeLease = claimed;
574
+ const previousFile = currentNativeFile;
575
+ currentNativeFile = file;
576
+ // A host that switched conversations must move two records together: the
577
+ // tmux generation binding (what a dead-pane respawn is checked against) and
578
+ // the journal manifest (what the hub reads). OMP has already switched by
579
+ // this point, so leaving both on the old journal would make the respawn
580
+ // check *pass* against a conversation this host no longer writes — a silent
581
+ // divergence. Every failure below therefore ends by clearing the binding,
582
+ // which makes that check refuse. Restoring the old generation is never the
583
+ // safe move here.
584
+ const ombName = process.env.BIZAR_OMP_SESSION_NAME;
585
+ const hosted = Boolean(ombName && isOmbSessionName(ombName) && process.env.TMUX);
586
+ const switched = Boolean(previousFile && !sameOmpSessionFile(previousFile, file));
587
+ const failClosed = async (reason) => {
588
+ // Two independent authorities gate a respawn: the tmux generation binding
589
+ // and the owner link manifest. Invalidate at least one, so a respawn can
590
+ // never pass against the conversation this host has left. A tombstone is
591
+ // deliberately not used here: it hides a live host instead of poisoning
592
+ // its recovery, and this host is still running.
593
+ const cleared = await clearOmbHostGeneration(ombName, ctx.cwd ?? process.cwd());
594
+ const delinked = clearOmbSessionReference(ombName);
595
+ if (cleared || delinked) {
596
+ ctx.ui?.notify(`This host resumed the conversation, but ${reason} Its recovery was invalidated, so a dead pane will refuse to respawn rather than resume a different conversation. Restart this host to continue working.`, "warning");
546
597
  return;
547
598
  }
599
+ // Neither authority could be invalidated, so the old generation and the
600
+ // old manifest still agree and a respawn would pass against the
601
+ // conversation this host has left. There is no honest way to describe
602
+ // that as fail-closed, so the host is stopped rather than left running on
603
+ // records that no longer describe it.
604
+ ctx.ui?.notify(`This host resumed the conversation, but ${reason} Its recovery records could not be invalidated, so it still describes the previous conversation. Stopping this host; close and reopen it before continuing.`, "error");
605
+ ctx.shutdown?.();
606
+ };
607
+ if (switched && hosted) {
608
+ const generation = await readOmbJournalNativeId(file);
609
+ if (!generation)
610
+ await failClosed("the resumed journal has no readable session header.");
611
+ else {
612
+ const rebound = await rebindOmbHostGeneration(ombName, generation, ctx.cwd ?? process.cwd());
613
+ if (!rebound.ok)
614
+ await failClosed(`its generation binding could not follow (${rebound.reason}).`);
615
+ else if (await registerNativeFile(ctx, file)) {
616
+ ctx.ui?.notify(`This host now writes the conversation you selected; its generation binding moved from ${rebound.previous ?? "unset"} to ${generation}.`, "info");
617
+ }
618
+ else
619
+ await failClosed("its journal manifest could not be updated.");
620
+ }
621
+ }
622
+ else {
623
+ await registerNativeFile(ctx, file);
548
624
  }
549
- currentNativeFile = file;
550
625
  });
551
- pi.on("session_shutdown", async () => { releaseNativeLease(); binding = undefined; orchestratorActive = false; });
552
- pi.on("before_agent_start", event => ({
553
- systemPrompt: appendUserIdentityInvariant(orchestratorActive
554
- ? applyOrchestratorPolicy(appendBizarInvariant(paramsOf(event).systemPrompt))
555
- : appendBizarInvariant(paramsOf(event).systemPrompt)),
556
- }));
626
+ pi.on("session_shutdown", async () => {
627
+ releaseNativeLease();
628
+ binding = undefined;
629
+ orchestratorActive = false;
630
+ sessionTitle.dispose();
631
+ extensionUi = undefined;
632
+ });
633
+ pi.on("before_agent_start", (event, ctx) => {
634
+ extensionUi = ctx?.ui ?? extensionUi;
635
+ sessionTitle.setState("working");
636
+ return {
637
+ systemPrompt: appendUserIdentityInvariant(orchestratorActive
638
+ ? applyOrchestratorPolicy(appendBizarInvariant(paramsOf(event).systemPrompt))
639
+ : appendBizarInvariant(paramsOf(event).systemPrompt)),
640
+ };
641
+ });
642
+ pi.on("agent_end", (event, ctx) => {
643
+ extensionUi = ctx?.ui ?? extensionUi;
644
+ // A scheduled continuation is not a user-visible settle, so the spinner
645
+ // keeps running across an automatic retry instead of flickering idle.
646
+ if (paramsOf(event).willContinue === true)
647
+ return;
648
+ // Re-read the label: OMP generates the conversation title during the turn,
649
+ // so the tab should pick it up as soon as it exists.
650
+ if (ctx)
651
+ sessionTitle.setLabel(sessionTitleLabel(ctx));
652
+ sessionTitle.setState("idle");
653
+ });
557
654
  // A002: managed task isolation/admission pre-execution gate.
558
- const BIZAR_PACKAGED_AGENTS = new Set([
559
- "bizar_planner", "bizar_researcher", "bizar_implementer", "bizar_reviewer",
560
- "bizar_verifier", "bizar_architect", "bizar_docs", "bizar_security_reviewer",
561
- ]);
655
+ //
656
+ // `AGENT_ROLES` is the canonical list of packaged specialists and is written
657
+ // with the hyphens the `agents/*.md` front matter actually uses. The gate
658
+ // previously carried its own underscored copy, so every valid call to a real
659
+ // packaged specialist was refused as a non-packaged agent.
660
+ const BIZAR_PACKAGED_AGENTS = Object.fromEntries(AGENT_ROLES.map(name => [name, true]));
661
+ /**
662
+ * Every task in one call, in the shape the model sent it.
663
+ *
664
+ * Bizar configures OMP with `task.batch=true`, so a valid call carries
665
+ * `tasks: [{ agent, isolated, ... }]` with no top-level `agent`/`isolated`.
666
+ * Reading only the flat shape rejected every batch call before OMP could run
667
+ * it, and treated a batch of individually-valid tasks as unadmitted because
668
+ * the top-level fields were absent.
669
+ */
670
+ const requestedTasks = (input) => {
671
+ if (!Array.isArray(input.tasks)) {
672
+ return [{ agent: typeof input.agent === "string" ? input.agent : "", isolated: input.isolated === true }];
673
+ }
674
+ return input.tasks.map(entry => {
675
+ const task = paramsOf(entry);
676
+ return { agent: typeof task.agent === "string" ? task.agent : "", isolated: task.isolated === true };
677
+ });
678
+ };
679
+ /**
680
+ * Name the run that closed the gate and how to reopen it.
681
+ *
682
+ * A run left mid-flight — a sleep, a kill, a Ctrl-C — keeps the managed gates
683
+ * engaged for the rest of the session, and the agent has no way to notice
684
+ * that from the outside. The refusal is the one message it reliably reads at
685
+ * the moment it is refused, so it carries the run id, its phase, and the
686
+ * cancel that releases it.
687
+ */
688
+ const managedRunNotice = (run) => ` A managed Bizar run (${run?.runId ?? "unknown"}) is active in phase "${run?.phase ?? "unknown"}"; `
689
+ + "this stays blocked until that run finishes. To release it now, call `bizar_workflow` with action \"cancel\" and runId \""
690
+ + `${run?.runId ?? "unknown"}\".`;
562
691
  pi.on("tool_call", (event, ctx) => {
563
692
  try {
564
693
  const params = paramsOf(event);
@@ -567,16 +696,15 @@ export default function bizarExtension(pi) {
567
696
  return;
568
697
  const active = currentBinding(ctx);
569
698
  const run = active.journal.snapshot().run;
570
- const managedActive = !!(run && !["complete", "failed", "cancelled"].includes(run.phase));
571
- if (!managedActive)
699
+ if (!isRunPhaseActive(run?.phase))
572
700
  return;
573
- const input = paramsOf(params.input);
574
- const isolated = input.isolated === true;
575
- const agent = typeof input.agent === "string" ? input.agent : "";
576
- if (!isolated)
577
- return { block: true, reason: "A002: managed workflow is active; native `task` calls must declare `isolated: true`." };
578
- if (!BIZAR_PACKAGED_AGENTS.has(agent))
579
- return { block: true, reason: `A002: managed workflow refused native \`task\` to non-packaged agent ${agent || "(unspecified)"}; only packaged Bizar specialists are admitted.` };
701
+ const tasks = requestedTasks(paramsOf(params.input));
702
+ for (const task of tasks) {
703
+ if (!task.isolated)
704
+ return { block: true, reason: `A002: managed workflow is active; native \`task\` calls must declare \`isolated: true\` on every task.${managedRunNotice(run)}` };
705
+ if (!BIZAR_PACKAGED_AGENTS[task.agent])
706
+ return { block: true, reason: `A002: managed workflow refused native \`task\` to non-packaged agent ${task.agent || "(unspecified)"}; only packaged Bizar specialists are admitted.${managedRunNotice(run)}` };
707
+ }
580
708
  }
581
709
  catch {
582
710
  return;
@@ -592,14 +720,13 @@ export default function bizarExtension(pi) {
592
720
  return;
593
721
  const active = currentBinding(ctx);
594
722
  const run = active.journal.snapshot().run;
595
- const managedActive = !!(run && !["complete", "failed", "cancelled"].includes(run.phase));
596
- if (!managedActive)
723
+ if (!isRunPhaseActive(run?.phase))
597
724
  return;
598
725
  const activeDispatch = active.journal.snapshot().dispatches.find(intent => intent.runId === run.runId && !["settled", "failed", "cancelled", "unknown-outcome", "rejected"].includes(intent.status));
599
726
  if (!activeDispatch?.tools || activeDispatch.tools.length === 0)
600
727
  return;
601
728
  if (!activeDispatch.tools.includes(toolName))
602
- return { block: true, reason: `A004: managed dispatch ${activeDispatch.id} declared tools allowlist; tool "${toolName}" is not permitted (allowed: ${activeDispatch.tools.join(", ")}).` };
729
+ return { block: true, reason: `A004: managed dispatch ${activeDispatch.id} declared tools allowlist; tool "${toolName}" is not permitted (allowed: ${activeDispatch.tools.join(", ")}).${managedRunNotice(run)}` };
603
730
  }
604
731
  catch {
605
732
  return;
@@ -614,16 +741,25 @@ export default function bizarExtension(pi) {
614
741
  return result({ orchestratorActive: true, scope: "main-session-only", note: "Main orchestrator policy will be appended to subsequent agent system prompts in this session. Task children must not invoke this tool." });
615
742
  },
616
743
  });
617
- // A008: operator receipt tool — required before accepting operator-review evidence as "passed".
744
+ // A008: operator receipt — required before an operator-judgment criterion is
745
+ // covered.
746
+ //
747
+ // The operator identity is taken from the environment, not from the call
748
+ // arguments. A model-callable tool whose caller supplies its own `operatorId`
749
+ // cannot attest that a human gave a verdict; that made the receipt a content
750
+ // checksum masquerading as operator authorization. The receipt now records
751
+ // *who the host attributes the verdict to*, and refuses to issue one at all
752
+ // when no operator is configured — so a run that demands operator judgment
753
+ // fails closed instead of being self-approved.
618
754
  pi.registerTool({
619
755
  name: "bizar_operator_receipt", label: "Bizar Operator Receipt",
620
- description: "Record an explicit operator verdict for operator-review criteria. Must be called before accepting an operator-review evidence record.",
756
+ description: "Record the configured operator's verdict on an operator-judgment criterion. The operator identity comes from the environment, not from this call.",
621
757
  parameters: pi.zod.object({
622
758
  workItemId: pi.zod.string(),
623
- evidenceId: pi.zod.string(),
759
+ criterionId: pi.zod.string(),
760
+ evidenceId: pi.zod.string().optional(),
624
761
  verdict: pi.zod.enum(["passed", "failed", "blocked"]),
625
762
  rationale: pi.zod.string(),
626
- operatorId: pi.zod.string(),
627
763
  }),
628
764
  async execute(_id, raw, _signal, _onUpdate, ctx) {
629
765
  try {
@@ -632,20 +768,25 @@ export default function bizarExtension(pi) {
632
768
  const state = active.journal.snapshot();
633
769
  if (!state.run)
634
770
  throw new Error("No active workflow run");
635
- const evidence = state.evidence.find(e => e.id === p.evidenceId);
636
- if (!evidence)
637
- throw new Error(`Evidence ${p.evidenceId} not found`);
771
+ // Never taken from the call: a caller must not be able to name itself.
772
+ const operatorId = process.env.BIZAR_OPERATOR_ID?.trim() || "";
773
+ if (!operatorId)
774
+ throw new Error("No operator is configured; set BIZAR_OPERATOR_ID to the identity that may record operator verdicts");
775
+ const evidenceId = typeof p.evidenceId === "string" ? p.evidenceId : "";
776
+ if (evidenceId && !state.evidence.some(record => record.id === evidenceId))
777
+ throw new Error(`Evidence ${evidenceId} not found`);
638
778
  const receiptId = `receipt_${randomUUID()}`;
639
- const signedContent = JSON.stringify({ workItemId: p.workItemId, evidenceId: p.evidenceId, verdict: p.verdict, operatorId: p.operatorId, issuedAt: new Date().toISOString() });
640
- const signedContentHash = createHash("sha256").update(signedContent).digest("hex");
779
+ const issuedAt = new Date().toISOString();
641
780
  const verdict = String(p.verdict);
781
+ const signedContent = JSON.stringify({ workItemId: p.workItemId, evidenceId, verdict, operatorId, issuedAt });
782
+ const signedContentHash = createHash("sha256").update(signedContent).digest("hex");
642
783
  const receipt = {
643
784
  id: receiptId, runId: state.run.runId, workItemId: String(p.workItemId),
644
- evidenceId: String(p.evidenceId), verdict, rationale: String(p.rationale),
645
- operatorId: String(p.operatorId), signedContentHash, issuedAt: new Date().toISOString(),
785
+ evidenceId, verdict, rationale: String(p.rationale),
786
+ operatorId, signedContentHash, issuedAt,
646
787
  };
647
788
  active.journal.append({ type: "operator.receipt.recorded", runId: state.run.runId, receipt }, `receipt:${receiptId}`, state.revision);
648
- return result({ receiptId, verdict: p.verdict, signedContentHash });
789
+ return result({ receiptId, verdict, operatorId, signedContentHash });
649
790
  }
650
791
  catch (error) {
651
792
  return failure(error);