@thispointon/kondi-chat 0.1.2

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/bin/kondi-chat +56 -0
  4. package/bin/kondi-chat.js +72 -0
  5. package/package.json +55 -0
  6. package/scripts/demo.tape +49 -0
  7. package/scripts/postinstall.cjs +103 -0
  8. package/src/audit/analytics.ts +261 -0
  9. package/src/audit/ledger.ts +253 -0
  10. package/src/audit/telemetry.ts +165 -0
  11. package/src/cli/backend.ts +675 -0
  12. package/src/cli/commands.ts +419 -0
  13. package/src/cli/help.ts +182 -0
  14. package/src/cli/submit-helpers.ts +159 -0
  15. package/src/cli/submit.ts +539 -0
  16. package/src/cli/wizard.ts +121 -0
  17. package/src/context/bootstrap.ts +138 -0
  18. package/src/context/budget.ts +100 -0
  19. package/src/context/manager.ts +666 -0
  20. package/src/context/memory.ts +160 -0
  21. package/src/context/preflight.ts +176 -0
  22. package/src/context/project-brain.ts +101 -0
  23. package/src/context/receipts.ts +108 -0
  24. package/src/context/skills.ts +154 -0
  25. package/src/context/symbol-index.ts +240 -0
  26. package/src/council/profiles.ts +137 -0
  27. package/src/council/tool.ts +138 -0
  28. package/src/council-engine/cli/council-artifacts.ts +230 -0
  29. package/src/council-engine/cli/council-config.ts +178 -0
  30. package/src/council-engine/cli/council-session-export.ts +116 -0
  31. package/src/council-engine/cli/kondi.ts +98 -0
  32. package/src/council-engine/cli/llm-caller.ts +229 -0
  33. package/src/council-engine/cli/localStorage-shim.ts +119 -0
  34. package/src/council-engine/cli/node-platform.ts +68 -0
  35. package/src/council-engine/cli/run-council.ts +481 -0
  36. package/src/council-engine/cli/run-pipeline.ts +772 -0
  37. package/src/council-engine/cli/session-export.ts +153 -0
  38. package/src/council-engine/configs/councils/analysis.json +101 -0
  39. package/src/council-engine/configs/councils/code-planning.json +86 -0
  40. package/src/council-engine/configs/councils/coding.json +89 -0
  41. package/src/council-engine/configs/councils/debate.json +97 -0
  42. package/src/council-engine/configs/councils/solo-claude.json +34 -0
  43. package/src/council-engine/configs/councils/solo-gpt.json +34 -0
  44. package/src/council-engine/council/coding-orchestrator.ts +1205 -0
  45. package/src/council-engine/council/context-bootstrap.ts +147 -0
  46. package/src/council-engine/council/context-inspection.ts +42 -0
  47. package/src/council-engine/council/context-store.ts +763 -0
  48. package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
  49. package/src/council-engine/council/factory.ts +164 -0
  50. package/src/council-engine/council/index.ts +201 -0
  51. package/src/council-engine/council/ledger-store.ts +438 -0
  52. package/src/council-engine/council/prompts.ts +1689 -0
  53. package/src/council-engine/council/storage-cleanup.ts +164 -0
  54. package/src/council-engine/council/store.ts +1110 -0
  55. package/src/council-engine/council/synthesis.ts +291 -0
  56. package/src/council-engine/council/types.ts +845 -0
  57. package/src/council-engine/council/validation.ts +613 -0
  58. package/src/council-engine/pipeline/build-detect.ts +73 -0
  59. package/src/council-engine/pipeline/executor.ts +1048 -0
  60. package/src/council-engine/pipeline/index.ts +9 -0
  61. package/src/council-engine/pipeline/install-detect.ts +84 -0
  62. package/src/council-engine/pipeline/memory-store.ts +182 -0
  63. package/src/council-engine/pipeline/output-parsers.ts +146 -0
  64. package/src/council-engine/pipeline/run-output.ts +149 -0
  65. package/src/council-engine/pipeline/session-import.ts +177 -0
  66. package/src/council-engine/pipeline/store.ts +753 -0
  67. package/src/council-engine/pipeline/test-detect.ts +82 -0
  68. package/src/council-engine/pipeline/types.ts +401 -0
  69. package/src/council-engine/services/deliberationSummary.ts +114 -0
  70. package/src/council-engine/tsconfig.json +16 -0
  71. package/src/council-engine/types/mcp.ts +122 -0
  72. package/src/council-engine/utils/filterTools.ts +73 -0
  73. package/src/engine/apply.ts +238 -0
  74. package/src/engine/checkpoints.ts +237 -0
  75. package/src/engine/consultants.ts +347 -0
  76. package/src/engine/diff.ts +171 -0
  77. package/src/engine/errors.ts +102 -0
  78. package/src/engine/git-tools.ts +246 -0
  79. package/src/engine/hooks.ts +181 -0
  80. package/src/engine/loop-guard.ts +155 -0
  81. package/src/engine/permissions.ts +293 -0
  82. package/src/engine/pipeline.ts +376 -0
  83. package/src/engine/sub-agents.ts +133 -0
  84. package/src/engine/task-card.ts +185 -0
  85. package/src/engine/task-router.ts +256 -0
  86. package/src/engine/task-store.ts +86 -0
  87. package/src/engine/tools.ts +783 -0
  88. package/src/engine/verify.ts +111 -0
  89. package/src/mcp/client.ts +225 -0
  90. package/src/mcp/config.ts +120 -0
  91. package/src/mcp/tool-manager.ts +192 -0
  92. package/src/mcp/types.ts +61 -0
  93. package/src/providers/llm-caller.ts +943 -0
  94. package/src/providers/rate-limiter.ts +238 -0
  95. package/src/router/NOTES.md +28 -0
  96. package/src/router/collector.ts +474 -0
  97. package/src/router/embeddings.ts +286 -0
  98. package/src/router/index.ts +299 -0
  99. package/src/router/intent-router.ts +225 -0
  100. package/src/router/nn-router.ts +205 -0
  101. package/src/router/profiles.ts +309 -0
  102. package/src/router/registry.ts +565 -0
  103. package/src/router/rules.ts +274 -0
  104. package/src/router/train.py +408 -0
  105. package/src/session/store.ts +211 -0
  106. package/src/test-utils/mock-llm.ts +39 -0
  107. package/src/types.ts +322 -0
  108. package/src/web/manager.ts +311 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Symbol Index — regex-based function/class/export extraction.
3
+ *
4
+ * Scans the working directory at startup and after edits, caches
5
+ * results to .kondi-chat/index/symbols.json. Provides find_symbol
6
+ * and related_files tools so the agent can navigate the codebase
7
+ * structurally instead of grep-and-hope.
8
+ *
9
+ * Uses simple regex, not tree-sitter — fast, zero native deps,
10
+ * good enough for 90% of cases. Can upgrade to tree-sitter later
11
+ * if the regex falls short.
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from 'node:fs';
15
+ import { join, relative, extname, dirname } from 'node:path';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface SymbolEntry {
22
+ name: string;
23
+ kind: 'function' | 'class' | 'interface' | 'type' | 'const' | 'export' | 'method';
24
+ file: string;
25
+ line: number;
26
+ }
27
+
28
+ export interface FileSymbols {
29
+ file: string;
30
+ symbols: SymbolEntry[];
31
+ imports: string[]; // files this file imports from
32
+ mtime: number;
33
+ }
34
+
35
+ export interface SymbolIndex {
36
+ files: Record<string, FileSymbols>;
37
+ buildTime: string;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Extraction regexes (covers TS, JS, Python, Rust, Go)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const EXTRACTORS: Array<{ ext: string[]; patterns: Array<{ re: RegExp; kind: SymbolEntry['kind'] }> }> = [
45
+ {
46
+ ext: ['.ts', '.tsx', '.js', '.jsx'],
47
+ patterns: [
48
+ { re: /^export\s+(?:async\s+)?function\s+(\w+)/gm, kind: 'function' },
49
+ { re: /^export\s+(?:default\s+)?class\s+(\w+)/gm, kind: 'class' },
50
+ { re: /^export\s+interface\s+(\w+)/gm, kind: 'interface' },
51
+ { re: /^export\s+type\s+(\w+)/gm, kind: 'type' },
52
+ { re: /^export\s+const\s+(\w+)/gm, kind: 'const' },
53
+ { re: /^(?:async\s+)?function\s+(\w+)/gm, kind: 'function' },
54
+ { re: /^class\s+(\w+)/gm, kind: 'class' },
55
+ { re: /^interface\s+(\w+)/gm, kind: 'interface' },
56
+ ],
57
+ },
58
+ {
59
+ ext: ['.py'],
60
+ patterns: [
61
+ { re: /^def\s+(\w+)/gm, kind: 'function' },
62
+ { re: /^class\s+(\w+)/gm, kind: 'class' },
63
+ { re: /^(\w+)\s*=\s*/gm, kind: 'const' },
64
+ ],
65
+ },
66
+ {
67
+ ext: ['.rs'],
68
+ patterns: [
69
+ { re: /^pub\s+(?:async\s+)?fn\s+(\w+)/gm, kind: 'function' },
70
+ { re: /^pub\s+struct\s+(\w+)/gm, kind: 'class' },
71
+ { re: /^pub\s+enum\s+(\w+)/gm, kind: 'type' },
72
+ { re: /^pub\s+trait\s+(\w+)/gm, kind: 'interface' },
73
+ { re: /^fn\s+(\w+)/gm, kind: 'function' },
74
+ { re: /^struct\s+(\w+)/gm, kind: 'class' },
75
+ ],
76
+ },
77
+ {
78
+ ext: ['.go'],
79
+ patterns: [
80
+ { re: /^func\s+(?:\([^)]+\)\s+)?(\w+)/gm, kind: 'function' },
81
+ { re: /^type\s+(\w+)\s+struct/gm, kind: 'class' },
82
+ { re: /^type\s+(\w+)\s+interface/gm, kind: 'interface' },
83
+ ],
84
+ },
85
+ ];
86
+
87
+ const IMPORT_RE_TS = /(?:import|from)\s+['"]([^'"]+)['"]/g;
88
+ const IMPORT_RE_PY = /^(?:from|import)\s+(\S+)/gm;
89
+
90
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.kondi-chat', '.claude', 'dist', 'target', '__pycache__', '.next', '.venv', 'venv', 'build']);
91
+ const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.py', '.rs', '.go']);
92
+ const MAX_FILE_SIZE = 200_000;
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Index builder
96
+ // ---------------------------------------------------------------------------
97
+
98
+ export class SymbolIndexer {
99
+ private workingDir: string;
100
+ private cachePath: string;
101
+ private index: SymbolIndex = { files: {}, buildTime: '' };
102
+
103
+ constructor(workingDir: string) {
104
+ this.workingDir = workingDir;
105
+ const indexDir = join(workingDir, '.kondi-chat', 'index');
106
+ mkdirSync(indexDir, { recursive: true });
107
+ this.cachePath = join(indexDir, 'symbols.json');
108
+ this.load();
109
+ }
110
+
111
+ /** Build or refresh the index. Only re-scans files whose mtime changed. */
112
+ build(): number {
113
+ let scanned = 0;
114
+ const currentFiles = new Set<string>();
115
+
116
+ const scan = (dir: string, depth: number) => {
117
+ if (depth > 4) return;
118
+ try {
119
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
120
+ if (SKIP_DIRS.has(entry.name)) continue;
121
+ const abs = join(dir, entry.name);
122
+ const rel = relative(this.workingDir, abs);
123
+ if (entry.isDirectory()) {
124
+ scan(abs, depth + 1);
125
+ } else if (entry.isFile()) {
126
+ const ext = extname(entry.name).toLowerCase();
127
+ if (!CODE_EXTENSIONS.has(ext)) continue;
128
+ currentFiles.add(rel);
129
+ const stat = statSync(abs);
130
+ if (stat.size > MAX_FILE_SIZE) continue;
131
+ const cached = this.index.files[rel];
132
+ if (cached && cached.mtime === stat.mtimeMs) continue;
133
+ // Re-scan this file
134
+ const content = readFileSync(abs, 'utf-8');
135
+ this.index.files[rel] = this.extractFileSymbols(rel, content, ext, stat.mtimeMs);
136
+ scanned++;
137
+ }
138
+ }
139
+ } catch { /* permission error */ }
140
+ };
141
+
142
+ scan(this.workingDir, 0);
143
+
144
+ // Remove entries for deleted files
145
+ for (const file of Object.keys(this.index.files)) {
146
+ if (!currentFiles.has(file)) delete this.index.files[file];
147
+ }
148
+
149
+ this.index.buildTime = new Date().toISOString();
150
+ this.save();
151
+ return scanned;
152
+ }
153
+
154
+ /** Find symbols by name (prefix match). */
155
+ findSymbol(query: string): SymbolEntry[] {
156
+ const lower = query.toLowerCase();
157
+ const results: SymbolEntry[] = [];
158
+ for (const fs of Object.values(this.index.files)) {
159
+ for (const sym of fs.symbols) {
160
+ if (sym.name.toLowerCase().includes(lower)) {
161
+ results.push(sym);
162
+ }
163
+ }
164
+ }
165
+ return results.slice(0, 20);
166
+ }
167
+
168
+ /** Find files that import or are imported by a given file. */
169
+ relatedFiles(filePath: string): string[] {
170
+ const rel = filePath.replace(/^\.\//, '');
171
+ const related = new Set<string>();
172
+
173
+ // Files that this file imports
174
+ const entry = this.index.files[rel];
175
+ if (entry) {
176
+ for (const imp of entry.imports) related.add(imp);
177
+ }
178
+
179
+ // Files that import this file
180
+ for (const [file, fs] of Object.entries(this.index.files)) {
181
+ if (fs.imports.some(i => i.includes(rel) || rel.includes(i))) {
182
+ related.add(file);
183
+ }
184
+ }
185
+
186
+ related.delete(rel);
187
+ return [...related].slice(0, 15);
188
+ }
189
+
190
+ /** Format for display. */
191
+ format(): string {
192
+ const totalSymbols = Object.values(this.index.files).reduce((s, f) => s + f.symbols.length, 0);
193
+ return `Symbol index: ${Object.keys(this.index.files).length} files, ${totalSymbols} symbols (${this.index.buildTime || 'not built'})`;
194
+ }
195
+
196
+ // ── Private ──────────────────────────────────────────────────────────
197
+
198
+ private extractFileSymbols(file: string, content: string, ext: string, mtime: number): FileSymbols {
199
+ const symbols: SymbolEntry[] = [];
200
+ const imports: string[] = [];
201
+
202
+ // Find matching extractor
203
+ const extractor = EXTRACTORS.find(e => e.ext.includes(ext));
204
+ if (extractor) {
205
+ const lines = content.split('\n');
206
+ for (const { re, kind } of extractor.patterns) {
207
+ re.lastIndex = 0;
208
+ let match;
209
+ while ((match = re.exec(content)) !== null) {
210
+ const line = content.slice(0, match.index).split('\n').length;
211
+ symbols.push({ name: match[1], kind, file, line });
212
+ }
213
+ }
214
+ }
215
+
216
+ // Extract imports
217
+ const importRe = ext === '.py' ? IMPORT_RE_PY : IMPORT_RE_TS;
218
+ importRe.lastIndex = 0;
219
+ let m;
220
+ while ((m = importRe.exec(content)) !== null) {
221
+ const imp = m[1].replace(/^\.\//, '').replace(/\.\w+$/, '');
222
+ if (!imp.startsWith('.') || imp.includes('/')) {
223
+ imports.push(imp);
224
+ }
225
+ }
226
+
227
+ return { file, symbols, imports, mtime };
228
+ }
229
+
230
+ private load(): void {
231
+ if (!existsSync(this.cachePath)) return;
232
+ try {
233
+ this.index = JSON.parse(readFileSync(this.cachePath, 'utf-8'));
234
+ } catch { /* start fresh */ }
235
+ }
236
+
237
+ private save(): void {
238
+ writeFileSync(this.cachePath, JSON.stringify(this.index, null, 2));
239
+ }
240
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Council Profiles — configurations for the `/council` command.
3
+ *
4
+ * A council profile is a kondi-council engine config file: a set of
5
+ * personas (manager / consultants / worker) plus orchestration settings.
6
+ * The deliberation engine itself is bundled at `src/council-engine/`.
7
+ *
8
+ * Presets are seeded on first run by copying the engine's curated
9
+ * configs (`src/council-engine/configs/councils/*.json`) into
10
+ * `.kondi-chat/councils/`. Users can edit those or add their own JSON
11
+ * files there; the filename (without `.json`) is the profile id passed
12
+ * to `/council run <id>`.
13
+ */
14
+
15
+ import { readFileSync, existsSync, mkdirSync, readdirSync, copyFileSync } from 'node:fs';
16
+ import { join, dirname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types — mirror the engine's CouncilConfigFile
21
+ // (see src/council-engine/cli/council-config.ts). Kept as a local copy
22
+ // because the engine subtree is excluded from kondi-chat's typecheck.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface CouncilPersona {
26
+ name: string;
27
+ role: 'manager' | 'worker' | 'consultant' | 'reviewer';
28
+ provider?: string;
29
+ model?: string;
30
+ avatar?: string;
31
+ systemPrompt?: string;
32
+ traits?: string[];
33
+ stance?: 'advocate' | 'critic' | 'neutral' | 'wildcard';
34
+ domain?: string;
35
+ temperature?: number;
36
+ suppressPersona?: boolean;
37
+ toolAccess?: 'full' | 'none';
38
+ }
39
+
40
+ export interface CouncilProfile {
41
+ /** Human-readable council title. */
42
+ name: string;
43
+ /** Council type — council, coding, analysis, review, agent, code_planning. */
44
+ type?: string;
45
+ personas: CouncilPersona[];
46
+ orchestration?: {
47
+ maxRounds?: number;
48
+ maxRevisions?: number;
49
+ contextTokenBudget?: number;
50
+ summarizeAfterRound?: number;
51
+ consultantExecution?: 'sequential' | 'parallel';
52
+ evolveContext?: boolean;
53
+ bootstrapContext?: boolean;
54
+ };
55
+ output?: { format?: string; directory?: string; sessionExport?: boolean };
56
+ expectedOutput?: string;
57
+ decisionCriteria?: string[];
58
+ testCommand?: string;
59
+ maxDebugCycles?: number;
60
+ maxReviewCycles?: number;
61
+ }
62
+
63
+ /** Bundled engine presets — copied into .kondi-chat/councils/ on first run. */
64
+ const ENGINE_PRESETS_DIR = join(
65
+ dirname(fileURLToPath(import.meta.url)),
66
+ '..', 'council-engine', 'configs', 'councils',
67
+ );
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Profile manager
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export class CouncilProfileManager {
74
+ private profileDir: string;
75
+
76
+ constructor(storageDir: string) {
77
+ this.profileDir = join(storageDir, 'councils');
78
+ mkdirSync(this.profileDir, { recursive: true });
79
+ this.ensurePresets();
80
+ }
81
+
82
+ /** Profile ids — the JSON filenames (without extension) in the dir. */
83
+ ids(): string[] {
84
+ return readdirSync(this.profileDir)
85
+ .filter(f => f.endsWith('.json'))
86
+ .map(f => f.slice(0, -5))
87
+ .sort();
88
+ }
89
+
90
+ /** Load a profile by id. Returns undefined for unknown / invalid files. */
91
+ get(id: string): CouncilProfile | undefined {
92
+ const path = this.getPath(id);
93
+ if (!existsSync(path)) return undefined;
94
+ try {
95
+ return JSON.parse(readFileSync(path, 'utf-8')) as CouncilProfile;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ /** Absolute path to a profile's config file (passed to the engine). */
102
+ getPath(id: string): string {
103
+ return join(this.profileDir, `${id}.json`);
104
+ }
105
+
106
+ /** Human-readable roster for `/council list`. */
107
+ format(): string {
108
+ const ids = this.ids();
109
+ if (ids.length === 0) return 'No council profiles configured.';
110
+ const lines: string[] = ['Council profiles — run with /council run <id> "<brief>":'];
111
+ for (const id of ids) {
112
+ const p = this.get(id);
113
+ if (!p) continue;
114
+ const rounds = p.orchestration?.maxRounds ?? '?';
115
+ const roster = p.personas.map(x => `${x.name}(${x.role})`).join(', ');
116
+ lines.push('');
117
+ lines.push(` ${id} — ${p.name}`);
118
+ lines.push(` Type: ${p.type || 'council'} | Rounds: ${rounds} | ${p.personas.length} personas`);
119
+ lines.push(` ${roster}`);
120
+ }
121
+ return lines.join('\n');
122
+ }
123
+
124
+ /**
125
+ * Seed presets from the bundled engine configs. Only copies files that
126
+ * don't already exist, so user edits to a preset are never clobbered.
127
+ */
128
+ private ensurePresets(): void {
129
+ if (!existsSync(ENGINE_PRESETS_DIR)) return;
130
+ for (const file of readdirSync(ENGINE_PRESETS_DIR).filter(f => f.endsWith('.json'))) {
131
+ const dest = join(this.profileDir, file);
132
+ if (!existsSync(dest)) {
133
+ copyFileSync(join(ENGINE_PRESETS_DIR, file), dest);
134
+ }
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Council Tool — runs the bundled kondi-council engine as a subprocess.
3
+ *
4
+ * The deliberation engine is vendored at `src/council-engine/` (no
5
+ * external repo required). Chat passes a brief, a working directory, and
6
+ * a council profile; the engine deliberates and returns structured JSON.
7
+ */
8
+
9
+ import { execSync } from 'node:child_process';
10
+ import { resolve, dirname } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import type { ToolDefinition } from '../types.ts';
13
+ import type { CouncilProfileManager } from './profiles.ts';
14
+
15
+ // Bundled engine entry point — resolved relative to this module so it
16
+ // works regardless of where kondi-chat was launched from.
17
+ const ENGINE_ENTRY = resolve(
18
+ dirname(fileURLToPath(import.meta.url)),
19
+ '..', 'council-engine', 'cli', 'run-council.ts',
20
+ );
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Tool definition
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export const COUNCIL_TOOL: ToolDefinition = {
27
+ name: 'run_council',
28
+ description: 'Run a multi-model deliberation council for complex decisions. Use for architecture reviews, security audits, major design decisions, or when you need multiple AI perspectives. This is expensive — use intentionally.',
29
+ parameters: {
30
+ type: 'object',
31
+ properties: {
32
+ profile: {
33
+ type: 'string',
34
+ description: 'Council profile id (e.g., coding, analysis, debate, code-planning)',
35
+ },
36
+ brief: {
37
+ type: 'string',
38
+ description: 'What to deliberate on — the problem statement',
39
+ },
40
+ files: {
41
+ type: 'array',
42
+ items: { type: 'string' },
43
+ description: 'Relevant file paths to include as context',
44
+ },
45
+ },
46
+ required: ['profile', 'brief'],
47
+ },
48
+ };
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Executor
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export async function executeCouncil(
55
+ profileName: string,
56
+ brief: string,
57
+ _files: string[],
58
+ workingDir: string,
59
+ profileManager: CouncilProfileManager,
60
+ ): Promise<{ content: string; isError?: boolean }> {
61
+ const profile = profileManager.get(profileName);
62
+ if (!profile) {
63
+ const available = profileManager.ids().join(', ') || '(none)';
64
+ return {
65
+ content: `Unknown council profile: ${profileName}. Available: ${available}`,
66
+ isError: true,
67
+ };
68
+ }
69
+
70
+ const configPath = profileManager.getPath(profileName);
71
+ process.stderr.write(
72
+ `[council] Running ${profileName} (${profile.type || 'council'}): ${brief.slice(0, 80)}\n`,
73
+ );
74
+
75
+ // Engine CLI contract — see src/council-engine/cli/run-council.ts.
76
+ // --output none keeps the user's working dir clean; --json-stdout still
77
+ // emits the structured result on stdout regardless.
78
+ const args = [
79
+ '--config', configPath,
80
+ '--task', brief,
81
+ '--working-dir', workingDir,
82
+ '--output', 'none',
83
+ '--json-stdout',
84
+ '--no-session-export',
85
+ '--quiet',
86
+ ];
87
+
88
+ try {
89
+ const cmd = `npx tsx ${JSON.stringify(ENGINE_ENTRY)} ${args.map(a => JSON.stringify(a)).join(' ')}`;
90
+ const output = execSync(cmd, {
91
+ cwd: workingDir,
92
+ encoding: 'utf-8',
93
+ timeout: 600_000, // 10 min — councils fan out across models for multiple rounds
94
+ env: { ...process.env },
95
+ stdio: ['pipe', 'pipe', 'pipe'],
96
+ });
97
+
98
+ // The engine writes some internal logs to stdout; the --json-stdout
99
+ // result is a single JSON line. Extract the last line that parses.
100
+ const result = parseLastJson(output);
101
+ if (result) {
102
+ const summary = [
103
+ `Council: ${profile.name} (${profile.type || 'council'})`,
104
+ `Status: ${result.status || 'completed'}`,
105
+ '',
106
+ result.decision ? `Decision:\n${result.decision}` : '',
107
+ result.output ? `Output:\n${result.output}` : '',
108
+ result.summary ? `Summary:\n${result.summary}` : '',
109
+ ].filter(Boolean).join('\n');
110
+ return { content: summary };
111
+ }
112
+ // No JSON found — return the tail of raw output as a fallback.
113
+ return { content: output.slice(-4000) };
114
+ } catch (error: any) {
115
+ const stderr = (error.stderr?.toString() || '').slice(-1000);
116
+ const stdout = (error.stdout?.toString() || '').slice(-1000);
117
+ return {
118
+ content: `Council failed: ${error.message}\n${stderr}\n${stdout}`,
119
+ isError: true,
120
+ };
121
+ }
122
+ }
123
+
124
+ /** Scan output lines bottom-up for the last one that parses as a JSON object. */
125
+ function parseLastJson(output: string): any | undefined {
126
+ const lines = output.split('\n').map(l => l.trim()).filter(Boolean);
127
+ for (let i = lines.length - 1; i >= 0; i--) {
128
+ const line = lines[i];
129
+ if (line.startsWith('{') && line.endsWith('}')) {
130
+ try {
131
+ return JSON.parse(line);
132
+ } catch {
133
+ /* keep scanning */
134
+ }
135
+ }
136
+ }
137
+ return undefined;
138
+ }