osborn 0.9.219 → 0.9.220

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.
@@ -46,10 +46,6 @@ export declare const NAMED_AGENTS: {
46
46
  description: string;
47
47
  tools: string[];
48
48
  grounded: boolean;
49
- coordination: {
50
- then: string[];
51
- startNote: string;
52
- };
53
49
  model: string;
54
50
  prompt: string;
55
51
  };
@@ -63,28 +59,13 @@ export declare const NAMED_AGENTS: {
63
59
  description: string;
64
60
  tools: string[];
65
61
  grounded: boolean;
66
- policy: {
67
- write: "anywhere";
68
- };
69
- reminder: string;
70
- coordination: {
71
- then: string[];
72
- mode: "parallel";
73
- startNote: string;
74
- };
62
+ criticalSystemReminder_EXPERIMENTAL: string;
75
63
  model: string;
76
64
  prompt: string;
77
65
  };
78
66
  tester: {
79
67
  description: string;
80
68
  tools: string[];
81
- policy: {
82
- write: {
83
- extensions: RegExp;
84
- label: string;
85
- matchBasename: boolean;
86
- };
87
- };
88
69
  model: string;
89
70
  prompt: string;
90
71
  };
@@ -98,12 +79,6 @@ export declare const NAMED_AGENTS: {
98
79
  reviewer: {
99
80
  description: string;
100
81
  tools: string[];
101
- policy: {
102
- write: {
103
- extensions: RegExp;
104
- label: string;
105
- };
106
- };
107
82
  model: string;
108
83
  prompt: string;
109
84
  };
@@ -134,6 +109,21 @@ export declare function applyTurbo(agents: Record<string, any>, turbo: boolean):
134
109
  * Adversarial agents (reviewer/tester) leave `grounded` unset → untouched, stay blind.
135
110
  */
136
111
  export declare function applyGrounding(agents: Record<string, any>, sessionId: string | null, workingDir: string | undefined): Record<string, any>;
112
+ /**
113
+ * Decide a Write/Edit/MultiEdit for the acting agent (null = main orchestrator).
114
+ * Pure lookup against WRITE_RULES. Returns:
115
+ * 'allow' → fall through (→ canUseTool workspace auto-approve, or {}).
116
+ * 'defer' → PreToolUse 'ask' (canUseTool decides).
117
+ * 'deny' → hard block with reason.
118
+ */
119
+ export declare function decideWrite(agentType: string | null, filePath: string): {
120
+ decision: 'allow' | 'defer' | 'deny';
121
+ reason?: string;
122
+ };
123
+ export declare const VERIFIER_CHAIN: Record<string, {
124
+ then: string[];
125
+ mode?: 'parallel' | 'sequential';
126
+ }>;
137
127
  /**
138
128
  * Claude LLM - Wraps Claude Agent SDK for LiveKit
139
129
  * Research mode: reads anything, writes only to session workspace
@@ -366,12 +366,6 @@ export const NAMED_AGENTS = {
366
366
  ].join(' '),
367
367
  tools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'Task'],
368
368
  grounded: true, // applyGrounding() injects the osborn-recall command + ensures Bash
369
- // Declarative flow. == today: after the researcher finishes, a reasoner-based
370
- // research gate judges completeness and may send it back for more.
371
- coordination: {
372
- then: ['reasoner'],
373
- startNote: 'When you finish, a reasoner-based gate judges whether your findings are COMPLETE and well-sourced against the task; thin or unsourced findings get sent back to you. Cite file paths + line numbers and explicitly note what you looked for but did NOT find.',
374
- },
375
369
  model: 'sonnet',
376
370
  prompt: [
377
371
  'You are Osborn\'s research agent. Your job is information gathering — thorough, structured, factual.',
@@ -408,6 +402,7 @@ export const NAMED_AGENTS = {
408
402
  'Return findings to the orchestrator — never directly to the user.',
409
403
  'Run several researchers in parallel when there are independent threads to investigate.',
410
404
  'Hand off back to the orchestrator; it decides whether to invoke planner or writer next.',
405
+ 'After you return, a reasoner-based gate judges whether your findings are COMPLETE and well-sourced; thin or unsourced findings get sent back to you — so cite paths/line numbers and state what you looked for but did NOT find.',
411
406
  ].join('\n'),
412
407
  },
413
408
  reasoner: {
@@ -474,17 +469,8 @@ export const NAMED_AGENTS = {
474
469
  ].join(' '),
475
470
  tools: ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Glob', 'Grep', 'NotebookRead', 'NotebookEdit'],
476
471
  grounded: true, // applyGrounding() injects the osborn-recall command
477
- policy: { write: 'anywhere' }, // sole writer — unrestricted; funnels through canUseTool
478
- // Soft behavior via the composable `reminder` seam → SDK criticalSystemReminder_EXPERIMENTAL.
479
- // Pinned into the writer's system prompt as a hard-to-ignore reminder.
480
- reminder: 'BEFORE you write implementation code: make sure a test exists for the behavior you are about to change. If none exists, say so explicitly in your report so the tester can cover it — the tester is the agent that writes tests. NEVER weaken, skip, or delete a test to make your change pass.',
481
- // Declarative flow (drives SubagentStop dispatch + SubagentStart injection).
482
- // == today: after the writer finishes, reviewer AND tester run in parallel.
483
- coordination: {
484
- then: ['reviewer', 'tester'],
485
- mode: 'parallel',
486
- startNote: 'When you finish, your change is automatically verified in parallel: a reviewer checks correctness against the git diff, and a tester runs the suite. Make the change review-ready and leave the tree in a runnable state — do not skip cleanup expecting a second pass.',
487
- },
472
+ // SDK-native soft-behavior field — pinned into the writer's system prompt.
473
+ criticalSystemReminder_EXPERIMENTAL: 'BEFORE you write implementation code: make sure a test exists for the behavior you are about to change. If none exists, say so explicitly in your report so the tester can cover it — the tester is the agent that writes tests. NEVER weaken, skip, or delete a test to make your change pass.',
488
474
  model: 'opus',
489
475
  prompt: [
490
476
  'You are Osborn\'s writer agent. You execute file changes with a verify-first approach.',
@@ -527,6 +513,7 @@ export const NAMED_AGENTS = {
527
513
  'Invoked AFTER the planner produces a written plan — the writer is the SOLE agent that edits files.',
528
514
  'Do not invoke writer until a plan exists for any multi-step change.',
529
515
  'When the writer returns, the orchestrator invokes tester AND reviewer in parallel before surfacing results.',
516
+ 'Because that verification runs automatically after you finish, make the change review-ready and leave the tree in a runnable state — do not skip cleanup expecting a second pass.',
530
517
  ].join('\n'),
531
518
  },
532
519
  tester: {
@@ -536,9 +523,7 @@ export const NAMED_AGENTS = {
536
523
  'Returns structured pass/fail results with exact output — does NOT edit files.',
537
524
  'NOT GROUNDED: no session index access — runs tests with fresh eyes, adversarial validation.',
538
525
  ].join(' '),
539
- tools: ['Bash', 'Read', 'Glob', 'Grep', 'Write', 'Edit'],
540
- // Fail-closed: may ONLY write test files (basename .test/.spec.[jt]sx?).
541
- policy: { write: { extensions: /\.(test|spec)\.[jt]sx?$/, label: 'test', matchBasename: true } },
526
+ tools: ['Bash', 'Read', 'Glob', 'Grep', 'Write', 'Edit'], // write-gate: test files only (WRITE_RULES.tester)
542
527
  model: 'sonnet',
543
528
  prompt: [
544
529
  'You are Osborn\'s tester agent. Your job is running tests and builds, then reporting results.',
@@ -687,9 +672,7 @@ export const NAMED_AGENTS = {
687
672
  'and returns an ACCEPT or REJECT verdict with specific, actionable feedback. May write documentation files (.md etc.) only.',
688
673
  'NOT GROUNDED: no session index access — reviews with fresh eyes for unbiased adversarial check.',
689
674
  ].join(' '),
690
- tools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
691
- // Fail-closed: may ONLY write documentation files.
692
- policy: { write: { extensions: /\.(md|markdown|mdx|txt|rst|adoc)$/i, label: 'documentation' } },
675
+ tools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'], // write-gate: docs only (WRITE_RULES.reviewer)
693
676
  model: 'sonnet',
694
677
  prompt: [
695
678
  'You are Osborn\'s reviewer agent. You are the VERIFY step in a generator-verifier loop.',
@@ -857,64 +840,53 @@ export function applyGrounding(agents, sessionId, workingDir) {
857
840
  }
858
841
  return out;
859
842
  }
860
- const DEFAULT_WRITE_POLICY = 'workspace';
843
+ const WRITE_RULES = {
844
+ writer: 'anywhere',
845
+ tester: { extensions: /\.(test|spec)\.[jt]sx?$/, label: 'test', matchBasename: true },
846
+ reviewer: { extensions: /\.(md|markdown|mdx|txt|rst|adoc)$/i, label: 'documentation' },
847
+ };
861
848
  /** Path is inside the per-session sandbox workspace. */
862
849
  function isWorkspacePath(filePath) {
863
850
  return !!filePath && (filePath.includes('/osb/') ||
864
851
  filePath.includes('.osborn/sessions/') ||
865
852
  filePath.includes('.osborn/research/'));
866
853
  }
867
- /** Effective write policy for the acting agent (null agentType = main orchestrator). */
868
- function resolveWritePolicy(agentType, roster) {
869
- const def = agentType ? roster?.[agentType] : null;
870
- return def?.policy?.write ?? DEFAULT_WRITE_POLICY;
871
- }
872
854
  /**
873
- * Decide a Write/Edit/MultiEdit against a policy. Pure — no side effects.
874
- * 'allow' → let it fall through (→ canUseTool workspace auto-approve, or {}).
875
- * 'defer' → PreToolUse returns permissionDecision:'ask' (canUseTool decides).
876
- * 'deny' → hard block with reason.
855
+ * Decide a Write/Edit/MultiEdit for the acting agent (null = main orchestrator).
856
+ * Pure lookup against WRITE_RULES. Returns:
857
+ * 'allow' → fall through (→ canUseTool workspace auto-approve, or {}).
858
+ * 'defer' → PreToolUse 'ask' (canUseTool decides).
859
+ * 'deny' → hard block with reason.
877
860
  */
878
- function decideWrite(policy, filePath) {
879
- if (policy === 'anywhere')
861
+ export function decideWrite(agentType, filePath) {
862
+ const rule = (agentType && WRITE_RULES[agentType]) || 'workspace';
863
+ if (rule === 'anywhere')
880
864
  return { decision: 'defer' };
881
- if (policy === 'workspace') {
865
+ if (rule === 'workspace') {
882
866
  if (filePath && !isWorkspacePath(filePath)) {
883
867
  return { decision: 'deny', reason: 'Research mode: writes restricted to session workspace.' };
884
868
  }
885
869
  return { decision: 'allow' };
886
870
  }
887
871
  // Extension whitelist — fail closed (empty/unknown path denied).
888
- const target = policy.matchBasename ? (filePath ? basename(resolve(filePath)) : '') : filePath;
889
- if (!filePath || !policy.extensions.test(target)) {
872
+ const target = rule.matchBasename ? (filePath ? basename(resolve(filePath)) : '') : filePath;
873
+ if (!filePath || !rule.extensions.test(target)) {
890
874
  const reason = filePath
891
- ? `Write denied: ${filePath} is not a ${policy.label} file. This agent may only write ${policy.label} files.`
875
+ ? `Write denied: ${filePath} is not a ${rule.label} file. This agent may only write ${rule.label} files.`
892
876
  : 'Write denied: could not determine target file path. Failing closed.';
893
877
  return { decision: 'deny', reason };
894
878
  }
895
879
  return { decision: 'defer' };
896
880
  }
897
- /** Coordination config for the acting/target agent (null agentType = main). */
898
- function coordinationFor(agentType, roster) {
899
- const def = agentType ? roster?.[agentType] : null;
900
- return def?.coordination ?? null;
901
- }
902
- /**
903
- * Strip/map behavior meta-fields so the roster is a clean AgentDefinition set
904
- * for the SDK: drop `policy` (write-gate) and `coordination` (hook-driven),
905
- * and map `reminder` → criticalSystemReminder_EXPERIMENTAL. NEVER mutates input.
906
- */
907
- function finalizeRoster(agents) {
908
- const out = {};
909
- for (const [name, agent] of Object.entries(agents)) {
910
- const { policy, reminder, coordination, ...rest } = agent;
911
- if (reminder && !rest.criticalSystemReminder_EXPERIMENTAL) {
912
- rest.criticalSystemReminder_EXPERIMENTAL = reminder;
913
- }
914
- out[name] = rest;
915
- }
916
- return out;
917
- }
881
+ // ── Verifier chaining ───────────────────────────────────────────────────────
882
+ // A plain name→verifiers lookup read by the SubagentStop hook. Same wiring the
883
+ // old hardcoded branches had (writer→reviewer+tester, researcher→reasoner gate),
884
+ // gathered in one visible place. Names resolve to spawnReviewer / spawnTester /
885
+ // spawnResearchGate. Any agent not listed chains to nothing.
886
+ export const VERIFIER_CHAIN = {
887
+ writer: { then: ['reviewer', 'tester'], mode: 'parallel' },
888
+ researcher: { then: ['reasoner'] },
889
+ };
918
890
  const RESEARCH_TOOLS = [
919
891
  'Read', 'Write', 'Edit', 'Glob', 'Grep',
920
892
  'Bash', 'WebSearch', 'WebFetch',
@@ -2034,10 +2006,6 @@ class ClaudeLLMStream extends llm.LLMStream {
2034
2006
  ? getSessionWorkspace(this.#opts.workingDirectory, sessionId)
2035
2007
  : null;
2036
2008
  const allowedTools = this.#opts.allowedTools || [];
2037
- // Roster the PreToolUse write-gate reads policy from — the PRE-strip view
2038
- // (retains `policy`), so DB-backed custom agents (set_agents) get their
2039
- // write policy enforced too. The SDK receives the finalized copy (agents:).
2040
- const enforcementRoster = this.#opts.agents ?? NAMED_AGENTS;
2041
2009
  const sdkOptions = {
2042
2010
  cwd: this.#opts.workingDirectory,
2043
2011
  permissionMode: this.#opts.permissionMode,
@@ -2155,35 +2123,22 @@ class ClaudeLLMStream extends llm.LLMStream {
2155
2123
  }
2156
2124
  console.log(`🔧 Tool call ${turnToolCallCount}/${TOOL_CALL_BUDGET}: ${toolName}`);
2157
2125
  }
2158
- // Delegation-point injection — when the hub spawns a sub-agent, add
2159
- // that agent's discretionary delegationNote to the hub's context
2160
- // (e.g. "add a tester for high-risk edits"). Empty by default → inert.
2161
- if (toolName === 'Task') {
2162
- const targetType = String(toolInput?.subagent_type || '');
2163
- const note = coordinationFor(targetType || null, enforcementRoster)?.delegationNote;
2164
- if (note) {
2165
- this.#eventEmitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: agentType || 'main' });
2166
- console.log(`🤝 Delegation note → subagent_type=${targetType}`);
2167
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: note } };
2168
- }
2169
- }
2170
- // Write/Edit/MultiEdit access control — DATA-DRIVEN by each agent's
2171
- // declarative policy.write (see AgentWritePolicy). Replaces the old
2172
- // hardcoded per-role branches; DB-backed custom agents get enforced too.
2126
+ // Write/Edit/MultiEdit access control — plain WRITE_RULES lookup
2127
+ // keyed by agent name (see decideWrite). Same behavior the old
2128
+ // hardcoded per-role branches had, now in one visible place.
2173
2129
  if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
2174
2130
  const filePath = String(toolInput.file_path || '');
2175
- const policy = resolveWritePolicy(agentType, enforcementRoster);
2176
- const { decision, reason } = decideWrite(policy, filePath);
2177
- console.log(`🔎 Write gate: agent=${agentType ?? 'main'} policy=${JSON.stringify(policy)} path="${filePath || '(none)'}" → ${decision}`);
2131
+ const { decision, reason } = decideWrite(agentType, filePath);
2132
+ console.log(`🔎 Write gate: agent=${agentType ?? 'main'} path="${filePath || '(none)'}" → ${decision}`);
2178
2133
  if (decision === 'deny') {
2179
2134
  this.#eventEmitter.emit('tool_blocked', { name: toolName, reason });
2180
2135
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' }, reason };
2181
2136
  }
2182
2137
  if (decision === 'defer') {
2183
- // Writer (write:'anywhere') surfaces its write in the live trace
2184
- // before the permission dialog; adversarial agents (reviewer/tester)
2185
- // do NOT emit here — parity with the prior hardcoded branches.
2186
- if (policy === 'anywhere') {
2138
+ // Writer ('anywhere') surfaces its write in the live trace before
2139
+ // the permission dialog; adversarial agents (reviewer/tester) do
2140
+ // NOT emit here — parity with the prior hardcoded branches.
2141
+ if (agentType === 'writer') {
2187
2142
  this.#eventEmitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: agentType || 'main' });
2188
2143
  }
2189
2144
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask' } };
@@ -2531,17 +2486,32 @@ class ClaudeLLMStream extends llm.LLMStream {
2531
2486
  return {};
2532
2487
  }]
2533
2488
  }],
2489
+ // Idle-agent guard / end-of-run self-review. When the main agent tries
2490
+ // to end its turn while background work is still in flight, block ONCE
2491
+ // (stop_hook_active guards against a loop) and force one more turn so it
2492
+ // either waits + synthesizes, or explicitly tells the user what's still
2493
+ // running — never goes silent leaving dispatched work dangling.
2494
+ Stop: [{
2495
+ matcher: '.*',
2496
+ hooks: [async (input) => {
2497
+ const inFlight = Array.isArray(input?.background_tasks) ? input.background_tasks : [];
2498
+ if (input?.stop_hook_active || inFlight.length === 0)
2499
+ return {};
2500
+ const labels = inFlight
2501
+ .map((t) => t?.agent_type || t?.name || t?.command || t?.type || 'task')
2502
+ .slice(0, 6);
2503
+ console.log(`🛑 Stop gate: ${inFlight.length} background task(s) in flight → blocking once for self-review`);
2504
+ return {
2505
+ decision: 'block',
2506
+ reason: `Before you end your turn: ${inFlight.length} background task(s) are still running (${labels.join(', ')}). Do NOT go idle. Either wait for them and synthesize their results into your answer, or explicitly tell the user what is still running and what you will do when it finishes.`,
2507
+ };
2508
+ }]
2509
+ }],
2534
2510
  SubagentStart: [{
2535
2511
  matcher: '.*',
2536
2512
  hooks: [async (input) => {
2537
2513
  console.log('[LIFECYCLE-PROBE] SubagentStart', JSON.stringify(input));
2538
2514
  this.#eventEmitter.emit('agent_started', { agent_type: input?.agent_type, agent_id: input?.agent_id });
2539
- // Inject the agent's declarative startNote (who it is paired with) —
2540
- // reliable, always-seen at boot regardless of what the hub relayed.
2541
- const startNote = coordinationFor(input?.agent_type ?? null, enforcementRoster)?.startNote;
2542
- if (startNote) {
2543
- return { hookSpecificOutput: { hookEventName: 'SubagentStart', additionalContext: startNote } };
2544
- }
2545
2515
  return {};
2546
2516
  }]
2547
2517
  }],
@@ -2555,14 +2525,13 @@ class ClaudeLLMStream extends llm.LLMStream {
2555
2525
  statusManager.upsertDispatch(aid, { subagentType: at, dispatchState: 'completed', artifact: msg });
2556
2526
  this.#eventEmitter.emit('task_completed', { agent_type: at, agent_id: aid, last_assistant_message: String(msg).slice(0, 400) });
2557
2527
  // Infinite-loop guard — verifiers never re-dispatch (they carry no
2558
- // coordination.then anyway; this is defense-in-depth against a
2559
- // DB-backed agent accidentally arming a loop).
2528
+ // VERIFIER_CHAIN entry anyway; defense-in-depth).
2560
2529
  if (at === 'reviewer' || at === 'tester' || at === 'reasoner')
2561
2530
  return {};
2562
- // Declarative verifier chaining — driven by the finishing agent's
2563
- // coordination.then (replaces the hardcoded writer/researcher branches).
2564
- const coord = coordinationFor(at ?? null, enforcementRoster);
2565
- if (coord?.then?.length && msg) {
2531
+ // Verifier chaining — driven by the plain VERIFIER_CHAIN lookup
2532
+ // (replaces the hardcoded writer/researcher branches).
2533
+ const chain = at ? VERIFIER_CHAIN[at] : undefined;
2534
+ if (chain?.then?.length && msg) {
2566
2535
  const spawn = (role) => {
2567
2536
  if (role === 'reviewer')
2568
2537
  return this.#llmRef.spawnReviewer(aid, msg, this.#eventEmitter);
@@ -2570,16 +2539,16 @@ class ClaudeLLMStream extends llm.LLMStream {
2570
2539
  return this.#llmRef.spawnTester(aid, msg, this.#eventEmitter);
2571
2540
  if (role === 'reasoner' || role === 'gate')
2572
2541
  return this.#llmRef.spawnResearchGate(aid, msg, this.#eventEmitter);
2573
- console.warn(`[DISPATCH] unknown coordination target '${role}' for ${at} — skipped`);
2542
+ console.warn(`[DISPATCH] unknown chain target '${role}' for ${at} — skipped`);
2574
2543
  return Promise.resolve();
2575
2544
  };
2576
- if (coord.mode === 'sequential') {
2545
+ if (chain.mode === 'sequential') {
2577
2546
  // Await in order WITHOUT blocking the hook return (fire the chain async).
2578
- void (async () => { for (const r of coord.then)
2547
+ void (async () => { for (const r of chain.then)
2579
2548
  await spawn(r); })();
2580
2549
  }
2581
2550
  else {
2582
- for (const r of coord.then)
2551
+ for (const r of chain.then)
2583
2552
  void spawn(r);
2584
2553
  }
2585
2554
  }
@@ -2608,9 +2577,9 @@ class ClaudeLLMStream extends llm.LLMStream {
2608
2577
  // opts.agents is undefined — the ?? NAMED_AGENTS fallback would skip
2609
2578
  // the override entirely. Explicitly apply applyTurbo(NAMED_AGENTS)
2610
2579
  // so built-in agents always get FAST_MODEL when turbo is on.
2611
- agents: finalizeRoster(applyGrounding(this.#llmRef.turbo
2580
+ agents: applyGrounding(this.#llmRef.turbo
2612
2581
  ? applyTurbo(this.#opts.agents ?? NAMED_AGENTS, true)
2613
- : (this.#opts.agents ?? NAMED_AGENTS), this.#sessionId, this.#opts.workingDirectory)),
2582
+ : (this.#opts.agents ?? NAMED_AGENTS), this.#sessionId, this.#opts.workingDirectory),
2614
2583
  };
2615
2584
  // Run Claude Agent SDK query() and stream results
2616
2585
  let hasOutput = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.219",
3
+ "version": "0.9.220",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {