osborn 0.9.218 → 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.
@@ -59,23 +59,13 @@ export declare const NAMED_AGENTS: {
59
59
  description: string;
60
60
  tools: string[];
61
61
  grounded: boolean;
62
- policy: {
63
- write: "anywhere";
64
- };
65
- reminder: string;
62
+ criticalSystemReminder_EXPERIMENTAL: string;
66
63
  model: string;
67
64
  prompt: string;
68
65
  };
69
66
  tester: {
70
67
  description: string;
71
68
  tools: string[];
72
- policy: {
73
- write: {
74
- extensions: RegExp;
75
- label: string;
76
- matchBasename: boolean;
77
- };
78
- };
79
69
  model: string;
80
70
  prompt: string;
81
71
  };
@@ -89,12 +79,6 @@ export declare const NAMED_AGENTS: {
89
79
  reviewer: {
90
80
  description: string;
91
81
  tools: string[];
92
- policy: {
93
- write: {
94
- extensions: RegExp;
95
- label: string;
96
- };
97
- };
98
82
  model: string;
99
83
  prompt: string;
100
84
  };
@@ -125,6 +109,21 @@ export declare function applyTurbo(agents: Record<string, any>, turbo: boolean):
125
109
  * Adversarial agents (reviewer/tester) leave `grounded` unset → untouched, stay blind.
126
110
  */
127
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
+ }>;
128
127
  /**
129
128
  * Claude LLM - Wraps Claude Agent SDK for LiveKit
130
129
  * Research mode: reads anything, writes only to session workspace
@@ -402,6 +402,7 @@ export const NAMED_AGENTS = {
402
402
  'Return findings to the orchestrator — never directly to the user.',
403
403
  'Run several researchers in parallel when there are independent threads to investigate.',
404
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.',
405
406
  ].join('\n'),
406
407
  },
407
408
  reasoner: {
@@ -468,10 +469,8 @@ export const NAMED_AGENTS = {
468
469
  ].join(' '),
469
470
  tools: ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Glob', 'Grep', 'NotebookRead', 'NotebookEdit'],
470
471
  grounded: true, // applyGrounding() injects the osborn-recall command
471
- policy: { write: 'anywhere' }, // sole writer — unrestricted; funnels through canUseTool
472
- // Soft behavior via the composable `reminder` seam → SDK criticalSystemReminder_EXPERIMENTAL.
473
- // Pinned into the writer's system prompt as a hard-to-ignore reminder.
474
- 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.',
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.',
475
474
  model: 'opus',
476
475
  prompt: [
477
476
  'You are Osborn\'s writer agent. You execute file changes with a verify-first approach.',
@@ -514,6 +513,7 @@ export const NAMED_AGENTS = {
514
513
  'Invoked AFTER the planner produces a written plan — the writer is the SOLE agent that edits files.',
515
514
  'Do not invoke writer until a plan exists for any multi-step change.',
516
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.',
517
517
  ].join('\n'),
518
518
  },
519
519
  tester: {
@@ -523,9 +523,7 @@ export const NAMED_AGENTS = {
523
523
  'Returns structured pass/fail results with exact output — does NOT edit files.',
524
524
  'NOT GROUNDED: no session index access — runs tests with fresh eyes, adversarial validation.',
525
525
  ].join(' '),
526
- tools: ['Bash', 'Read', 'Glob', 'Grep', 'Write', 'Edit'],
527
- // Fail-closed: may ONLY write test files (basename .test/.spec.[jt]sx?).
528
- 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)
529
527
  model: 'sonnet',
530
528
  prompt: [
531
529
  'You are Osborn\'s tester agent. Your job is running tests and builds, then reporting results.',
@@ -674,9 +672,7 @@ export const NAMED_AGENTS = {
674
672
  'and returns an ACCEPT or REJECT verdict with specific, actionable feedback. May write documentation files (.md etc.) only.',
675
673
  'NOT GROUNDED: no session index access — reviews with fresh eyes for unbiased adversarial check.',
676
674
  ].join(' '),
677
- tools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
678
- // Fail-closed: may ONLY write documentation files.
679
- 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)
680
676
  model: 'sonnet',
681
677
  prompt: [
682
678
  'You are Osborn\'s reviewer agent. You are the VERIFY step in a generator-verifier loop.',
@@ -844,59 +840,53 @@ export function applyGrounding(agents, sessionId, workingDir) {
844
840
  }
845
841
  return out;
846
842
  }
847
- 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
+ };
848
848
  /** Path is inside the per-session sandbox workspace. */
849
849
  function isWorkspacePath(filePath) {
850
850
  return !!filePath && (filePath.includes('/osb/') ||
851
851
  filePath.includes('.osborn/sessions/') ||
852
852
  filePath.includes('.osborn/research/'));
853
853
  }
854
- /** Effective write policy for the acting agent (null agentType = main orchestrator). */
855
- function resolveWritePolicy(agentType, roster) {
856
- const def = agentType ? roster?.[agentType] : null;
857
- return def?.policy?.write ?? DEFAULT_WRITE_POLICY;
858
- }
859
854
  /**
860
- * Decide a Write/Edit/MultiEdit against a policy. Pure — no side effects.
861
- * 'allow' → let it fall through (→ canUseTool workspace auto-approve, or {}).
862
- * 'defer' → PreToolUse returns permissionDecision:'ask' (canUseTool decides).
863
- * '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.
864
860
  */
865
- function decideWrite(policy, filePath) {
866
- if (policy === 'anywhere')
861
+ export function decideWrite(agentType, filePath) {
862
+ const rule = (agentType && WRITE_RULES[agentType]) || 'workspace';
863
+ if (rule === 'anywhere')
867
864
  return { decision: 'defer' };
868
- if (policy === 'workspace') {
865
+ if (rule === 'workspace') {
869
866
  if (filePath && !isWorkspacePath(filePath)) {
870
867
  return { decision: 'deny', reason: 'Research mode: writes restricted to session workspace.' };
871
868
  }
872
869
  return { decision: 'allow' };
873
870
  }
874
871
  // Extension whitelist — fail closed (empty/unknown path denied).
875
- const target = policy.matchBasename ? (filePath ? basename(resolve(filePath)) : '') : filePath;
876
- if (!filePath || !policy.extensions.test(target)) {
872
+ const target = rule.matchBasename ? (filePath ? basename(resolve(filePath)) : '') : filePath;
873
+ if (!filePath || !rule.extensions.test(target)) {
877
874
  const reason = filePath
878
- ? `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.`
879
876
  : 'Write denied: could not determine target file path. Failing closed.';
880
877
  return { decision: 'deny', reason };
881
878
  }
882
879
  return { decision: 'defer' };
883
880
  }
884
- /**
885
- * Strip/map behavior meta-fields so the roster is a clean AgentDefinition set
886
- * for the SDK: drop `policy` (enforced in-process by the write-gate) and map
887
- * `reminder` → criticalSystemReminder_EXPERIMENTAL. NEVER mutates the input.
888
- */
889
- function finalizeRoster(agents) {
890
- const out = {};
891
- for (const [name, agent] of Object.entries(agents)) {
892
- const { policy, reminder, ...rest } = agent;
893
- if (reminder && !rest.criticalSystemReminder_EXPERIMENTAL) {
894
- rest.criticalSystemReminder_EXPERIMENTAL = reminder;
895
- }
896
- out[name] = rest;
897
- }
898
- return out;
899
- }
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
+ };
900
890
  const RESEARCH_TOOLS = [
901
891
  'Read', 'Write', 'Edit', 'Glob', 'Grep',
902
892
  'Bash', 'WebSearch', 'WebFetch',
@@ -2016,10 +2006,6 @@ class ClaudeLLMStream extends llm.LLMStream {
2016
2006
  ? getSessionWorkspace(this.#opts.workingDirectory, sessionId)
2017
2007
  : null;
2018
2008
  const allowedTools = this.#opts.allowedTools || [];
2019
- // Roster the PreToolUse write-gate reads policy from — the PRE-strip view
2020
- // (retains `policy`), so DB-backed custom agents (set_agents) get their
2021
- // write policy enforced too. The SDK receives the finalized copy (agents:).
2022
- const enforcementRoster = this.#opts.agents ?? NAMED_AGENTS;
2023
2009
  const sdkOptions = {
2024
2010
  cwd: this.#opts.workingDirectory,
2025
2011
  permissionMode: this.#opts.permissionMode,
@@ -2137,23 +2123,22 @@ class ClaudeLLMStream extends llm.LLMStream {
2137
2123
  }
2138
2124
  console.log(`🔧 Tool call ${turnToolCallCount}/${TOOL_CALL_BUDGET}: ${toolName}`);
2139
2125
  }
2140
- // Write/Edit/MultiEdit access control — DATA-DRIVEN by each agent's
2141
- // declarative policy.write (see AgentWritePolicy). Replaces the old
2142
- // 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.
2143
2129
  if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
2144
2130
  const filePath = String(toolInput.file_path || '');
2145
- const policy = resolveWritePolicy(agentType, enforcementRoster);
2146
- const { decision, reason } = decideWrite(policy, filePath);
2147
- 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}`);
2148
2133
  if (decision === 'deny') {
2149
2134
  this.#eventEmitter.emit('tool_blocked', { name: toolName, reason });
2150
2135
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' }, reason };
2151
2136
  }
2152
2137
  if (decision === 'defer') {
2153
- // Writer (write:'anywhere') surfaces its write in the live trace
2154
- // before the permission dialog; adversarial agents (reviewer/tester)
2155
- // do NOT emit here — parity with the prior hardcoded branches.
2156
- 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') {
2157
2142
  this.#eventEmitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: agentType || 'main' });
2158
2143
  }
2159
2144
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask' } };
@@ -2501,6 +2486,27 @@ class ClaudeLLMStream extends llm.LLMStream {
2501
2486
  return {};
2502
2487
  }]
2503
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
+ }],
2504
2510
  SubagentStart: [{
2505
2511
  matcher: '.*',
2506
2512
  hooks: [async (input) => {
@@ -2518,15 +2524,33 @@ class ClaudeLLMStream extends llm.LLMStream {
2518
2524
  const aid = input?.agent_id ?? ('sa-' + Date.now());
2519
2525
  statusManager.upsertDispatch(aid, { subagentType: at, dispatchState: 'completed', artifact: msg });
2520
2526
  this.#eventEmitter.emit('task_completed', { agent_type: at, agent_id: aid, last_assistant_message: String(msg).slice(0, 400) });
2521
- // Infinite-loop guard — never re-dispatch the reviewer, tester, or reasoner.
2527
+ // Infinite-loop guard — verifiers never re-dispatch (they carry no
2528
+ // VERIFIER_CHAIN entry anyway; defense-in-depth).
2522
2529
  if (at === 'reviewer' || at === 'tester' || at === 'reasoner')
2523
2530
  return {};
2524
- if (at === 'writer' && msg) {
2525
- void this.#llmRef.spawnReviewer(aid, msg, this.#eventEmitter);
2526
- void this.#llmRef.spawnTester(aid, msg, this.#eventEmitter);
2527
- }
2528
- else if (at === 'researcher' && msg) {
2529
- void this.#llmRef.spawnResearchGate(aid, msg, this.#eventEmitter);
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) {
2535
+ const spawn = (role) => {
2536
+ if (role === 'reviewer')
2537
+ return this.#llmRef.spawnReviewer(aid, msg, this.#eventEmitter);
2538
+ if (role === 'tester')
2539
+ return this.#llmRef.spawnTester(aid, msg, this.#eventEmitter);
2540
+ if (role === 'reasoner' || role === 'gate')
2541
+ return this.#llmRef.spawnResearchGate(aid, msg, this.#eventEmitter);
2542
+ console.warn(`[DISPATCH] unknown chain target '${role}' for ${at} — skipped`);
2543
+ return Promise.resolve();
2544
+ };
2545
+ if (chain.mode === 'sequential') {
2546
+ // Await in order WITHOUT blocking the hook return (fire the chain async).
2547
+ void (async () => { for (const r of chain.then)
2548
+ await spawn(r); })();
2549
+ }
2550
+ else {
2551
+ for (const r of chain.then)
2552
+ void spawn(r);
2553
+ }
2530
2554
  }
2531
2555
  return {};
2532
2556
  }]
@@ -2553,9 +2577,9 @@ class ClaudeLLMStream extends llm.LLMStream {
2553
2577
  // opts.agents is undefined — the ?? NAMED_AGENTS fallback would skip
2554
2578
  // the override entirely. Explicitly apply applyTurbo(NAMED_AGENTS)
2555
2579
  // so built-in agents always get FAST_MODEL when turbo is on.
2556
- agents: finalizeRoster(applyGrounding(this.#llmRef.turbo
2580
+ agents: applyGrounding(this.#llmRef.turbo
2557
2581
  ? applyTurbo(this.#opts.agents ?? NAMED_AGENTS, true)
2558
- : (this.#opts.agents ?? NAMED_AGENTS), this.#sessionId, this.#opts.workingDirectory)),
2582
+ : (this.#opts.agents ?? NAMED_AGENTS), this.#sessionId, this.#opts.workingDirectory),
2559
2583
  };
2560
2584
  // Run Claude Agent SDK query() and stream results
2561
2585
  let hasOutput = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.218",
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": {