@kinqs/brainrouter-cli 0.3.5 → 0.3.7

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 (125) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/bin/cli.cjs +71 -0
  8. package/dist/agent/agent.d.ts +224 -3
  9. package/dist/agent/agent.js +561 -55
  10. package/dist/cli/banner.d.ts +80 -0
  11. package/dist/cli/banner.js +232 -0
  12. package/dist/cli/cliPrompt.d.ts +106 -0
  13. package/dist/cli/cliPrompt.js +314 -0
  14. package/dist/cli/commands/_context.d.ts +3 -1
  15. package/dist/cli/commands/_helpers.d.ts +1 -1
  16. package/dist/cli/commands/_helpers.js +6 -6
  17. package/dist/cli/commands/config.d.ts +46 -0
  18. package/dist/cli/commands/config.js +1042 -0
  19. package/dist/cli/commands/guard.js +75 -10
  20. package/dist/cli/commands/init.d.ts +20 -0
  21. package/dist/cli/commands/init.js +64 -0
  22. package/dist/cli/commands/login.d.ts +13 -0
  23. package/dist/cli/commands/login.js +179 -0
  24. package/dist/cli/commands/mcp.d.ts +19 -0
  25. package/dist/cli/commands/mcp.js +286 -0
  26. package/dist/cli/commands/memory.js +2 -2
  27. package/dist/cli/commands/obs.js +22 -22
  28. package/dist/cli/commands/orchestration.js +18 -0
  29. package/dist/cli/commands/session.js +13 -5
  30. package/dist/cli/commands/ui.js +202 -91
  31. package/dist/cli/commands/workflow.d.ts +20 -0
  32. package/dist/cli/commands/workflow.js +368 -51
  33. package/dist/cli/ink/ChatApp.d.ts +206 -0
  34. package/dist/cli/ink/ChatApp.js +493 -0
  35. package/dist/cli/ink/Frame.d.ts +26 -0
  36. package/dist/cli/ink/Frame.js +5 -0
  37. package/dist/cli/ink/Picker.d.ts +65 -0
  38. package/dist/cli/ink/Picker.js +133 -0
  39. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  40. package/dist/cli/ink/SlashPalette.js +136 -0
  41. package/dist/cli/ink/TextField.d.ts +34 -0
  42. package/dist/cli/ink/TextField.js +47 -0
  43. package/dist/cli/ink/WizardApp.d.ts +7 -0
  44. package/dist/cli/ink/WizardApp.js +422 -0
  45. package/dist/cli/ink/ambientChat.d.ts +34 -0
  46. package/dist/cli/ink/ambientChat.js +7 -0
  47. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  48. package/dist/cli/ink/consoleCapture.js +33 -0
  49. package/dist/cli/ink/markdownRender.d.ts +41 -0
  50. package/dist/cli/ink/markdownRender.js +278 -0
  51. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  52. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  53. package/dist/cli/ink/runChat.d.ts +34 -0
  54. package/dist/cli/ink/runChat.js +571 -0
  55. package/dist/cli/ink/runPicker.d.ts +31 -0
  56. package/dist/cli/ink/runPicker.js +139 -0
  57. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  58. package/dist/cli/ink/runSlashPalette.js +33 -0
  59. package/dist/cli/ink/runWizard.d.ts +22 -0
  60. package/dist/cli/ink/runWizard.js +133 -0
  61. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  62. package/dist/cli/ink/stdinHandoff.js +78 -0
  63. package/dist/cli/ink/toolFormat.d.ts +73 -0
  64. package/dist/cli/ink/toolFormat.js +180 -0
  65. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  66. package/dist/cli/ink/useTerminalSize.js +26 -0
  67. package/dist/cli/repl.d.ts +25 -3
  68. package/dist/cli/repl.js +64 -646
  69. package/dist/cli/slashSuggest.d.ts +32 -0
  70. package/dist/cli/slashSuggest.js +146 -0
  71. package/dist/cli/spinner.d.ts +34 -0
  72. package/dist/cli/spinner.js +36 -0
  73. package/dist/cli/statusline.d.ts +67 -0
  74. package/dist/cli/statusline.js +204 -0
  75. package/dist/cli/theme.d.ts +79 -0
  76. package/dist/cli/theme.js +106 -0
  77. package/dist/cli/whereView.d.ts +81 -0
  78. package/dist/cli/whereView.js +245 -0
  79. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  80. package/dist/cli/wizard/modelsApi.js +166 -0
  81. package/dist/cli/wizard/picker.d.ts +202 -0
  82. package/dist/cli/wizard/picker.js +547 -0
  83. package/dist/cli/wizard/providers.d.ts +86 -0
  84. package/dist/cli/wizard/providers.js +190 -0
  85. package/dist/cli/wizard/runner.d.ts +13 -0
  86. package/dist/cli/wizard/runner.js +488 -0
  87. package/dist/cli/wizard/types.d.ts +122 -0
  88. package/dist/cli/wizard/types.js +109 -0
  89. package/dist/config/config.d.ts +52 -0
  90. package/dist/config/config.js +89 -75
  91. package/dist/index.js +215 -206
  92. package/dist/memory/briefing.d.ts +11 -1
  93. package/dist/memory/briefing.js +69 -1
  94. package/dist/memory/consolidation.d.ts +1 -1
  95. package/dist/orchestration/agentRegistry.d.ts +36 -0
  96. package/dist/orchestration/agentRegistry.js +64 -0
  97. package/dist/orchestration/orchestrator.d.ts +7 -0
  98. package/dist/orchestration/orchestrator.js +2 -0
  99. package/dist/orchestration/tools.d.ts +10 -1
  100. package/dist/orchestration/tools.js +48 -4
  101. package/dist/prompt/breadthHint.d.ts +5 -0
  102. package/dist/prompt/breadthHint.js +44 -0
  103. package/dist/prompt/skillCatalog.d.ts +11 -0
  104. package/dist/prompt/skillCatalog.js +134 -0
  105. package/dist/prompt/skillRunner.d.ts +2 -2
  106. package/dist/prompt/skillRunner.js +2 -31
  107. package/dist/prompt/systemPrompt.d.ts +34 -0
  108. package/dist/prompt/systemPrompt.js +128 -108
  109. package/dist/runtime/dangerousCommand.d.ts +53 -0
  110. package/dist/runtime/dangerousCommand.js +105 -0
  111. package/dist/runtime/mcpClient.d.ts +38 -1
  112. package/dist/runtime/mcpClient.js +104 -13
  113. package/dist/runtime/mcpPool.d.ts +162 -0
  114. package/dist/runtime/mcpPool.js +423 -0
  115. package/dist/runtime/mcpUtils.d.ts +3 -1
  116. package/dist/state/goalStore.d.ts +98 -17
  117. package/dist/state/goalStore.js +132 -42
  118. package/dist/state/preferencesStore.d.ts +67 -3
  119. package/dist/state/preferencesStore.js +84 -1
  120. package/dist/state/workflowArtifacts.d.ts +63 -2
  121. package/dist/state/workflowArtifacts.js +120 -8
  122. package/dist/tests/_helpers.d.ts +31 -0
  123. package/dist/tests/_helpers.js +91 -0
  124. package/package.json +12 -5
  125. package/.env.example +0 -109
@@ -0,0 +1,36 @@
1
+ export type Tier = 'chat' | 'reasoning' | 'worker';
2
+ export type AccessMode = 'read' | 'write' | 'shell';
3
+ export interface AgentDefinition {
4
+ id: string;
5
+ displayName: string;
6
+ whenToUse: string;
7
+ prompt: string;
8
+ model: string | null;
9
+ effort: string | null;
10
+ defaultAccess: AccessMode;
11
+ toolScope: {
12
+ local: string[];
13
+ mcp: string[];
14
+ };
15
+ disallowedTools: string[];
16
+ maxIterations: number;
17
+ timeoutMs: number;
18
+ maxResultChars: number;
19
+ subagents: string[];
20
+ delegateName: string;
21
+ tier: Tier;
22
+ outputContract: unknown;
23
+ }
24
+ export type DefinitionSource = 'builtin' | 'user' | 'workspace';
25
+ export interface LoadedDefinition {
26
+ def: AgentDefinition;
27
+ source: DefinitionSource;
28
+ filePath: string;
29
+ }
30
+ /**
31
+ * Load all agent definitions from three tiers (builtin → user-global → workspace).
32
+ * Same `id` from a higher-priority source wins; distinct ids coexist.
33
+ */
34
+ export declare function loadRegistry(workspaceRoot?: string): LoadedDefinition[];
35
+ export declare function findById(id: string, workspaceRoot?: string): LoadedDefinition | undefined;
36
+ export declare function listAll(workspaceRoot?: string): LoadedDefinition[];
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ // Resolved at import time from dist/orchestration/agentRegistry.js → ../../agents
6
+ const BUILTIN_AGENTS_DIR = fileURLToPath(new URL('../../agents', import.meta.url));
7
+ function getUserAgentsDir() {
8
+ const home = process.env.BRAINROUTER_HOME ?? path.join(os.homedir(), '.config', 'brainrouter');
9
+ return path.join(home, 'agents');
10
+ }
11
+ function getWorkspaceAgentsDir(workspaceRoot) {
12
+ return path.join(workspaceRoot, '.brainrouter', 'agents');
13
+ }
14
+ function loadFromDir(dir, source) {
15
+ if (!fs.existsSync(dir))
16
+ return [];
17
+ let entries;
18
+ try {
19
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ const results = [];
25
+ for (const entry of entries) {
26
+ const filePath = path.join(dir, entry);
27
+ try {
28
+ const raw = fs.readFileSync(filePath, 'utf-8');
29
+ const def = JSON.parse(raw);
30
+ if (!def.id || typeof def.id !== 'string') {
31
+ console.error(`[agentRegistry] Skipping ${filePath}: missing or invalid "id" field.`);
32
+ continue;
33
+ }
34
+ results.push({ def, source, filePath });
35
+ }
36
+ catch (err) {
37
+ console.error(`[agentRegistry] Skipping ${filePath}: ${err.message}`);
38
+ }
39
+ }
40
+ return results;
41
+ }
42
+ /**
43
+ * Load all agent definitions from three tiers (builtin → user-global → workspace).
44
+ * Same `id` from a higher-priority source wins; distinct ids coexist.
45
+ */
46
+ export function loadRegistry(workspaceRoot) {
47
+ const builtin = loadFromDir(BUILTIN_AGENTS_DIR, 'builtin');
48
+ const user = loadFromDir(getUserAgentsDir(), 'user');
49
+ const workspace = workspaceRoot
50
+ ? loadFromDir(getWorkspaceAgentsDir(workspaceRoot), 'workspace')
51
+ : [];
52
+ // Precedence: builtin first (lowest), workspace last (highest).
53
+ const merged = new Map();
54
+ for (const loaded of [...builtin, ...user, ...workspace]) {
55
+ merged.set(loaded.def.id, loaded);
56
+ }
57
+ return Array.from(merged.values());
58
+ }
59
+ export function findById(id, workspaceRoot) {
60
+ return loadRegistry(workspaceRoot).find((l) => l.def.id === id);
61
+ }
62
+ export function listAll(workspaceRoot) {
63
+ return loadRegistry(workspaceRoot);
64
+ }
@@ -1,4 +1,5 @@
1
1
  import { type AccessMode } from './roles.js';
2
+ import type { Tier } from './agentRegistry.js';
2
3
  export type ChildStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stale' | 'closed';
3
4
  export interface ChildSessionRecord {
4
5
  id: string;
@@ -14,6 +15,10 @@ export interface ChildSessionRecord {
14
15
  pid: number;
15
16
  finalOutput?: string;
16
17
  error?: string;
18
+ /** Agent tier from the definition (reasoning | worker). Undefined for legacy records. */
19
+ tier?: Tier;
20
+ /** Nesting depth in the spawn chain; 0 = direct child of the chat root. */
21
+ depth?: number;
17
22
  /** LLM usage attributable to this child (filled when the child completes). */
18
23
  usage?: {
19
24
  promptTokens: number;
@@ -30,6 +35,8 @@ export declare function createSession(workspaceRoot: string, input: {
30
35
  parentSessionKey: string;
31
36
  access?: AccessMode;
32
37
  label?: string;
38
+ tier?: Tier;
39
+ depth?: number;
33
40
  }): ChildSessionRecord;
34
41
  export declare function updateSession(workspaceRoot: string, id: string, patch: Partial<ChildSessionRecord>): ChildSessionRecord;
35
42
  export declare function reconcileStale(workspaceRoot: string): number;
@@ -28,6 +28,8 @@ export function createSession(workspaceRoot, input) {
28
28
  startedAt: now,
29
29
  updatedAt: now,
30
30
  pid: process.pid,
31
+ tier: input.tier,
32
+ depth: input.depth,
31
33
  };
32
34
  const data = readFile(workspaceRoot);
33
35
  data.sessions.push(record);
@@ -1,6 +1,7 @@
1
- import type { McpClientWrapper } from '../runtime/mcpClient.js';
1
+ import type { McpClientPool as McpClientWrapper } from '../runtime/mcpPool.js';
2
2
  import type { LLMConfig } from '../config/config.js';
3
3
  import { type AccessMode } from './roles.js';
4
+ import { type Tier } from './agentRegistry.js';
4
5
  export interface OrchestrationContext {
5
6
  workspaceRoot: string;
6
7
  parentSessionKey: string;
@@ -21,6 +22,10 @@ export interface OrchestrationContext {
21
22
  parentSpanId?: string;
22
23
  /** Parent agent_id so children can be grouped via attribute even without trace links. */
23
24
  parentAgentId?: string;
25
+ /** Parent agent tier — used for hierarchy checks (worker cannot spawn; reasoning can only spawn workers). */
26
+ parentTier?: Tier;
27
+ /** Current spawn-chain depth (0 = direct child of chat root). */
28
+ depth?: number;
24
29
  mcpClient: McpClientWrapper;
25
30
  llmConfig: LLMConfig;
26
31
  launchCwd: string;
@@ -76,6 +81,10 @@ export declare function createSpawnAgentTool(): {
76
81
  type: string;
77
82
  description: string;
78
83
  };
84
+ agentId: {
85
+ type: string;
86
+ description: string;
87
+ };
79
88
  prompt: {
80
89
  type: string;
81
90
  description: string;
@@ -1,6 +1,7 @@
1
1
  import { Agent } from '../agent/agent.js';
2
2
  import { createSession, formatSessionSummary, getSession, listSessions, updateSession, } from './orchestrator.js';
3
3
  import { buildRolePrompt, resolveRole } from './roles.js';
4
+ import { findById, listAll } from './agentRegistry.js';
4
5
  import { buildSystemPrompt, loadWorkspaceInstructionSummary } from '../prompt/systemPrompt.js';
5
6
  import { readTranscriptEntries } from '../state/sessionStore.js';
6
7
  import { callMcpTool, childSessionKey } from '../runtime/mcpUtils.js';
@@ -100,11 +101,12 @@ export function trackedPromiseFor(id) {
100
101
  export function createSpawnAgentTool() {
101
102
  return {
102
103
  name: 'spawn_agent',
103
- description: 'Spawn a child agent with a specific role (explorer, architect, reviewer, worker, verifier) and a bounded prompt. Returns the child agent id immediately; the child runs in the background.',
104
+ description: 'Spawn a child agent and a bounded prompt. Returns the child agent id immediately; the child runs in the background. Specify the agent via `role` (legacy: explorer/architect/reviewer/worker/verifier) or `agentId` (registry id, e.g. a custom workspace definition).',
104
105
  inputSchema: {
105
106
  type: 'object',
106
107
  properties: {
107
- role: { type: 'string', description: 'One of: explorer, architect, reviewer, worker, verifier.' },
108
+ role: { type: 'string', description: 'One of: explorer, architect, reviewer, worker, verifier. Prefer agentId for custom definitions.' },
109
+ agentId: { type: 'string', description: 'Registry id of the agent definition. Takes precedence over role when both are provided.' },
108
110
  prompt: { type: 'string', description: 'The bounded task prompt for the child agent.' },
109
111
  label: { type: 'string', description: 'Optional short label for the child run.' },
110
112
  access: { type: 'string', enum: ['read', 'write', 'shell'], description: 'Override the role default access mode. Default: role default.' },
@@ -116,7 +118,7 @@ export function createSpawnAgentTool() {
116
118
  description: 'Optional BrainRouter memory record IDs that the parent already recalled. The child agent is told to build on these instead of re-discovering them.',
117
119
  },
118
120
  },
119
- required: ['role', 'prompt'],
121
+ required: ['prompt'],
120
122
  },
121
123
  };
122
124
  }
@@ -297,10 +299,47 @@ function explainRoute(task, role) {
297
299
  }
298
300
  }
299
301
  async function handleSpawn(args, ctx) {
300
- const role = resolveRole(String(args.role));
302
+ // Resolve agent definition via agentId (registry) or role (legacy).
303
+ let role;
304
+ let childTier;
305
+ if (typeof args.agentId === 'string' && args.agentId.trim()) {
306
+ const loaded = findById(args.agentId.trim(), ctx.workspaceRoot);
307
+ if (!loaded) {
308
+ const known = listAll(ctx.workspaceRoot).map((l) => l.def.id).join(', ');
309
+ throw new Error(`Unknown agentId "${args.agentId}". Known agents: ${known}.`);
310
+ }
311
+ role = {
312
+ name: loaded.def.id,
313
+ description: loaded.def.whenToUse,
314
+ defaultAccess: loaded.def.defaultAccess,
315
+ promptOverlay: loaded.def.prompt,
316
+ };
317
+ childTier = loaded.def.tier;
318
+ }
319
+ else {
320
+ const roleName = String(args.role ?? '');
321
+ if (!roleName.trim())
322
+ throw new Error('spawn_agent requires either "agentId" or "role".');
323
+ role = resolveRole(roleName);
324
+ childTier = findById(role.name, ctx.workspaceRoot)?.def.tier;
325
+ }
301
326
  const prompt = String(args.prompt ?? '');
302
327
  if (!prompt.trim())
303
328
  throw new Error('spawn_agent requires a non-empty prompt.');
329
+ // P1.2 — spawn hierarchy checks.
330
+ const rawMaxDepth = parseInt(process.env.BRAINROUTER_MAX_SPAWN_DEPTH ?? '3', 10);
331
+ const maxDepth = Number.isFinite(rawMaxDepth) && rawMaxDepth > 0 ? rawMaxDepth : 3;
332
+ const currentDepth = ctx.depth ?? 0;
333
+ const parentTier = ctx.parentTier;
334
+ if (parentTier === 'worker') {
335
+ throw new Error('Tier "worker" cannot delegate — ask the parent agent to spawn instead.');
336
+ }
337
+ if (parentTier === 'reasoning' && childTier && (childTier === 'chat' || childTier === 'reasoning')) {
338
+ throw new Error(`Tier "reasoning" cannot spawn a "${childTier}" agent — only "worker" children are allowed.`);
339
+ }
340
+ if (currentDepth >= maxDepth) {
341
+ throw new Error(`Spawn depth cap reached (${currentDepth}/${maxDepth}). Reduce agent nesting or raise BRAINROUTER_MAX_SPAWN_DEPTH.`);
342
+ }
304
343
  const requested = args.access ?? role.defaultAccess;
305
344
  const access = clampAccess(ctx.parentAccessMode ?? 'shell', requested);
306
345
  const record = createSession(ctx.workspaceRoot, {
@@ -309,6 +348,8 @@ async function handleSpawn(args, ctx) {
309
348
  parentSessionKey: ctx.parentSessionKey,
310
349
  access,
311
350
  label: typeof args.label === 'string' ? args.label : undefined,
351
+ tier: childTier,
352
+ depth: currentDepth + 1,
312
353
  });
313
354
  const childKey = childSessionKey(ctx.parentSessionKey, record.id);
314
355
  const seededIds = Array.isArray(args.seedRecordIds)
@@ -346,6 +387,9 @@ async function handleSpawn(args, ctx) {
346
387
  // dispatching spawn_agent tool span instead of starting a fresh tree.
347
388
  parentTraceId: ctx.parentTraceId,
348
389
  parentSpanId: ctx.parentSpanId,
390
+ // Propagate tier and depth so grandchildren can enforce hierarchy caps.
391
+ tier: childTier,
392
+ agentDepth: currentDepth + 1,
349
393
  });
350
394
  if (ctx.parentAgentId)
351
395
  childAgent.setParentAgentId(ctx.parentAgentId);
@@ -36,9 +36,14 @@ export declare function detectBreadthIntent(prompt: string): BreadthIntent;
36
36
  * turn that should have been parallel.
37
37
  */
38
38
  export declare const BREADTH_FAN_OUT_THRESHOLD = 1.5;
39
+ export declare function detectFanOutVeto(prompt: string): {
40
+ vetoed: boolean;
41
+ pattern?: string;
42
+ };
39
43
  export declare function shouldSuggestFanOut(prompt: string): {
40
44
  suggest: boolean;
41
45
  intent: BreadthIntent;
46
+ veto?: string;
42
47
  };
43
48
  /**
44
49
  * The system message we inject to nudge the agent toward spawn_agents. It
@@ -64,8 +64,52 @@ export function detectBreadthIntent(prompt) {
64
64
  * turn that should have been parallel.
65
65
  */
66
66
  export const BREADTH_FAN_OUT_THRESHOLD = 1.5;
67
+ /**
68
+ * Negation hints — explicit signals from the user that they DO NOT want
69
+ * fan-out for this prompt. Honored as a hard veto: even a high breadth
70
+ * score won't trigger the hint if any of these match.
71
+ *
72
+ * Common cases we want to honor:
73
+ * - "(no spawn_agent, no fan-out, files are small)" — explicit opt-out
74
+ * - "do this in one turn" — wants serial
75
+ * - "directly with read_file, no fan-out" — explicit tool
76
+ * - "yourself, don't spawn agents" — explicit self
77
+ *
78
+ * Without this veto, a prompt like
79
+ * "audit every file (no spawn_agent, files are small)"
80
+ * still scores high on `verb-object-broad` + `every` and the model gets
81
+ * told "fan out!" — directly contradicting the user's instruction.
82
+ */
83
+ const NEGATION_PATTERNS = [
84
+ /\bno\s+(spawn[_-]?agents?|fan[- ]?out|children?|sub[- ]?agents?|orchestration)\b/i,
85
+ /\b(don'?t|do not)\s+(spawn|fan[- ]?out|delegate|orchestrate)\b/i,
86
+ /\b(in\s+one\s+turn|single\s+turn|sequentially|one[- ]by[- ]one|in[- ]process)\b/i,
87
+ /\bdirectly\s+(with|using|via)\b/i,
88
+ /\b(yourself|by\s+yourself|on\s+your\s+own)\b/i,
89
+ ];
90
+ export function detectFanOutVeto(prompt) {
91
+ const text = (prompt ?? '').toString();
92
+ for (const re of NEGATION_PATTERNS) {
93
+ const match = text.match(re);
94
+ if (match)
95
+ return { vetoed: true, pattern: match[0] };
96
+ }
97
+ return { vetoed: false };
98
+ }
67
99
  export function shouldSuggestFanOut(prompt) {
68
100
  const intent = detectBreadthIntent(prompt);
101
+ const veto = detectFanOutVeto(prompt);
102
+ if (veto.vetoed) {
103
+ // Reflect the veto in the intent's signals so onToolEnd's surfacing
104
+ // shows the user why we didn't fan out, even though the breadth
105
+ // score was high. The score itself isn't zeroed — it's still useful
106
+ // signal for other heuristics.
107
+ return {
108
+ suggest: false,
109
+ intent: { ...intent, signals: [...intent.signals, `vetoed:${veto.pattern}`] },
110
+ veto: veto.pattern,
111
+ };
112
+ }
69
113
  return { suggest: intent.score >= BREADTH_FAN_OUT_THRESHOLD, intent };
70
114
  }
71
115
  /**
@@ -0,0 +1,11 @@
1
+ export interface SkillListItem {
2
+ name: string;
3
+ scope?: string;
4
+ category?: string;
5
+ description?: string;
6
+ source?: 'mcp' | 'filesystem';
7
+ }
8
+ export declare function listFilesystemSkills(workspaceRoot: string): SkillListItem[];
9
+ export declare function mergeSkillLists(primary: SkillListItem[], fallback: SkillListItem[]): SkillListItem[];
10
+ export declare function sortSkills(a: SkillListItem, b: SkillListItem): number;
11
+ export declare function skillSearchRoots(workspaceRoot: string): string[];
@@ -0,0 +1,134 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ const requireFromHere = createRequire(import.meta.url);
5
+ const WORKSPACE_SKILL_ROOTS = ['skills', '.brainrouter/skills'];
6
+ export function listFilesystemSkills(workspaceRoot) {
7
+ const seen = new Map();
8
+ for (const root of skillSearchRoots(workspaceRoot)) {
9
+ if (!fs.existsSync(root))
10
+ continue;
11
+ const scope = inferRootScope(root, workspaceRoot);
12
+ for (const filePath of findSkillFiles(root)) {
13
+ const parsed = parseSkillFile(filePath);
14
+ if (!parsed)
15
+ continue;
16
+ const rel = path.relative(root, filePath);
17
+ const category = rel.split(path.sep)[0] || 'uncategorized';
18
+ if (!seen.has(parsed.name)) {
19
+ seen.set(parsed.name, {
20
+ name: parsed.name,
21
+ category,
22
+ description: parsed.description,
23
+ scope,
24
+ source: 'filesystem',
25
+ });
26
+ }
27
+ }
28
+ }
29
+ return Array.from(seen.values()).sort(sortSkills);
30
+ }
31
+ export function mergeSkillLists(primary, fallback) {
32
+ const merged = new Map();
33
+ for (const skill of primary) {
34
+ merged.set(skill.name, { ...skill, source: skill.source ?? 'mcp' });
35
+ }
36
+ for (const skill of fallback) {
37
+ if (!merged.has(skill.name))
38
+ merged.set(skill.name, skill);
39
+ }
40
+ return Array.from(merged.values()).sort(sortSkills);
41
+ }
42
+ export function sortSkills(a, b) {
43
+ return (a.category ?? '').localeCompare(b.category ?? '') || a.name.localeCompare(b.name);
44
+ }
45
+ export function skillSearchRoots(workspaceRoot) {
46
+ const roots = [];
47
+ for (const sub of WORKSPACE_SKILL_ROOTS)
48
+ roots.push(path.join(workspaceRoot, sub));
49
+ const mcpPkgDir = resolveInstalledMcpPackageDir();
50
+ if (mcpPkgDir) {
51
+ roots.push(path.join(mcpPkgDir, 'skills'));
52
+ const monorepoRoot = path.dirname(mcpPkgDir);
53
+ roots.push(path.join(monorepoRoot, 'skills'));
54
+ }
55
+ return [...new Set(roots.map((root) => path.resolve(root)))];
56
+ }
57
+ function resolveInstalledMcpPackageDir() {
58
+ try {
59
+ const pkgJsonPath = requireFromHere.resolve('@kinqs/brainrouter-mcp-server/package.json');
60
+ return path.dirname(pkgJsonPath);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ function inferRootScope(root, workspaceRoot) {
67
+ const resolvedWorkspace = path.resolve(workspaceRoot);
68
+ const resolvedRoot = path.resolve(root);
69
+ if (resolvedRoot.startsWith(path.join(resolvedWorkspace, '.brainrouter')))
70
+ return 'local';
71
+ if (isBrainRouterRepoRoot(path.dirname(resolvedRoot)))
72
+ return 'global';
73
+ return resolvedRoot.startsWith(resolvedWorkspace) ? 'local' : 'global';
74
+ }
75
+ function isBrainRouterRepoRoot(root) {
76
+ return (fs.existsSync(path.join(root, 'brainrouter', 'package.json')) &&
77
+ fs.existsSync(path.join(root, 'brainrouter-cli', 'package.json')) &&
78
+ fs.existsSync(path.join(root, 'skills')));
79
+ }
80
+ function findSkillFiles(root) {
81
+ const results = [];
82
+ function walk(current, depth) {
83
+ if (depth < 0)
84
+ return;
85
+ let entries;
86
+ try {
87
+ entries = fs.readdirSync(current, { withFileTypes: true });
88
+ }
89
+ catch {
90
+ return;
91
+ }
92
+ for (const entry of entries) {
93
+ if (entry.name === 'node_modules' || entry.name === '.git')
94
+ continue;
95
+ const full = path.join(current, entry.name);
96
+ if (entry.isDirectory())
97
+ walk(full, depth - 1);
98
+ else if (entry.isFile() && entry.name === 'SKILL.md')
99
+ results.push(full);
100
+ }
101
+ }
102
+ walk(root, 5);
103
+ return results;
104
+ }
105
+ function parseSkillFile(filePath) {
106
+ let raw;
107
+ try {
108
+ raw = fs.readFileSync(filePath, 'utf8');
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
114
+ const block = frontmatter?.[1] ?? '';
115
+ const name = readYamlScalar(block, 'name') ?? path.basename(path.dirname(filePath));
116
+ const description = readYamlScalar(block, 'description') ?? firstParagraph(raw);
117
+ if (!name)
118
+ return undefined;
119
+ return { name, description };
120
+ }
121
+ function readYamlScalar(block, key) {
122
+ const match = block.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
123
+ if (!match?.[1])
124
+ return undefined;
125
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
126
+ }
127
+ function firstParagraph(raw) {
128
+ const withoutFrontmatter = raw.replace(/^---\r?\n[\s\S]*?\r?\n---/, '').trim();
129
+ const line = withoutFrontmatter
130
+ .split(/\r?\n/)
131
+ .map((part) => part.trim())
132
+ .find((part) => part && !part.startsWith('#'));
133
+ return line;
134
+ }
@@ -1,4 +1,4 @@
1
- import type { McpClientWrapper } from '../runtime/mcpClient.js';
1
+ import type { McpClient } from '../runtime/mcpUtils.js';
2
2
  export interface SkillResolution {
3
3
  name: string;
4
4
  body: string;
@@ -24,7 +24,7 @@ export declare const SLASH_TO_SKILL: Record<string, string>;
24
24
  * server has loaded, including their own private skills), falls back to a
25
25
  * local filesystem scan of `skills/` for when the MCP tool is unavailable.
26
26
  */
27
- export declare function resolveSkill(mcpClient: McpClientWrapper, name: string, workspaceRoot: string, section?: RunSkillOptions['section']): Promise<SkillResolution>;
27
+ export declare function resolveSkill(mcpClient: McpClient, name: string, workspaceRoot: string, section?: RunSkillOptions['section']): Promise<SkillResolution>;
28
28
  /**
29
29
  * Build the user prompt that asks the agent to execute a skill. The skill
30
30
  * body is embedded so the agent does not need to round-trip through
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
- const requireFromHere = createRequire(import.meta.url);
3
+ import { skillSearchRoots } from './skillCatalog.js';
5
4
  /**
6
5
  * Slash-command → skill mapping. Each entry names the skill catalogued in
7
6
  * the BrainRouter skills/ folder (and exposed by the MCP server) that the
@@ -21,7 +20,6 @@ export const SLASH_TO_SKILL = {
21
20
  '/refactor': 'code-simplification',
22
21
  '/test': 'testing-skill',
23
22
  };
24
- const WORKSPACE_SKILL_ROOTS = ['skills', '.brainrouter/skills'];
25
23
  /**
26
24
  * Resolve a skill by name. Prefers the MCP server (so users get whatever the
27
25
  * server has loaded, including their own private skills), falls back to a
@@ -61,34 +59,7 @@ function readSkillFromFilesystem(workspaceRoot, name) {
61
59
  }
62
60
  return undefined;
63
61
  }
64
- /**
65
- * Roots to search for SKILL.md when the MCP server is unavailable. Includes:
66
- * - the user's workspace (so per-project skills win locally)
67
- * - the installed @kinqs/brainrouter-mcp-server package directory (so the canonical
68
- * BrainRouter catalogue is found even when MCP is down, because prepack
69
- * bundles `skills/` into the published package)
70
- * - the monorepo root (when running from source during development)
71
- */
72
- function skillSearchRoots(workspaceRoot) {
73
- const roots = [];
74
- for (const sub of WORKSPACE_SKILL_ROOTS) {
75
- roots.push(path.join(workspaceRoot, sub));
76
- }
77
- const mcpPkgDir = resolveInstalledMcpPackageDir();
78
- if (mcpPkgDir)
79
- roots.push(path.join(mcpPkgDir, 'skills'));
80
- return roots;
81
- }
82
- function resolveInstalledMcpPackageDir() {
83
- try {
84
- const pkgJsonPath = requireFromHere.resolve('@kinqs/brainrouter-mcp-server/package.json');
85
- return path.dirname(pkgJsonPath);
86
- }
87
- catch {
88
- return undefined;
89
- }
90
- }
91
- function findSkillDir(rootDir, skillName, depth = 3) {
62
+ function findSkillDir(rootDir, skillName, depth = 5) {
92
63
  if (depth < 0)
93
64
  return undefined;
94
65
  let entries;
@@ -5,6 +5,40 @@ export interface SystemPromptContext {
5
5
  instructionSummary?: string;
6
6
  /** Communication style overlay set by /personality. */
7
7
  personality?: 'concise' | 'standard' | 'detailed' | 'pair-programmer';
8
+ /**
9
+ * Name of the active BrainRouter skill latched by a slash command (e.g.
10
+ * `/spec`, `/feature-dev`, `/grill-me`). Most skills are workflow
11
+ * directives the model loads via `get_skill` and don't change the system
12
+ * prompt — `grill-me` is the exception: it appends a CLARIFY-mode block
13
+ * here so the model asks questions instead of jumping to edits.
14
+ */
15
+ activeSkill?: string;
16
+ /**
17
+ * Execution-mode overlay set by `/mode`. Only `fast` produces an overlay
18
+ * — `planning` is the unchanged default behaviour and adding prose for it
19
+ * would just dilute the rest of the prompt.
20
+ */
21
+ executionMode?: 'planning' | 'fast';
22
+ /**
23
+ * Review-policy overlay set by `/review-policy`. Only `proceed` produces
24
+ * an overlay; `request` is the default behaviour.
25
+ */
26
+ reviewPolicy?: 'request' | 'proceed';
27
+ /**
28
+ * Reasoning-depth overlay set by `/effort` (or `BRAINROUTER_EFFORT`).
29
+ * `medium` is the default and emits no overlay — adding prose for it
30
+ * would silently change behaviour for every existing user on upgrade.
31
+ */
32
+ effort?: 'low' | 'medium' | 'high';
33
+ /**
34
+ * 0.3.6 item 10b: the set of MCP tool names actually connected this turn.
35
+ * When this list lacks `memory_recall` (i.e. the BrainRouter cloud brain
36
+ * is offline), the prompt omits the "BrainRouter MCP Tools" / "Memory-
37
+ * First" sections so the model doesn't try to call tools that don't
38
+ * exist. Undefined = "assume the BrainRouter MCP is online" (pre-10b
39
+ * back-compat for callers that don't pass the inventory).
40
+ */
41
+ connectedMcpTools?: string[];
8
42
  }
9
43
  export declare function buildSystemPrompt(context: SystemPromptContext): string;
10
44
  export declare function loadWorkspaceInstructionSummary(workspaceRoot: string): string | undefined;