@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,2762 @@
1
+ /**
2
+ * Council: Deliberation Orchestrator
3
+ * Deterministic state machine for structured multi-agent deliberation
4
+ *
5
+ * The orchestrator is CODE, not an agent. It:
6
+ * - Drives the state machine
7
+ * - Invokes agents with correct context
8
+ * - Manages artifacts and ledger
9
+ * - Enforces invariants
10
+ */
11
+
12
+ import type {
13
+ Council,
14
+ Persona,
15
+ LedgerEntry,
16
+ LedgerEntryType,
17
+ DeliberationPhase,
18
+ DeliberationRole,
19
+ DeliberationRoleAssignment,
20
+ ManagerEvaluation,
21
+ ManagerReview,
22
+ ContextArtifact,
23
+ ContextPatch,
24
+ ArtifactRef,
25
+ } from './types';
26
+
27
+ import {
28
+ ledgerStore,
29
+ getEntries,
30
+ getAllEntries,
31
+ getLatestOfType,
32
+ getEntriesForRound,
33
+ getManagerNotes,
34
+ getLedgerTokenCount,
35
+ formatEntriesForContext,
36
+ buildMechanicalSummary,
37
+ } from './ledger-store';
38
+
39
+ import {
40
+ getCurrentContext,
41
+ getContextHistory,
42
+ createInitialContext,
43
+ createContextVersion,
44
+ getPendingPatches,
45
+ createPatch,
46
+ acceptPatch,
47
+ rejectPatch,
48
+ createDecision,
49
+ createPlan,
50
+ createDirective,
51
+ createOutput,
52
+ createRevisionOutput,
53
+ getDecision,
54
+ getPlan,
55
+ getDirective,
56
+ getLatestOutput,
57
+ } from './context-store';
58
+
59
+ import {
60
+ councilStore,
61
+ getPersonaByRole,
62
+ getRoleAssignment,
63
+ isDeliberationMode,
64
+ } from './store';
65
+
66
+ import { bootstrapDirectoryContext } from './context-bootstrap';
67
+ import { buildContextInspection } from './context-inspection';
68
+ import type { ContextInspection } from './types';
69
+
70
+ import {
71
+ buildManagerFramingPrompt,
72
+ buildManagerEvaluationPrompt,
73
+ buildManagerDecisionPrompt,
74
+ buildManagerForcedDecisionPrompt,
75
+ buildManagerPlanPrompt,
76
+ buildWorkDirectivePrompt,
77
+ buildManagerReviewPrompt,
78
+ buildManagerRoundSummaryPrompt,
79
+ buildIndependentAnalysisPrompt,
80
+ buildDeliberationResponsePrompt,
81
+ buildConsultantFinalPositionPrompt,
82
+ buildConsultantReviewPrompt,
83
+ buildWorkerExecutionPrompt,
84
+ buildWorkerRevisionPrompt,
85
+ getMinimalWorkerSystemPrompt,
86
+ type WorkerPermissions,
87
+ } from './prompts';
88
+
89
+ // ============================================================================
90
+ // Types
91
+ // ============================================================================
92
+
93
+ export interface AgentInvocation {
94
+ personaId: string;
95
+ systemPrompt: string;
96
+ userMessage: string;
97
+ /** When true, the invoker should NOT pass MCP tools (avoids slow MCP connections) */
98
+ skipTools?: boolean;
99
+ /** Restrict to specific tool names (e.g. ['Read', 'Write', 'Bash']) */
100
+ allowedTools?: string[];
101
+ /** MCP servers this invocation can access (undefined = all servers) */
102
+ allowedServerIds?: string[];
103
+ /** Working directory override for local tool calls (bypasses singleton) */
104
+ workingDirectory?: string;
105
+ /** Timeout in ms — set by invokeAgentSafe based on role/context defaults */
106
+ timeoutMs?: number;
107
+ /** Cacheable context (e.g., bootstrap directory scan) for Anthropic prompt caching */
108
+ cacheableContext?: string;
109
+ }
110
+
111
+ export interface AgentResponse {
112
+ content: string;
113
+ tokensUsed: number;
114
+ latencyMs: number;
115
+ structured?: Record<string, unknown>;
116
+ }
117
+
118
+ export type AgentInvoker = (invocation: AgentInvocation, persona: Persona) => Promise<AgentResponse>;
119
+
120
+ export interface OrchestratorConfig {
121
+ invokeAgent: AgentInvoker;
122
+ onPhaseChange?: (from: DeliberationPhase, to: DeliberationPhase) => void;
123
+ onEntryAdded?: (entry: LedgerEntry) => void;
124
+ onError?: (error: Error, context: string) => void;
125
+ /** Called when a persona starts thinking (startedAt = Date.now() timestamp, prompt = user message sent) */
126
+ onAgentThinkingStart?: (persona: Persona, startedAt: number, prompt?: string) => void;
127
+ /** Called when a persona finishes thinking */
128
+ onAgentThinkingEnd?: (persona: Persona) => void;
129
+ /** Called when an agent times out (before retry). Enables UI timeout warnings. */
130
+ onAgentTimeout?: (persona: Persona, context: string, elapsedMs: number) => void;
131
+ }
132
+
133
+ // ============================================================================
134
+ // Phase Transition Table
135
+ // ============================================================================
136
+
137
+ const PHASE_TRANSITIONS: Record<DeliberationPhase, {
138
+ validNext: DeliberationPhase[];
139
+ terminal: boolean;
140
+ }> = {
141
+ created: { validNext: ['problem_framing', 'executing'], terminal: false },
142
+ problem_framing: { validNext: ['round_independent', 'deciding'], terminal: false },
143
+ round_independent: { validNext: ['round_waiting_for_manager'], terminal: false },
144
+ round_interactive: { validNext: ['round_waiting_for_manager'], terminal: false },
145
+ round_waiting_for_manager: { validNext: ['round_interactive', 'deciding', 'planning'], terminal: false },
146
+ planning: { validNext: ['directing'], terminal: false },
147
+ deciding: { validNext: ['planning', 'directing', 'completed'], terminal: false },
148
+ directing: { validNext: ['executing'], terminal: false },
149
+ executing: { validNext: ['reviewing', 'completed'], terminal: false },
150
+ reviewing: { validNext: ['completed', 'revising', 'round_interactive'], terminal: false },
151
+ revising: { validNext: ['reviewing', 'completed'], terminal: false },
152
+ decomposing: { validNext: [], terminal: false },
153
+ implementing: { validNext: [], terminal: false },
154
+ code_reviewing: { validNext: [], terminal: false },
155
+ testing: { validNext: [], terminal: false },
156
+ debugging: { validNext: [], terminal: false },
157
+ paused: { validNext: ['created', 'problem_framing', 'round_independent', 'round_interactive', 'round_waiting_for_manager', 'planning', 'deciding', 'directing', 'executing', 'reviewing', 'revising', 'decomposing', 'implementing', 'code_reviewing', 'testing', 'debugging'], terminal: false },
158
+ completed: { validNext: [], terminal: true },
159
+ cancelled: { validNext: [], terminal: true },
160
+ failed: { validNext: [], terminal: true },
161
+ };
162
+
163
+ // ============================================================================
164
+ // Deliberation Orchestrator
165
+ // ============================================================================
166
+
167
+ export class DeliberationOrchestrator {
168
+ private config: OrchestratorConfig;
169
+ private activeCouncilId: string | null = null;
170
+ /** Bootstrapped directory context — shared with all personas via prompt */
171
+ private bootstrappedContext: string = '';
172
+
173
+ constructor(config: OrchestratorConfig) {
174
+ this.config = config;
175
+ }
176
+
177
+ // ==========================================================================
178
+ // Auto-Run: Full Deliberation Loop
179
+ // ==========================================================================
180
+
181
+ /**
182
+ * Run the full deliberation automatically from start to completion.
183
+ * Frames the problem then hands off to continueDeliberation.
184
+ */
185
+ async runFullDeliberation(council: Council, rawProblem: string): Promise<void> {
186
+ const deliberationStart = Date.now();
187
+ console.log('[Orchestrator] Starting full deliberation...');
188
+ this.activeCouncilId = council.id;
189
+
190
+ // Always read fresh from store to avoid stale React state
191
+ council = councilStore.get(council.id) || council;
192
+
193
+ // Pre-flight check: log role assignments and personas for debugging
194
+ const roleAssignments = council.deliberation?.roleAssignments || [];
195
+ const consultantAssignments = roleAssignments.filter(r => r.role === 'consultant');
196
+ const managerAssignments = roleAssignments.filter(r => r.role === 'manager');
197
+ const workerAssignments = roleAssignments.filter(r => r.role === 'worker');
198
+
199
+ console.log('[Orchestrator] PRE-FLIGHT CHECK:', {
200
+ totalPersonas: council.personas.length,
201
+ personaNames: council.personas.map(p => `${p.name} (${p.id.slice(0, 8)})`),
202
+ totalRoleAssignments: roleAssignments.length,
203
+ roleBreakdown: {
204
+ managers: managerAssignments.length,
205
+ consultants: consultantAssignments.length,
206
+ workers: workerAssignments.length,
207
+ },
208
+ consultantDetails: consultantAssignments.map(r => {
209
+ const p = council.personas.find(p => p.id === r.personaId);
210
+ return `${p?.name || 'MISSING'} (${r.personaId.slice(0, 8)})`;
211
+ }),
212
+ // Check for personas without role assignments
213
+ unassignedPersonas: council.personas
214
+ .filter(p => !roleAssignments.some(r => r.personaId === p.id))
215
+ .map(p => `${p.name} (${p.id.slice(0, 8)})`),
216
+ // Check for role assignments without matching personas
217
+ orphanedAssignments: roleAssignments
218
+ .filter(r => !council.personas.some(p => p.id === r.personaId))
219
+ .map(r => `${r.role}:${r.personaId.slice(0, 8)}`),
220
+ });
221
+
222
+ if (consultantAssignments.length < 2 && consultantAssignments.length > 0) {
223
+ console.warn(`[Orchestrator] WARNING: Only ${consultantAssignments.length} consultant(s) found. ` +
224
+ `If you expected more, check role assignments in Setup.`);
225
+ }
226
+
227
+ // Auto-repair: ensure every persona has a role assignment
228
+ const unassigned = council.personas.filter(
229
+ p => !roleAssignments.some(r => r.personaId === p.id)
230
+ );
231
+ if (unassigned.length > 0) {
232
+ console.warn(`[Orchestrator] REPAIRING: ${unassigned.length} persona(s) without role assignments:`,
233
+ unassigned.map(p => p.name));
234
+ const repairedAssignments = [
235
+ ...roleAssignments,
236
+ ...unassigned.map(p => ({
237
+ personaId: p.id,
238
+ role: (p.preferredDeliberationRole || 'consultant') as 'manager' | 'consultant' | 'worker',
239
+ })),
240
+ ];
241
+ councilStore.setRoleAssignments(council.id, repairedAssignments);
242
+ // Re-fetch council with repaired assignments
243
+ council = councilStore.get(council.id)!;
244
+ }
245
+
246
+ // Special path: 0 managers → direct execution (worker-only council)
247
+ if (managerAssignments.length === 0) {
248
+ console.log('[Orchestrator] No manager — running direct execution path');
249
+ await this.runDirectExecution(council, rawProblem);
250
+ return;
251
+ }
252
+
253
+ // Lightweight council types (agent/analysis): skip full deliberation,
254
+ // go straight to worker execution. The manager's system prompt is folded into
255
+ // the worker's context so its guidance isn't lost.
256
+ const stepType = council.deliberation?.stepType;
257
+ if (stepType === 'agent' || stepType === 'analysis') {
258
+ console.log(`[Orchestrator] Lightweight step type '${stepType}' — running direct execution path`);
259
+ await this.runDirectExecution(council, rawProblem);
260
+ return;
261
+ }
262
+
263
+ // Phase 1: Frame the problem
264
+ const t0 = Date.now();
265
+ await this.frameProblem(council, rawProblem);
266
+ console.log(`[Orchestrator:Timing] frameProblem took ${((Date.now() - t0) / 1000).toFixed(1)}s (elapsed: ${((Date.now() - deliberationStart) / 1000).toFixed(0)}s)`);
267
+
268
+ // Continue through all remaining phases automatically
269
+ await this.continueDeliberation(council.id, deliberationStart);
270
+ }
271
+
272
+ /**
273
+ * Continue deliberation from whatever phase it's currently in.
274
+ * Used after initial start (runFullDeliberation) and after resume from pause.
275
+ * Runs fully automatically — no manual step-by-step buttons needed.
276
+ */
277
+ async continueDeliberation(councilId: string, startTime?: number): Promise<void> {
278
+ this.activeCouncilId = councilId;
279
+ const elapsed = () => startTime ? ` (elapsed: ${((Date.now() - startTime) / 1000).toFixed(0)}s)` : '';
280
+ let council = councilStore.get(councilId)!;
281
+
282
+ // Phase 2: Deliberation rounds
283
+ const maxRounds = council.deliberation?.maxRounds || 4;
284
+
285
+ // Run rounds until manager decides or max rounds reached
286
+ let loopGuard = 0;
287
+ const maxLoopIterations = maxRounds * 3 + 10; // Safety limit
288
+
289
+ while (loopGuard++ < maxLoopIterations) {
290
+ council = councilStore.get(councilId)!;
291
+ const phase = council.deliberationState?.currentPhase;
292
+
293
+ // Stop on pause or terminal states
294
+ if (phase === 'paused') {
295
+ console.log('[Orchestrator] Deliberation paused — stopping auto-run');
296
+ return;
297
+ }
298
+ if (phase === 'deciding' || phase === 'planning' || phase === 'directing' ||
299
+ phase === 'executing' || phase === 'reviewing' || phase === 'revising' ||
300
+ phase === 'completed' || phase === 'cancelled' || phase === 'failed') {
301
+ break; // Move past deliberation rounds
302
+ }
303
+
304
+ try {
305
+ const phaseStart = Date.now();
306
+ if (phase === 'round_independent') {
307
+ console.log(`[Orchestrator] Running independent round...`);
308
+ await this.runIndependentRound(council);
309
+ console.log(`[Orchestrator:Timing] round_independent took ${((Date.now() - phaseStart) / 1000).toFixed(1)}s${elapsed()}`);
310
+ // Fallback: if phase didn't advance (isRoundComplete returned false), force it
311
+ const afterPhase = councilStore.get(councilId)?.deliberationState?.currentPhase;
312
+ if (afterPhase === 'round_independent') {
313
+ console.warn('[Orchestrator] Round did not advance after independent round — forcing transition');
314
+ this.transitionPhase(councilId, 'round_waiting_for_manager');
315
+ }
316
+ } else if (phase === 'round_waiting_for_manager') {
317
+ console.log('[Orchestrator] Manager evaluating...');
318
+ await this.managerEvaluate(council);
319
+ console.log(`[Orchestrator:Timing] managerEvaluate took ${((Date.now() - phaseStart) / 1000).toFixed(1)}s${elapsed()}`);
320
+ } else if (phase === 'round_interactive') {
321
+ console.log('[Orchestrator] Running interactive round...');
322
+ await this.runInteractiveRound(council);
323
+ console.log(`[Orchestrator:Timing] round_interactive took ${((Date.now() - phaseStart) / 1000).toFixed(1)}s${elapsed()}`);
324
+ // Fallback: if phase didn't advance, force it
325
+ const afterPhase = councilStore.get(councilId)?.deliberationState?.currentPhase;
326
+ if (afterPhase === 'round_interactive') {
327
+ console.warn('[Orchestrator] Round did not advance after interactive round — forcing transition');
328
+ this.transitionPhase(councilId, 'round_waiting_for_manager');
329
+ }
330
+ } else {
331
+ console.log(`[Orchestrator] Unexpected phase during deliberation: ${phase}`);
332
+ break;
333
+ }
334
+ } catch (error) {
335
+ const errMsg = (error as Error).message || String(error);
336
+ const isRateLimit = /\b(429|rate.limit|too many requests)\b/i.test(errMsg);
337
+
338
+ if (isRateLimit) {
339
+ // Rate limit during deliberation round — wait and retry this iteration
340
+ const delayMs = 120_000; // 2 minutes
341
+ console.warn(
342
+ `[Orchestrator] Rate limit during ${phase}, waiting ${Math.round(delayMs / 1000)}s before retrying...`
343
+ );
344
+ this.createEntry(
345
+ councilId, 'manager', this.getManager(council).id,
346
+ 'error', phase || 'failed',
347
+ `Rate limited during ${phase} — waiting ${Math.round(delayMs / 1000)}s before retrying...`,
348
+ 0, 0, undefined, council.deliberationState?.currentRound
349
+ );
350
+ await new Promise(resolve => setTimeout(resolve, delayMs));
351
+ loopGuard--; // don't count this as a loop iteration
352
+ continue; // retry the same phase
353
+ }
354
+
355
+ console.error(`[Orchestrator] Error during phase ${phase}:`, errMsg);
356
+
357
+ // Record the error as a ledger entry — attribute to the phase's active role
358
+ const isConsultantPhase = phase === 'round_independent' || phase === 'round_interactive';
359
+ const role = isConsultantPhase ? 'consultant' : 'manager';
360
+ const fallbackPersona = isConsultantPhase
361
+ ? (council.personas.find(p => p.preferredDeliberationRole === 'consultant') || this.getManager(council))
362
+ : this.getManager(council);
363
+ this.createEntry(
364
+ councilId,
365
+ role,
366
+ fallbackPersona.id,
367
+ 'error',
368
+ phase || 'failed',
369
+ `Deliberation error during ${phase}: ${errMsg}`,
370
+ 0, 0, undefined,
371
+ council.deliberationState?.currentRound
372
+ );
373
+
374
+ // Transition to failed state so UI reflects the problem
375
+ this.transitionPhase(councilId, 'failed');
376
+ throw error; // Re-throw so caller can handle
377
+ }
378
+ }
379
+
380
+ // Phase 3: Decision + Plan + Directive + Execution
381
+ // Wrapped with phase-level rate-limit retry: if a phase fails due to
382
+ // rate limiting (after invokeAgentSafe's own 5 retries are exhausted),
383
+ // we wait and retry from the current phase instead of failing the council.
384
+ const PHASE_RETRY_MAX = 3;
385
+ const PHASE_RETRY_BASE_MS = 120_000; // 2 minutes
386
+
387
+ for (let phaseAttempt = 0; phaseAttempt <= PHASE_RETRY_MAX; phaseAttempt++) {
388
+ try {
389
+ council = councilStore.get(councilId)!;
390
+ let phase = council.deliberationState?.currentPhase;
391
+
392
+ // If still in round phases after max iterations, force to deciding
393
+ if (phase === 'round_waiting_for_manager' || phase === 'round_interactive' || phase === 'round_independent') {
394
+ console.log('[Orchestrator] Max rounds exhausted, forcing decision phase');
395
+ this.transitionPhase(councilId, 'deciding');
396
+ council = councilStore.get(councilId)!;
397
+ phase = council.deliberationState?.currentPhase;
398
+ }
399
+
400
+ if (phase === 'paused' || phase === 'failed') return;
401
+
402
+ if (phase === 'deciding') {
403
+ const t = Date.now();
404
+ console.log('[Orchestrator] Making decision...');
405
+ await this.makeDecision(council);
406
+ console.log(`[Orchestrator:Timing] makeDecision took ${((Date.now() - t) / 1000).toFixed(1)}s${elapsed()}`);
407
+ council = councilStore.get(councilId)!;
408
+ phase = council.deliberationState?.currentPhase;
409
+ }
410
+
411
+ if (phase === 'paused' || phase === 'failed') return;
412
+
413
+ if (phase === 'planning') {
414
+ const t = Date.now();
415
+ console.log('[Orchestrator] Creating plan...');
416
+ await this.createPlan(council);
417
+ console.log(`[Orchestrator:Timing] createPlan took ${((Date.now() - t) / 1000).toFixed(1)}s${elapsed()}`);
418
+ council = councilStore.get(councilId)!;
419
+ phase = council.deliberationState?.currentPhase;
420
+ }
421
+
422
+ if (phase === 'paused' || phase === 'failed') return;
423
+
424
+ if (phase === 'directing') {
425
+ const t = Date.now();
426
+ console.log('[Orchestrator] Issuing directive...');
427
+ await this.issueDirective(council);
428
+ console.log(`[Orchestrator:Timing] issueDirective took ${((Date.now() - t) / 1000).toFixed(1)}s${elapsed()}`);
429
+ council = councilStore.get(councilId)!;
430
+ phase = council.deliberationState?.currentPhase;
431
+ }
432
+
433
+ // Phase 4: Execution loop
434
+ const maxRevisions = council.deliberation?.maxRevisions || 3;
435
+ let revisionCount = council.deliberationState?.revisionCount || 0;
436
+
437
+ while (revisionCount < maxRevisions + 1) {
438
+ council = councilStore.get(councilId)!;
439
+ phase = council.deliberationState?.currentPhase;
440
+
441
+ if (phase === 'paused' || phase === 'failed') {
442
+ console.log('[Orchestrator] Deliberation paused/failed — stopping auto-run');
443
+ return;
444
+ }
445
+
446
+ if (phase === 'executing') {
447
+ const t = Date.now();
448
+ console.log('[Orchestrator] Executing work...');
449
+ await this.executeWork(council);
450
+ console.log(`[Orchestrator:Timing] executeWork took ${((Date.now() - t) / 1000).toFixed(1)}s${elapsed()}`);
451
+ } else if (phase === 'reviewing') {
452
+ const t = Date.now();
453
+ console.log('[Orchestrator] Reviewing work...');
454
+ const { review } = await this.reviewWork(council);
455
+ console.log(`[Orchestrator:Timing] reviewWork took ${((Date.now() - t) / 1000).toFixed(1)}s${elapsed()}`);
456
+
457
+ if (review.verdict === 'accept') {
458
+ console.log('[Orchestrator] Work approved!');
459
+ break;
460
+ } else if (review.verdict === 'revise') {
461
+ revisionCount++;
462
+ console.log(`[Orchestrator] Revision requested (${revisionCount}/${maxRevisions})`);
463
+ } else if (review.verdict === 're_deliberate') {
464
+ const reDelibCount = council.deliberationState?.reDeliberationCount || 0;
465
+ if (reDelibCount >= 1) {
466
+ console.log('[Orchestrator] Max re-deliberations reached — accepting with best effort');
467
+ this.transitionPhase(councilId, 'completed');
468
+ councilStore.setStatus(councilId, 'resolved');
469
+ break;
470
+ }
471
+ console.log('[Orchestrator] Re-deliberation requested — looping back');
472
+ councilStore.updateDeliberationState(councilId, {
473
+ reDeliberationCount: reDelibCount + 1,
474
+ });
475
+ // Phase transitions back to round_interactive, re-enter deliberation loop
476
+ await this.continueDeliberation(councilId);
477
+ return;
478
+ }
479
+ } else if (phase === 'revising') {
480
+ console.log('[Orchestrator] Revising work...');
481
+ await this.requestRevision(council);
482
+ } else if (phase === 'completed') {
483
+ console.log('[Orchestrator] Deliberation completed!');
484
+ break;
485
+ } else {
486
+ console.log(`[Orchestrator] Unexpected phase during execution: ${phase}`);
487
+ break;
488
+ }
489
+ }
490
+
491
+ // Success — break out of the phase-level retry loop
492
+ break;
493
+
494
+ } catch (error) {
495
+ const errMsg = (error as Error).message || String(error);
496
+ const isRateLimit = /\b(429|rate.limit|too many requests)\b/i.test(errMsg);
497
+
498
+ council = councilStore.get(councilId)!;
499
+ const errPhase = council.deliberationState?.currentPhase;
500
+
501
+ if (isRateLimit && phaseAttempt < PHASE_RETRY_MAX) {
502
+ const delayMs = PHASE_RETRY_BASE_MS * Math.pow(1.5, phaseAttempt);
503
+ console.warn(
504
+ `[Orchestrator] Rate limit during ${errPhase}, retrying phase in ${Math.round(delayMs / 1000)}s ` +
505
+ `(phase attempt ${phaseAttempt + 1}/${PHASE_RETRY_MAX})`
506
+ );
507
+ this.createEntry(
508
+ councilId, 'manager', this.getManager(council).id,
509
+ 'error', errPhase || 'failed',
510
+ `Rate limited during ${errPhase} — waiting ${Math.round(delayMs / 1000)}s before retrying...`,
511
+ 0, 0, undefined, council.deliberationState?.currentRound
512
+ );
513
+ await new Promise(resolve => setTimeout(resolve, delayMs));
514
+ continue; // retry from current phase
515
+ }
516
+
517
+ console.error(`[Orchestrator] Error during post-round phase:`, errMsg);
518
+
519
+ // Record error in ledger — attribute to the phase's active role
520
+ try {
521
+ const isWorkerPhase = errPhase === 'executing' || errPhase === 'revising';
522
+ const role = isWorkerPhase ? 'worker' : 'manager';
523
+ const persona = isWorkerPhase
524
+ ? (council.personas.find(p => p.preferredDeliberationRole === 'worker') || this.getManager(council))
525
+ : this.getManager(council);
526
+ this.createEntry(
527
+ councilId,
528
+ role,
529
+ persona.id,
530
+ 'error',
531
+ errPhase || 'failed',
532
+ `Deliberation error during ${errPhase}: ${errMsg}`,
533
+ 0, 0, undefined,
534
+ council.deliberationState?.currentRound
535
+ );
536
+ } catch { /* best effort */ }
537
+
538
+ this.transitionPhase(councilId, 'failed');
539
+ throw error;
540
+ }
541
+ } // end phase-level retry loop
542
+
543
+ console.log(`[Orchestrator] Auto-run finished${elapsed()}`);
544
+ }
545
+
546
+ // ==========================================================================
547
+ // Phase 1: Problem Framing
548
+ // ==========================================================================
549
+
550
+ /**
551
+ * Manager frames the problem, creating Context v1
552
+ */
553
+ async frameProblem(council: Council, rawProblem: string): Promise<LedgerEntry> {
554
+ this.validatePhase(council, 'created');
555
+ const manager = this.getManager(council);
556
+
557
+ // Transition to problem_framing
558
+ this.transitionPhase(council.id, 'problem_framing');
559
+
560
+ // Show manager as thinking immediately (before bootstrap, which can be slow)
561
+ this.config.onAgentThinkingStart?.(manager, Date.now(), rawProblem);
562
+
563
+ // Bootstrap directory context if enabled — stored for all personas
564
+ let enrichedProblem = rawProblem;
565
+ if (council.deliberation?.bootstrapContext && council.deliberation?.workingDirectory) {
566
+ try {
567
+ const dirContext = await bootstrapDirectoryContext(council.deliberation.workingDirectory, { deep: true });
568
+ if (dirContext) {
569
+ this.bootstrappedContext = dirContext;
570
+ enrichedProblem = `${dirContext}\n\n---\n\n${rawProblem}`;
571
+ }
572
+ } catch (error) {
573
+ console.warn('[Orchestrator] Directory context bootstrap failed:', error);
574
+ }
575
+ }
576
+
577
+ // Build prompts per Section 9.1
578
+ const systemPrompt = manager.predisposition.systemPrompt;
579
+ const userMessage = buildManagerFramingPrompt(enrichedProblem);
580
+
581
+ // Note: onAgentThinkingStart was already called above (before bootstrap).
582
+ // invokeAgentSafe will call it again (idempotent) and onAgentThinkingEnd when done.
583
+
584
+ // Invoke manager
585
+ const response = await this.invokeAgentSafe(
586
+ { personaId: manager.id, systemPrompt, userMessage },
587
+ manager,
588
+ 'problem_framing'
589
+ );
590
+
591
+ // Create Context v1
592
+ const context = createInitialContext(council.id, response.content, manager.id);
593
+
594
+ // Update deliberation state
595
+ councilStore.setActiveContext(council.id, context.id, context.version);
596
+
597
+ // Create ledger entry
598
+ const entry = this.createEntry(
599
+ council.id,
600
+ 'manager',
601
+ manager.id,
602
+ 'problem_statement',
603
+ 'problem_framing',
604
+ response.content,
605
+ response.tokensUsed,
606
+ response.latencyMs,
607
+ [{ artifactType: 'context', artifactId: context.id, version: 1 }],
608
+ undefined,
609
+ response.structured
610
+ );
611
+
612
+ // Check if consultants exist — skip deliberation rounds if none
613
+ const consultants = this.getConsultants(council);
614
+ if (consultants.length === 0) {
615
+ console.log('[Orchestrator] 0 consultants — skipping deliberation rounds, moving to deciding');
616
+ this.transitionPhase(council.id, 'deciding');
617
+ } else {
618
+ // Transition to round_independent
619
+ this.transitionPhase(council.id, 'round_independent');
620
+ councilStore.advanceRound(council.id);
621
+ }
622
+
623
+ return entry;
624
+ }
625
+
626
+ // ==========================================================================
627
+ // Phase 2: Deliberation Rounds
628
+ // ==========================================================================
629
+
630
+ /**
631
+ * Generate independent analysis for all consultants (Round 1)
632
+ */
633
+ async runIndependentRound(council: Council): Promise<LedgerEntry[]> {
634
+ this.validatePhase(council, 'round_independent');
635
+ const consultants = this.getConsultants(council);
636
+ const context = getCurrentContext(council.id);
637
+
638
+ // Detailed logging to debug missing consultant participation
639
+ const allRoleAssignments = council.deliberation?.roleAssignments || [];
640
+ const consultantRoles = allRoleAssignments.filter(r => r.role === 'consultant');
641
+ console.log(`[Orchestrator] Independent round: ${consultants.length} consultant(s) ` +
642
+ `from ${consultantRoles.length} consultant role assignment(s)`,
643
+ {
644
+ consultantNames: consultants.map(c => `${c.name} (${c.id.slice(0, 8)})`),
645
+ consultantRoleIds: consultantRoles.map(r => r.personaId.slice(0, 8)),
646
+ allPersonaIds: council.personas.map(p => `${p.name}:${p.id.slice(0, 8)}`),
647
+ });
648
+
649
+ if (!context) {
650
+ throw new Error('No context found - problem must be framed first');
651
+ }
652
+
653
+ if (consultants.length === 0) {
654
+ console.warn('[Orchestrator] No consultants assigned - skipping independent round');
655
+ this.transitionPhase(council.id, 'round_waiting_for_manager');
656
+ return [];
657
+ }
658
+
659
+ const entries: LedgerEntry[] = [];
660
+
661
+ const runOne = async (consultant: Persona): Promise<LedgerEntry> => {
662
+ const assignment = getRoleAssignment(council, consultant.id);
663
+ const focusArea = assignment?.focusArea || consultant.predisposition.domain || 'general';
664
+
665
+ const systemPrompt = consultant.predisposition.systemPrompt;
666
+ const userMessage = buildIndependentAnalysisPrompt(consultant, focusArea, context.content);
667
+
668
+ const response = await this.invokeAgentSafe(
669
+ { personaId: consultant.id, systemPrompt, userMessage },
670
+ consultant,
671
+ 'independent_analysis'
672
+ );
673
+
674
+ const proposedPatch = this.extractContextProposal(response.content);
675
+
676
+ const entry = this.createEntry(
677
+ council.id,
678
+ 'consultant',
679
+ consultant.id,
680
+ 'analysis',
681
+ 'round_independent',
682
+ response.content,
683
+ response.tokensUsed,
684
+ response.latencyMs,
685
+ undefined,
686
+ council.deliberationState?.currentRound,
687
+ response.structured
688
+ );
689
+
690
+ councilStore.recordSubmission(council.id, consultant.id);
691
+
692
+ // Evolve context with consultant findings
693
+ this.appendToContext(council.id, 'Consultant Analysis', consultant.name, response.content, 'consultant', consultant.id);
694
+
695
+ if (proposedPatch) {
696
+ const patch = createPatch(
697
+ council.id,
698
+ context.id,
699
+ context.version,
700
+ proposedPatch.diff,
701
+ proposedPatch.rationale,
702
+ consultant.id,
703
+ council.deliberationState?.currentRound || 1
704
+ );
705
+ councilStore.addPendingPatch(council.id, patch.id);
706
+ }
707
+
708
+ return entry;
709
+ };
710
+
711
+ // Respect consultantExecution setting
712
+ const isSequential = council.deliberation?.consultantExecution === 'sequential';
713
+
714
+ if (isSequential) {
715
+ // Sequential: run one at a time, each with retry
716
+ for (const consultant of consultants) {
717
+ const entry = await this.runConsultantWithRetry(council, consultant, () => runOne(consultant));
718
+ if (entry) entries.push(entry);
719
+ }
720
+ } else {
721
+ // Parallel: all at once, each with retry
722
+ const results = await Promise.allSettled(
723
+ consultants.map((consultant) =>
724
+ this.runConsultantWithRetry(council, consultant, () => runOne(consultant))
725
+ )
726
+ );
727
+ for (const result of results) {
728
+ if (result.status === 'fulfilled' && result.value) {
729
+ entries.push(result.value);
730
+ }
731
+ }
732
+ }
733
+
734
+ // Check if round is complete (all consultants submitted or accounted for)
735
+ if (councilStore.isRoundComplete(council.id)) {
736
+ this.transitionPhase(council.id, 'round_waiting_for_manager');
737
+ }
738
+
739
+ return entries;
740
+ }
741
+
742
+ /**
743
+ * Generate deliberation response for a consultant (Round 2+)
744
+ */
745
+ async generateDeliberationResponse(council: Council, consultantId: string): Promise<LedgerEntry> {
746
+ this.validatePhase(council, 'round_interactive');
747
+ const consultant = council.personas.find((p) => p.id === consultantId);
748
+
749
+ if (!consultant) {
750
+ throw new Error(`Consultant not found: ${consultantId}`);
751
+ }
752
+
753
+ const assignment = getRoleAssignment(council, consultant.id);
754
+ const focusArea = assignment?.focusArea || consultant.predisposition.domain || 'general';
755
+
756
+ // Build context per Section 9.3
757
+ // In sequential mode, include current round entries so later consultants
758
+ // can see and build on earlier consultants' responses from this round
759
+ const isSequential = council.deliberation?.consultantExecution === 'sequential';
760
+ const contextContent = this.buildRoundNContext(council, isSequential);
761
+
762
+ // Build prompts
763
+ const systemPrompt = consultant.predisposition.systemPrompt;
764
+ const userMessage = buildDeliberationResponsePrompt(consultant, focusArea, contextContent);
765
+
766
+ const response = await this.invokeAgentSafe(
767
+ { personaId: consultant.id, systemPrompt, userMessage },
768
+ consultant,
769
+ 'deliberation_response'
770
+ );
771
+
772
+ // Check for context change proposal
773
+ const proposedPatch = this.extractContextProposal(response.content);
774
+ const context = getCurrentContext(council.id);
775
+
776
+ // Create ledger entry
777
+ const entryType: LedgerEntryType = proposedPatch ? 'proposal' : 'response';
778
+ const entry = this.createEntry(
779
+ council.id,
780
+ 'consultant',
781
+ consultant.id,
782
+ entryType,
783
+ 'round_interactive',
784
+ response.content,
785
+ response.tokensUsed,
786
+ response.latencyMs,
787
+ undefined,
788
+ council.deliberationState?.currentRound,
789
+ response.structured
790
+ );
791
+
792
+ // Record submission
793
+ councilStore.recordSubmission(council.id, consultant.id);
794
+
795
+ // Create patch if proposed
796
+ if (proposedPatch && context) {
797
+ const patch = createPatch(
798
+ council.id,
799
+ context.id,
800
+ context.version,
801
+ proposedPatch.diff,
802
+ proposedPatch.rationale,
803
+ consultant.id,
804
+ council.deliberationState?.currentRound || 1
805
+ );
806
+ councilStore.addPendingPatch(council.id, patch.id);
807
+ }
808
+
809
+ return entry;
810
+ }
811
+
812
+ /**
813
+ * Run a full interactive round for all consultants
814
+ */
815
+ async runInteractiveRound(council: Council): Promise<LedgerEntry[]> {
816
+ this.validatePhase(council, 'round_interactive');
817
+ const consultants = this.getConsultants(council);
818
+ const entries: LedgerEntry[] = [];
819
+
820
+ // Detailed logging to debug missing consultant participation
821
+ const allRoleAssignments = council.deliberation?.roleAssignments || [];
822
+ const consultantRoles = allRoleAssignments.filter(r => r.role === 'consultant');
823
+ console.log(`[Orchestrator] Interactive round ${council.deliberationState?.currentRound}: ` +
824
+ `${consultants.length} consultant(s) from ${consultantRoles.length} consultant role assignment(s)`,
825
+ {
826
+ consultantNames: consultants.map(c => `${c.name} (${c.id.slice(0, 8)})`),
827
+ consultantRoleIds: consultantRoles.map(r => r.personaId.slice(0, 8)),
828
+ allPersonaIds: council.personas.map(p => `${p.name}:${p.id.slice(0, 8)}`),
829
+ });
830
+
831
+ if (consultants.length === 0) {
832
+ console.warn('[Orchestrator] No consultants assigned - skipping interactive round');
833
+ this.transitionPhase(council.id, 'round_waiting_for_manager');
834
+ return [];
835
+ }
836
+
837
+ // Respect consultantExecution setting
838
+ const isSequential = council.deliberation?.consultantExecution === 'sequential';
839
+
840
+ if (isSequential) {
841
+ // Sequential: each consultant sees previous consultants' responses in this round
842
+ for (const consultant of consultants) {
843
+ const entry = await this.runConsultantWithRetry(
844
+ council,
845
+ consultant,
846
+ () => this.generateDeliberationResponse(councilStore.get(council.id)!, consultant.id)
847
+ );
848
+ if (entry) entries.push(entry);
849
+ }
850
+ } else {
851
+ // Parallel: all consultants respond simultaneously with same context
852
+ const results = await Promise.allSettled(
853
+ consultants.map((consultant) =>
854
+ this.runConsultantWithRetry(
855
+ council,
856
+ consultant,
857
+ () => this.generateDeliberationResponse(council, consultant.id)
858
+ )
859
+ )
860
+ );
861
+ for (const result of results) {
862
+ if (result.status === 'fulfilled' && result.value) {
863
+ entries.push(result.value);
864
+ }
865
+ }
866
+ }
867
+
868
+ // Check if round is complete (all consultants submitted or accounted for)
869
+ const updatedCouncil = councilStore.get(council.id);
870
+ if (updatedCouncil && councilStore.isRoundComplete(council.id)) {
871
+ this.transitionPhase(council.id, 'round_waiting_for_manager');
872
+ }
873
+
874
+ return entries;
875
+ }
876
+
877
+ /**
878
+ * Manager evaluates the round
879
+ */
880
+ async managerEvaluate(council: Council): Promise<ManagerEvaluation> {
881
+ this.validatePhase(council, 'round_waiting_for_manager');
882
+ const manager = this.getManager(council);
883
+
884
+ // Build context per Section 9.4
885
+ const evalContext = this.buildManagerEvalContext(council);
886
+ const pendingPatches = getPendingPatches(council.id);
887
+ const expectedOutput = council.deliberation?.expectedOutput;
888
+
889
+ // Build prompts
890
+ const systemPrompt = manager.predisposition.systemPrompt;
891
+ const userMessage = buildManagerEvaluationPrompt(evalContext, pendingPatches, expectedOutput);
892
+
893
+ const response = await this.invokeAgentSafe(
894
+ { personaId: manager.id, systemPrompt, userMessage },
895
+ manager,
896
+ 'manager_evaluation'
897
+ );
898
+
899
+ // Parse structured response
900
+ const evaluation = this.parseManagerEvaluation(response.content);
901
+
902
+ // Process patch decisions
903
+ if (evaluation.patchDecisions) {
904
+ for (const decision of evaluation.patchDecisions) {
905
+ const patch = getPendingPatches(council.id).find((p) => p.id === decision.patchId);
906
+ if (!patch) {
907
+ console.warn(`[Deliberation] Patch not found: ${decision.patchId}`);
908
+ continue;
909
+ }
910
+
911
+ if (decision.accepted) {
912
+ const currentContext = getCurrentContext(council.id);
913
+
914
+ // Check for staleness - patch was proposed against an older context version
915
+ const isStale = currentContext ? patch.baseVersion < currentContext.version : false;
916
+
917
+ if (isStale) {
918
+ // For stale patches, we need to either:
919
+ // 1. Reject and ask for rebase
920
+ // 2. Apply with allowStale (if manager explicitly approves)
921
+ // For now, we log a warning and allow with explicit flag
922
+ console.warn(
923
+ `[Deliberation] Accepting stale patch ${patch.id}: ` +
924
+ `proposed against v${patch.baseVersion}, current is v${currentContext!.version}`
925
+ );
926
+ }
927
+
928
+ // Intelligently integrate the patch into the current context
929
+ // The patch.diff contains the proposed change/addition
930
+ // Instead of naive append, use the manager's decision reason as context
931
+ const integrationNote = isStale
932
+ ? `[Context change from ${this.getPersonaDisplayName(council, patch.authorPersonaId)}, ` +
933
+ `originally proposed against v${patch.baseVersion}, integrated at v${currentContext?.version || 1}]`
934
+ : `[Context change from ${this.getPersonaDisplayName(council, patch.authorPersonaId)}]`;
935
+
936
+ const newContent = currentContext
937
+ ? `${currentContext.content}\n\n${integrationNote}:\n${patch.diff}`
938
+ : patch.diff;
939
+
940
+ const result = acceptPatch(
941
+ council.id,
942
+ decision.patchId,
943
+ manager.id,
944
+ decision.reason,
945
+ newContent,
946
+ {
947
+ allowStale: true, // Manager made explicit decision to accept
948
+ changeSummary: `${patch.rationale}${isStale ? ' (applied to newer context)' : ''}`,
949
+ }
950
+ );
951
+
952
+ councilStore.removePendingPatch(council.id, decision.patchId);
953
+ councilStore.setActiveContext(council.id, result.newContext.id, result.newContext.version);
954
+
955
+ // Create acceptance ledger entry with staleness info
956
+ this.createEntry(
957
+ council.id,
958
+ 'manager',
959
+ manager.id,
960
+ 'context_acceptance',
961
+ 'round_waiting_for_manager',
962
+ `Accepted context change: ${decision.reason}${result.wasStale ? ' (applied to newer context version)' : ''}`,
963
+ 0, 0,
964
+ [{ artifactType: 'context', artifactId: result.newContext.id, version: result.newContext.version }]
965
+ );
966
+ } else {
967
+ rejectPatch(council.id, decision.patchId, manager.id, decision.reason);
968
+ councilStore.removePendingPatch(council.id, decision.patchId);
969
+
970
+ // Create rejection ledger entry
971
+ this.createEntry(
972
+ council.id,
973
+ 'manager',
974
+ manager.id,
975
+ 'context_rejection',
976
+ 'round_waiting_for_manager',
977
+ `Rejected context change: ${decision.reason}`,
978
+ 0, 0
979
+ );
980
+ }
981
+ }
982
+ }
983
+
984
+ // Store evaluation
985
+ councilStore.setManagerEvaluation(council.id, evaluation);
986
+
987
+ // Handle action
988
+ const currentRound = council.deliberationState?.currentRound || 1;
989
+ const minRounds = council.deliberation?.minRounds || 1;
990
+ const maxRounds = council.deliberation?.maxRounds || 4;
991
+
992
+ // Enforce min rounds: if manager wants to decide but we haven't hit minRounds, continue instead
993
+ const canDecide = currentRound >= minRounds;
994
+ const mustDecide = currentRound >= maxRounds;
995
+
996
+ if (mustDecide || (evaluation.action === 'decide' && canDecide)) {
997
+ this.transitionPhase(council.id, 'deciding');
998
+ } else if (!canDecide && evaluation.action === 'decide') {
999
+ // Manager tried to decide too early — force another round
1000
+ console.log(`[Orchestrator] Manager wanted to decide at round ${currentRound}, but minRounds is ${minRounds}. Continuing.`);
1001
+ councilStore.advanceRound(council.id);
1002
+ this.transitionPhase(council.id, 'round_interactive');
1003
+ } else if (evaluation.action === 'continue' || evaluation.action === 'redirect') {
1004
+ // Create question/redirect entry if provided
1005
+ if (evaluation.question) {
1006
+ const entryType: LedgerEntryType = evaluation.action === 'redirect' ? 'manager_redirect' : 'manager_question';
1007
+ this.createEntry(
1008
+ council.id,
1009
+ 'manager',
1010
+ manager.id,
1011
+ entryType,
1012
+ 'round_waiting_for_manager',
1013
+ evaluation.question,
1014
+ response.tokensUsed,
1015
+ response.latencyMs,
1016
+ undefined,
1017
+ currentRound,
1018
+ response.structured
1019
+ );
1020
+ }
1021
+
1022
+ // Generate summary if needed
1023
+ if (this.shouldSummarize(council)) {
1024
+ await this.generateRoundSummary(council);
1025
+ }
1026
+
1027
+ // Advance to next round
1028
+ councilStore.advanceRound(council.id);
1029
+ this.transitionPhase(council.id, 'round_interactive');
1030
+ }
1031
+
1032
+ return evaluation;
1033
+ }
1034
+
1035
+ /**
1036
+ * Generate round summary
1037
+ */
1038
+ async generateRoundSummary(council: Council): Promise<LedgerEntry> {
1039
+ const manager = this.getManager(council);
1040
+ const currentRound = council.deliberationState?.currentRound || 1;
1041
+ const roundEntries = getEntriesForRound(council.id, currentRound);
1042
+
1043
+ let summaryContent: string;
1044
+ let tokensUsed = 0;
1045
+ let latencyMs = 0;
1046
+ let summaryStructured: Record<string, unknown> | undefined;
1047
+
1048
+ const summaryMode = council.deliberation?.summaryMode || 'manager';
1049
+ const summarizeAfterRound = council.deliberation?.summarizeAfterRound || 2;
1050
+
1051
+ // Determine whether to use automatic or manager summary
1052
+ const useAutomatic = summaryMode === 'automatic' ||
1053
+ (summaryMode === 'hybrid' && currentRound <= summarizeAfterRound + 1);
1054
+
1055
+ if (useAutomatic) {
1056
+ summaryContent = buildMechanicalSummary(roundEntries);
1057
+ } else {
1058
+ // Manager-generated summary
1059
+ const systemPrompt = manager.predisposition.systemPrompt;
1060
+ const userMessage = buildManagerRoundSummaryPrompt(roundEntries);
1061
+
1062
+ const response = await this.invokeAgentSafe(
1063
+ { personaId: manager.id, systemPrompt, userMessage },
1064
+ manager,
1065
+ 'round_summary'
1066
+ );
1067
+
1068
+ summaryContent = response.content;
1069
+ tokensUsed = response.tokensUsed;
1070
+ latencyMs = response.latencyMs;
1071
+ summaryStructured = response.structured;
1072
+ }
1073
+
1074
+ // Store summary
1075
+ councilStore.setRoundSummary(council.id, currentRound, summaryContent);
1076
+
1077
+ // Create ledger entry
1078
+ return this.createEntry(
1079
+ council.id,
1080
+ 'manager',
1081
+ manager.id,
1082
+ 'round_summary',
1083
+ council.deliberationState?.currentPhase || 'round_waiting_for_manager',
1084
+ summaryContent,
1085
+ tokensUsed,
1086
+ latencyMs,
1087
+ undefined,
1088
+ currentRound,
1089
+ summaryStructured
1090
+ );
1091
+ }
1092
+
1093
+ // ==========================================================================
1094
+ // Consultant Final Positions (before decision)
1095
+ // ==========================================================================
1096
+
1097
+ private async collectConsultantFinalPositions(council: Council): Promise<string> {
1098
+ const consultants = this.getConsultants(council);
1099
+ if (consultants.length === 0) return '';
1100
+
1101
+ const contextContent = this.buildRoundNContext(council, false);
1102
+ const positions: string[] = [];
1103
+
1104
+ for (const consultant of consultants) {
1105
+ const assignment = getRoleAssignment(council, consultant.id);
1106
+ const focusArea = assignment?.focusArea || consultant.predisposition.domain || 'general';
1107
+ const systemPrompt = consultant.predisposition.systemPrompt;
1108
+ const userMessage = buildConsultantFinalPositionPrompt(consultant, focusArea, contextContent);
1109
+
1110
+ this.config.onAgentThinkingStart?.(consultant, Date.now(), userMessage);
1111
+ const response = await this.invokeAgentSafe(
1112
+ { personaId: consultant.id, systemPrompt, userMessage },
1113
+ consultant,
1114
+ 'deliberation_response' // reuse existing tool phase (READ_ONLY_TOOLS)
1115
+ );
1116
+ this.config.onAgentThinkingEnd?.(consultant);
1117
+
1118
+ positions.push(`[${consultant.name} - ${focusArea}]: ${response.content}`);
1119
+
1120
+ this.createEntry(council.id, 'consultant', consultant.id, 'response', 'deciding',
1121
+ response.content, response.tokensUsed, response.latencyMs, undefined,
1122
+ council.deliberationState?.currentRound, response.structured);
1123
+ }
1124
+
1125
+ return positions.join('\n\n');
1126
+ }
1127
+
1128
+ // ==========================================================================
1129
+ // Phase 3: Decision
1130
+ // ==========================================================================
1131
+
1132
+ /**
1133
+ * Manager makes final decision
1134
+ */
1135
+ async makeDecision(council: Council): Promise<LedgerEntry> {
1136
+ this.validatePhase(council, 'deciding');
1137
+ const manager = this.getManager(council);
1138
+
1139
+ // Collect consultant final positions before manager decides
1140
+ const consultantPositions = await this.collectConsultantFinalPositions(council);
1141
+
1142
+ // Build context per Section 9.5
1143
+ const decisionContext = this.buildDecisionContext(council);
1144
+ const fullContext = consultantPositions
1145
+ ? `CONSULTANT FINAL POSITIONS:\n${consultantPositions}\n\n---\n\n${decisionContext}`
1146
+ : decisionContext;
1147
+ const decisionCriteria = council.deliberation?.decisionCriteria;
1148
+ const expectedOutput = council.deliberation?.expectedOutput;
1149
+
1150
+ // Build prompts
1151
+ const systemPrompt = manager.predisposition.systemPrompt;
1152
+ const stepType = council.deliberation?.stepType;
1153
+ const userMessage = buildManagerDecisionPrompt(fullContext, decisionCriteria, expectedOutput, stepType);
1154
+
1155
+ const response = await this.invokeAgentSafe(
1156
+ { personaId: manager.id, systemPrompt, userMessage },
1157
+ manager,
1158
+ 'decision'
1159
+ );
1160
+
1161
+ // Create decision artifact
1162
+ const context = getCurrentContext(council.id);
1163
+ const acceptanceCriteria = this.extractAcceptanceCriteria(response.content);
1164
+
1165
+ const decision = createDecision(
1166
+ council.id,
1167
+ response.content,
1168
+ context?.version || 1,
1169
+ acceptanceCriteria
1170
+ );
1171
+
1172
+ // Update state
1173
+ councilStore.setFinalDecision(council.id, decision.id);
1174
+
1175
+ // Create ledger entry
1176
+ const entry = this.createEntry(
1177
+ council.id,
1178
+ 'manager',
1179
+ manager.id,
1180
+ 'decision',
1181
+ 'deciding',
1182
+ response.content,
1183
+ response.tokensUsed,
1184
+ response.latencyMs,
1185
+ [{ artifactType: 'decision', artifactId: decision.id }],
1186
+ undefined,
1187
+ response.structured
1188
+ );
1189
+
1190
+ // Check if workers exist — if not, decision IS the output
1191
+ const workers = getPersonaByRole(council, 'worker');
1192
+ if (workers.length === 0) {
1193
+ console.log('[Orchestrator] 0 workers — decision is the final output');
1194
+ councilStore.updateDeliberationState(council.id, { completionSummary: response.content });
1195
+ this.transitionPhase(council.id, 'completed');
1196
+ councilStore.setStatus(council.id, 'resolved');
1197
+ return entry;
1198
+ }
1199
+
1200
+ // Transition to planning or directing
1201
+ const requirePlan = council.deliberation?.requirePlan || false;
1202
+ this.transitionPhase(council.id, requirePlan ? 'planning' : 'directing');
1203
+
1204
+ return entry;
1205
+ }
1206
+
1207
+ /**
1208
+ * Manager creates execution plan (optional)
1209
+ */
1210
+ async createPlan(council: Council): Promise<LedgerEntry> {
1211
+ this.validatePhase(council, 'planning');
1212
+ const manager = this.getManager(council);
1213
+ const decision = getDecision(council.id);
1214
+
1215
+ if (!decision) {
1216
+ throw new Error('No decision found - must make decision first');
1217
+ }
1218
+
1219
+ // Build prompts
1220
+ const systemPrompt = manager.predisposition.systemPrompt;
1221
+ const userMessage = buildManagerPlanPrompt(decision.content);
1222
+
1223
+ const response = await this.invokeAgentSafe(
1224
+ { personaId: manager.id, systemPrompt, userMessage },
1225
+ manager,
1226
+ 'plan'
1227
+ );
1228
+
1229
+ // Create plan artifact
1230
+ const plan = createPlan(council.id, response.content, decision.id);
1231
+
1232
+ // Create ledger entry
1233
+ const entry = this.createEntry(
1234
+ council.id,
1235
+ 'manager',
1236
+ manager.id,
1237
+ 'plan',
1238
+ 'planning',
1239
+ response.content,
1240
+ response.tokensUsed,
1241
+ response.latencyMs,
1242
+ [{ artifactType: 'plan', artifactId: plan.id }],
1243
+ undefined,
1244
+ response.structured
1245
+ );
1246
+
1247
+ this.transitionPhase(council.id, 'directing');
1248
+ return entry;
1249
+ }
1250
+
1251
+ // ==========================================================================
1252
+ // Phase 4: Work Directive
1253
+ // ==========================================================================
1254
+
1255
+ /**
1256
+ * Manager issues work directive
1257
+ */
1258
+ async issueDirective(council: Council): Promise<LedgerEntry> {
1259
+ this.validatePhase(council, 'directing');
1260
+ const manager = this.getManager(council);
1261
+ const decision = getDecision(council.id);
1262
+ const plan = getPlan(council.id);
1263
+
1264
+ if (!decision) {
1265
+ throw new Error('No decision found - must make decision first');
1266
+ }
1267
+
1268
+ // Check if the worker has write permissions (informs the directive to emphasize implementation)
1269
+ const worker = this.getWorker(council);
1270
+ const workerAssignment = getRoleAssignment(council, worker.id);
1271
+ const hasWritePermissions = !!(workerAssignment?.writePermissions);
1272
+ const stepType = council.deliberation?.stepType;
1273
+
1274
+ // Build prompts per Section 9.6
1275
+ const systemPrompt = manager.predisposition.systemPrompt;
1276
+ const userMessage = buildWorkDirectivePrompt(decision.content, plan?.content, hasWritePermissions, stepType);
1277
+
1278
+ const response = await this.invokeAgentSafe(
1279
+ { personaId: manager.id, systemPrompt, userMessage },
1280
+ manager,
1281
+ 'directive'
1282
+ );
1283
+
1284
+ // Create directive artifact
1285
+ const directive = createDirective(
1286
+ council.id,
1287
+ response.content,
1288
+ decision.id,
1289
+ plan?.id
1290
+ );
1291
+
1292
+ // Update state
1293
+ councilStore.setWorkDirective(council.id, directive.id);
1294
+
1295
+ // Create ledger entry
1296
+ const entry = this.createEntry(
1297
+ council.id,
1298
+ 'manager',
1299
+ manager.id,
1300
+ 'work_directive',
1301
+ 'directing',
1302
+ response.content,
1303
+ response.tokensUsed,
1304
+ response.latencyMs,
1305
+ [{ artifactType: 'directive', artifactId: directive.id }],
1306
+ undefined,
1307
+ response.structured
1308
+ );
1309
+
1310
+ this.transitionPhase(council.id, 'executing');
1311
+ return entry;
1312
+ }
1313
+
1314
+ // ==========================================================================
1315
+ // Phase 5: Execution
1316
+ // ==========================================================================
1317
+
1318
+ /**
1319
+ * Worker executes directive
1320
+ */
1321
+ async executeWork(council: Council): Promise<LedgerEntry> {
1322
+ this.validatePhase(council, 'executing');
1323
+ const worker = this.getWorker(council);
1324
+ const directive = getDirective(council.id);
1325
+
1326
+ if (!directive) {
1327
+ throw new Error('No directive found - must issue directive first');
1328
+ }
1329
+
1330
+ // Get role assignment for suppress check
1331
+ const assignment = getRoleAssignment(council, worker.id);
1332
+ const suppressPersona = assignment?.suppressPersona !== false; // Default true for worker
1333
+
1334
+ // Build worker permissions from role assignment + deliberation config
1335
+ const workerPermissions: WorkerPermissions = {
1336
+ writePermissions: assignment?.writePermissions,
1337
+ workingDirectory: council.deliberation?.workingDirectory,
1338
+ directoryConstrained: council.deliberation?.directoryConstrained,
1339
+ };
1340
+
1341
+ // Build prompts per Section 9.7
1342
+ const stepType = council.deliberation?.stepType;
1343
+ const systemPrompt = suppressPersona
1344
+ ? getMinimalWorkerSystemPrompt(workerPermissions, stepType)
1345
+ : worker.predisposition.systemPrompt;
1346
+
1347
+ const userMessage = buildWorkerExecutionPrompt(directive.content, workerPermissions, stepType);
1348
+
1349
+ const response = await this.invokeAgentSafe(
1350
+ { personaId: worker.id, systemPrompt, userMessage },
1351
+ worker,
1352
+ 'execution'
1353
+ );
1354
+
1355
+ // Create output artifact
1356
+ const output = createOutput(council.id, response.content, directive.id);
1357
+
1358
+ // Update state
1359
+ councilStore.setCurrentOutput(council.id, output.id);
1360
+
1361
+ // Create ledger entry
1362
+ const entry = this.createEntry(
1363
+ council.id,
1364
+ 'worker',
1365
+ worker.id,
1366
+ 'work_output',
1367
+ 'executing',
1368
+ response.content,
1369
+ response.tokensUsed,
1370
+ response.latencyMs,
1371
+ [{ artifactType: 'output', artifactId: output.id, version: output.version }],
1372
+ undefined,
1373
+ response.structured
1374
+ );
1375
+
1376
+ // Evolve context with worker results
1377
+ this.appendToContext(council.id, 'Worker Execution', worker.name, response.content, 'worker', worker.id);
1378
+
1379
+ this.transitionPhase(council.id, 'reviewing');
1380
+ return entry;
1381
+ }
1382
+
1383
+ // ==========================================================================
1384
+ // Consultant Reviews (before manager verdict)
1385
+ // ==========================================================================
1386
+
1387
+ private async collectConsultantReviews(council: Council): Promise<string> {
1388
+ const consultants = this.getConsultants(council);
1389
+ if (consultants.length === 0) return '';
1390
+
1391
+ const output = getLatestOutput(council.id);
1392
+ const directive = getDirective(council.id);
1393
+ if (!output || !directive) return '';
1394
+
1395
+ const expectedOutput = council.deliberation?.expectedOutput;
1396
+
1397
+ // Run consultant reviews in parallel to reduce total time
1398
+ const reviewOne = async (consultant: Persona): Promise<string> => {
1399
+ const assignment = getRoleAssignment(council, consultant.id);
1400
+ const focusArea = assignment?.focusArea || consultant.predisposition.domain || 'general';
1401
+ const systemPrompt = consultant.predisposition.systemPrompt;
1402
+ const userMessage = buildConsultantReviewPrompt(
1403
+ consultant, focusArea, output.content, directive.content, expectedOutput
1404
+ );
1405
+
1406
+ const response = await this.invokeAgentSafe(
1407
+ { personaId: consultant.id, systemPrompt, userMessage },
1408
+ consultant,
1409
+ 'consultant_review'
1410
+ );
1411
+
1412
+ this.createEntry(council.id, 'consultant', consultant.id, 'review', 'reviewing',
1413
+ response.content, response.tokensUsed, response.latencyMs, undefined,
1414
+ council.deliberationState?.currentRound, response.structured);
1415
+
1416
+ return `[${consultant.name} - ${focusArea}]: ${response.content}`;
1417
+ };
1418
+
1419
+ const results = await Promise.allSettled(consultants.map(c => reviewOne(c)));
1420
+
1421
+ const reviews: string[] = [];
1422
+ for (const result of results) {
1423
+ if (result.status === 'fulfilled') {
1424
+ reviews.push(result.value);
1425
+ } else {
1426
+ console.warn('[Orchestrator] Consultant review failed:', result.reason);
1427
+ }
1428
+ }
1429
+
1430
+ return reviews.join('\n\n');
1431
+ }
1432
+
1433
+ // ==========================================================================
1434
+ // Phase 6: Review
1435
+ // ==========================================================================
1436
+
1437
+ /**
1438
+ * Manager reviews work output
1439
+ */
1440
+ async reviewWork(council: Council): Promise<{ entry: LedgerEntry; review: ManagerReview }> {
1441
+ this.validatePhase(council, 'reviewing');
1442
+ const manager = this.getManager(council);
1443
+ const directive = getDirective(council.id);
1444
+ const output = getLatestOutput(council.id);
1445
+ const decision = getDecision(council.id);
1446
+
1447
+ if (!directive || !output) {
1448
+ throw new Error('Missing directive or output for review');
1449
+ }
1450
+
1451
+ // Collect consultant reviews first
1452
+ const consultantReviews = await this.collectConsultantReviews(council);
1453
+
1454
+ // Check if worker had write permissions (affects review criteria)
1455
+ const worker = this.getWorker(council);
1456
+ const workerAssignment = getRoleAssignment(council, worker.id);
1457
+ const hasWritePermissions = !!(workerAssignment?.writePermissions);
1458
+ const stepType = council.deliberation?.stepType;
1459
+
1460
+ // Build prompts per Section 9.8
1461
+ const systemPrompt = manager.predisposition.systemPrompt;
1462
+ const expectedOutput = council.deliberation?.expectedOutput;
1463
+ const userMessage = buildManagerReviewPrompt(
1464
+ output.content,
1465
+ directive.content,
1466
+ decision?.acceptanceCriteria,
1467
+ expectedOutput,
1468
+ hasWritePermissions,
1469
+ stepType,
1470
+ consultantReviews,
1471
+ );
1472
+
1473
+ const response = await this.invokeAgentSafe(
1474
+ { personaId: manager.id, systemPrompt, userMessage },
1475
+ manager,
1476
+ 'review'
1477
+ );
1478
+
1479
+ // Parse review
1480
+ const review = this.parseManagerReview(response.content);
1481
+
1482
+ // Create ledger entry
1483
+ const entry = this.createEntry(
1484
+ council.id,
1485
+ 'manager',
1486
+ manager.id,
1487
+ 'review',
1488
+ 'reviewing',
1489
+ response.content,
1490
+ response.tokensUsed,
1491
+ response.latencyMs,
1492
+ undefined,
1493
+ undefined,
1494
+ response.structured,
1495
+ undefined,
1496
+ review.verdict
1497
+ );
1498
+
1499
+ // Handle verdict
1500
+ if (review.verdict === 'accept') {
1501
+ this.transitionPhase(council.id, 'completed');
1502
+ councilStore.setStatus(council.id, 'resolved');
1503
+ } else if (review.verdict === 'revise') {
1504
+ const revisionCount = council.deliberationState?.revisionCount || 0;
1505
+ const maxRevisions = council.deliberation?.maxRevisions || 3;
1506
+
1507
+ if (revisionCount >= maxRevisions) {
1508
+ // Max revisions reached - complete with best effort
1509
+ this.transitionPhase(council.id, 'completed');
1510
+ councilStore.setStatus(council.id, 'resolved');
1511
+ } else {
1512
+ // Request revision
1513
+ if (review.feedback) {
1514
+ this.createEntry(
1515
+ council.id,
1516
+ 'manager',
1517
+ manager.id,
1518
+ 'revision_request',
1519
+ 'reviewing',
1520
+ review.feedback,
1521
+ 0, 0
1522
+ );
1523
+ }
1524
+ councilStore.incrementRevisionCount(council.id);
1525
+ this.transitionPhase(council.id, 'revising');
1526
+ }
1527
+ } else if (review.verdict === 're_deliberate') {
1528
+ // Start new deliberation round with new information
1529
+ let newContextVersion: number | undefined;
1530
+
1531
+ if (review.newInformation) {
1532
+ // Add new information to context
1533
+ const currentContext = getCurrentContext(council.id);
1534
+ if (currentContext) {
1535
+ const updatedContext = createContextVersion(
1536
+ council.id,
1537
+ `${currentContext.content}\n\n[New Information from Review]:\n${review.newInformation}`,
1538
+ 'New information emerged during review - re-deliberation required',
1539
+ 'manager',
1540
+ manager.id
1541
+ );
1542
+ newContextVersion = updatedContext.version;
1543
+
1544
+ // Update council's active context reference
1545
+ councilStore.setActiveContext(council.id, updatedContext.id, updatedContext.version);
1546
+ }
1547
+ }
1548
+
1549
+ // Write ledger entry for re-deliberation decision
1550
+ this.createEntry(
1551
+ council.id,
1552
+ 'manager',
1553
+ manager.id,
1554
+ 're_deliberation',
1555
+ 'reviewing',
1556
+ `Re-deliberation requested: ${review.reasoning || 'New information requires additional deliberation'}${
1557
+ review.newInformation ? `\n\nNew information:\n${review.newInformation}` : ''
1558
+ }${newContextVersion ? `\n\n[Context updated to version ${newContextVersion}]` : ''}`,
1559
+ 0, 0
1560
+ );
1561
+
1562
+ councilStore.advanceRound(council.id);
1563
+ this.transitionPhase(council.id, 'round_interactive');
1564
+ }
1565
+
1566
+ return { entry, review };
1567
+ }
1568
+
1569
+ /**
1570
+ * Worker revises output based on feedback
1571
+ */
1572
+ async requestRevision(council: Council): Promise<LedgerEntry> {
1573
+ this.validatePhase(council, 'revising');
1574
+ const worker = this.getWorker(council);
1575
+ const directive = getDirective(council.id);
1576
+ const previousOutput = getLatestOutput(council.id);
1577
+
1578
+ // Get latest revision request
1579
+ const revisionRequest = getLatestOfType(council.id, 'revision_request');
1580
+
1581
+ if (!directive || !previousOutput || !revisionRequest) {
1582
+ throw new Error('Missing directive, previous output, or revision feedback');
1583
+ }
1584
+
1585
+ // Get role assignment for suppress check
1586
+ const assignment = getRoleAssignment(council, worker.id);
1587
+ const suppressPersona = assignment?.suppressPersona !== false;
1588
+
1589
+ // Build worker permissions from role assignment + deliberation config
1590
+ const workerPermissions: WorkerPermissions = {
1591
+ writePermissions: assignment?.writePermissions,
1592
+ workingDirectory: council.deliberation?.workingDirectory,
1593
+ directoryConstrained: council.deliberation?.directoryConstrained,
1594
+ };
1595
+
1596
+ // Build prompts per Section 9.7.1
1597
+ const stepType = council.deliberation?.stepType;
1598
+ const systemPrompt = suppressPersona
1599
+ ? getMinimalWorkerSystemPrompt(workerPermissions, stepType)
1600
+ : worker.predisposition.systemPrompt;
1601
+
1602
+ const userMessage = buildWorkerRevisionPrompt(
1603
+ directive.content,
1604
+ previousOutput.content,
1605
+ revisionRequest.content,
1606
+ workerPermissions,
1607
+ stepType,
1608
+ );
1609
+
1610
+ const response = await this.invokeAgentSafe(
1611
+ { personaId: worker.id, systemPrompt, userMessage },
1612
+ worker,
1613
+ 'revision'
1614
+ );
1615
+
1616
+ // Create revision output
1617
+ const output = createRevisionOutput(
1618
+ council.id,
1619
+ response.content,
1620
+ directive.id,
1621
+ previousOutput.id
1622
+ );
1623
+
1624
+ // Update state
1625
+ councilStore.setCurrentOutput(council.id, output.id);
1626
+
1627
+ // Create ledger entry
1628
+ const entry = this.createEntry(
1629
+ council.id,
1630
+ 'worker',
1631
+ worker.id,
1632
+ 'work_output',
1633
+ 'revising',
1634
+ response.content,
1635
+ response.tokensUsed,
1636
+ response.latencyMs,
1637
+ [{ artifactType: 'output', artifactId: output.id, version: output.version }],
1638
+ undefined,
1639
+ response.structured
1640
+ );
1641
+
1642
+ // Evolve context with revision results
1643
+ this.appendToContext(council.id, 'Worker Revision', worker.name, response.content, 'worker', worker.id);
1644
+
1645
+ this.transitionPhase(council.id, 'reviewing');
1646
+ return entry;
1647
+ }
1648
+
1649
+ // ==========================================================================
1650
+ // Direct Execution (0 managers — worker-only council)
1651
+ // ==========================================================================
1652
+
1653
+ /**
1654
+ * Run direct execution: worker executes the raw input directly.
1655
+ * No manager framing, no deliberation, no review.
1656
+ * Used when a council has only worker(s) and no manager.
1657
+ */
1658
+ async runDirectExecution(council: Council, rawProblem: string): Promise<void> {
1659
+ this.activeCouncilId = council.id;
1660
+ const workers = getPersonaByRole(council, 'worker');
1661
+ if (workers.length === 0) {
1662
+ throw new Error('No workers assigned for direct execution');
1663
+ }
1664
+
1665
+ const worker = workers[0];
1666
+ const assignment = getRoleAssignment(council, worker.id);
1667
+ const suppressPersona = assignment?.suppressPersona !== false;
1668
+
1669
+ // Bootstrap directory context if enabled
1670
+ let enrichedProblem = rawProblem;
1671
+ if (council.deliberation?.bootstrapContext && council.deliberation?.workingDirectory) {
1672
+ try {
1673
+ const dirContext = await bootstrapDirectoryContext(council.deliberation.workingDirectory, { deep: true });
1674
+ if (dirContext) {
1675
+ enrichedProblem = `${dirContext}\n\n---\n\n${rawProblem}`;
1676
+ }
1677
+ } catch (error) {
1678
+ console.warn('[Orchestrator] Directory context bootstrap failed:', error);
1679
+ }
1680
+ }
1681
+
1682
+ // Create a synthetic directive from the raw input
1683
+ this.transitionPhase(council.id, 'executing');
1684
+
1685
+ const directive = createDirective(council.id, enrichedProblem, 'direct-execution');
1686
+ councilStore.setWorkDirective(council.id, directive.id);
1687
+
1688
+ this.createEntry(
1689
+ council.id,
1690
+ 'worker',
1691
+ worker.id,
1692
+ 'work_directive',
1693
+ 'executing',
1694
+ enrichedProblem,
1695
+ 0, 0,
1696
+ [{ artifactType: 'directive', artifactId: directive.id }]
1697
+ );
1698
+
1699
+ // Build worker permissions
1700
+ const workerPermissions: WorkerPermissions = {
1701
+ writePermissions: assignment?.writePermissions,
1702
+ workingDirectory: council.deliberation?.workingDirectory,
1703
+ directoryConstrained: council.deliberation?.directoryConstrained,
1704
+ };
1705
+
1706
+ const stepType = council.deliberation?.stepType;
1707
+ const systemPrompt = suppressPersona
1708
+ ? getMinimalWorkerSystemPrompt(workerPermissions, stepType)
1709
+ : worker.predisposition.systemPrompt;
1710
+
1711
+ const userMessage = buildWorkerExecutionPrompt(enrichedProblem, workerPermissions, stepType);
1712
+
1713
+ const response = await this.invokeAgentSafe(
1714
+ { personaId: worker.id, systemPrompt, userMessage },
1715
+ worker,
1716
+ 'execution'
1717
+ );
1718
+
1719
+ // Create output artifact
1720
+ const output = createOutput(council.id, response.content, directive.id);
1721
+ councilStore.setCurrentOutput(council.id, output.id);
1722
+
1723
+ this.createEntry(
1724
+ council.id,
1725
+ 'worker',
1726
+ worker.id,
1727
+ 'work_output',
1728
+ 'executing',
1729
+ response.content,
1730
+ response.tokensUsed,
1731
+ response.latencyMs,
1732
+ [{ artifactType: 'output', artifactId: output.id, version: output.version }],
1733
+ undefined,
1734
+ response.structured
1735
+ );
1736
+
1737
+ // No review — first output is accepted
1738
+ councilStore.updateDeliberationState(council.id, { completionSummary: response.content });
1739
+ this.transitionPhase(council.id, 'completed');
1740
+ councilStore.setStatus(council.id, 'resolved');
1741
+
1742
+ console.log('[Orchestrator] Direct execution completed');
1743
+ }
1744
+
1745
+ // ==========================================================================
1746
+ // Control Operations
1747
+ // ==========================================================================
1748
+
1749
+ /**
1750
+ * Pause deliberation
1751
+ */
1752
+ async pause(council: Council): Promise<void> {
1753
+ const currentPhase = council.deliberationState?.currentPhase;
1754
+ if (!currentPhase || PHASE_TRANSITIONS[currentPhase].terminal) {
1755
+ throw new Error('Cannot pause - deliberation is in terminal state');
1756
+ }
1757
+
1758
+ councilStore.setDeliberationPhase(council.id, 'paused', currentPhase);
1759
+ councilStore.setStatus(council.id, 'paused');
1760
+ }
1761
+
1762
+ /**
1763
+ * Resume deliberation
1764
+ */
1765
+ async resume(council: Council): Promise<void> {
1766
+ if (council.deliberationState?.currentPhase !== 'paused') {
1767
+ throw new Error('Council is not paused');
1768
+ }
1769
+
1770
+ const previousPhase = council.deliberationState.previousPhase;
1771
+ if (!previousPhase) {
1772
+ throw new Error('No previous phase to resume from');
1773
+ }
1774
+
1775
+ councilStore.setDeliberationPhase(council.id, previousPhase);
1776
+ councilStore.setStatus(council.id, 'active');
1777
+ }
1778
+
1779
+ /**
1780
+ * Cancel and force decision
1781
+ */
1782
+ async cancelAndForceDecision(council: Council): Promise<LedgerEntry> {
1783
+ const manager = this.getManager(council);
1784
+
1785
+ // Create cancellation entry
1786
+ this.createEntry(
1787
+ council.id,
1788
+ 'manager',
1789
+ manager.id,
1790
+ 'cancellation',
1791
+ council.deliberationState?.currentPhase || 'created',
1792
+ 'Deliberation ended early by user - forcing decision',
1793
+ 0, 0
1794
+ );
1795
+
1796
+ // Build forced decision context per Section 9.9
1797
+ const contextContent = this.buildForcedDecisionContext(council);
1798
+
1799
+ const systemPrompt = manager.predisposition.systemPrompt;
1800
+ const stepType = council.deliberation?.stepType;
1801
+ const userMessage = buildManagerForcedDecisionPrompt(contextContent, stepType);
1802
+
1803
+ const response = await this.invokeAgentSafe(
1804
+ { personaId: manager.id, systemPrompt, userMessage },
1805
+ manager,
1806
+ 'forced_decision'
1807
+ );
1808
+
1809
+ // Create decision artifact
1810
+ const context = getCurrentContext(council.id);
1811
+ const decision = createDecision(
1812
+ council.id,
1813
+ response.content,
1814
+ context?.version || 1
1815
+ );
1816
+
1817
+ councilStore.setFinalDecision(council.id, decision.id);
1818
+
1819
+ const entry = this.createEntry(
1820
+ council.id,
1821
+ 'manager',
1822
+ manager.id,
1823
+ 'decision',
1824
+ 'deciding',
1825
+ response.content,
1826
+ response.tokensUsed,
1827
+ response.latencyMs,
1828
+ [{ artifactType: 'decision', artifactId: decision.id }],
1829
+ undefined,
1830
+ response.structured
1831
+ );
1832
+
1833
+ // Continue with normal flow
1834
+ this.transitionPhase(council.id, 'directing');
1835
+
1836
+ return entry;
1837
+ }
1838
+
1839
+ /**
1840
+ * Abort deliberation
1841
+ */
1842
+ async abort(council: Council): Promise<void> {
1843
+ const manager = this.getManager(council);
1844
+
1845
+ this.createEntry(
1846
+ council.id,
1847
+ 'manager',
1848
+ manager.id,
1849
+ 'cancellation',
1850
+ council.deliberationState?.currentPhase || 'created',
1851
+ 'Deliberation aborted by user',
1852
+ 0, 0
1853
+ );
1854
+
1855
+ this.transitionPhase(council.id, 'cancelled');
1856
+ councilStore.setStatus(council.id, 'resolved');
1857
+ }
1858
+
1859
+ // ==========================================================================
1860
+ // Context Builders
1861
+ // ==========================================================================
1862
+
1863
+ /**
1864
+ * Build context for Round 1 (independent analysis) - Section 9.2
1865
+ * ONLY context artifact, no other consultant output
1866
+ */
1867
+ buildRound1Context(council: Council): string {
1868
+ const context = getCurrentContext(council.id);
1869
+ if (!context) {
1870
+ throw new Error('No context found');
1871
+ }
1872
+ return `SHARED CONTEXT (v${context.version}):\n${context.content}`;
1873
+ }
1874
+
1875
+ /**
1876
+ * Build context for Round 2+ (interactive deliberation) - Section 9.3
1877
+ * Context artifact + prior rounds (full or summarized) + manager notes
1878
+ * When includeCurrentRound is true (sequential mode), appends current round
1879
+ * entries so later consultants can see earlier consultants' contributions.
1880
+ */
1881
+ buildRoundNContext(council: Council, includeCurrentRound: boolean = false): string {
1882
+ const context = getCurrentContext(council.id);
1883
+ if (!context) {
1884
+ throw new Error('No context found');
1885
+ }
1886
+
1887
+ let result = `SHARED CONTEXT (v${context.version}):\n${context.content}\n\n---\n`;
1888
+
1889
+ const currentRound = council.deliberationState?.currentRound || 1;
1890
+ const useSummaries = this.shouldSummarize(council);
1891
+ const roundSummaries = council.deliberationState?.roundSummaries || {};
1892
+
1893
+ // Always use summaries for old rounds (round 2+), full for most recent
1894
+ // This saves 30-40% of input tokens per round
1895
+ if (currentRound > 1) {
1896
+ // OLD rounds: summaries only (generate on-the-fly if missing)
1897
+ for (let r = 1; r < currentRound - 1; r++) {
1898
+ const summary = roundSummaries[r];
1899
+ if (summary) {
1900
+ result += `\nROUND ${r} SUMMARY:\n${summary}\n`;
1901
+ } else {
1902
+ // Fallback: generate mechanical summary instead of sending full entries
1903
+ const roundEntries = getEntriesForRound(council.id, r);
1904
+ if (roundEntries.length > 0) {
1905
+ const fallbackSummary = roundEntries
1906
+ .map(e => `${e.authorName || e.authorRole}: ${e.content.slice(0, 200)}`)
1907
+ .join('\n');
1908
+ result += `\nROUND ${r} SUMMARY:\n${fallbackSummary}\n`;
1909
+ }
1910
+ }
1911
+ }
1912
+
1913
+ // MOST RECENT prior round: full entries (model needs detail for response)
1914
+ const recentEntries = getEntriesForRound(council.id, currentRound - 1);
1915
+ result += `\nROUND ${currentRound - 1}:\n`;
1916
+ result += formatEntriesForContext(recentEntries);
1917
+ }
1918
+
1919
+ // Manager notes: summarize if more than 3, keep last 2 full
1920
+ const managerNotes = getManagerNotes(council.id, currentRound);
1921
+ if (managerNotes.length > 3) {
1922
+ const oldNotes = managerNotes.slice(0, -2);
1923
+ const recentNotes = managerNotes.slice(-2);
1924
+ result += `\n---\nMANAGER NOTES (earlier, summarized):\n`;
1925
+ result += oldNotes.map(n => `- ${n.content.slice(0, 150)}`).join('\n');
1926
+ result += `\n\nMANAGER NOTES (recent):\n`;
1927
+ result += formatEntriesForContext(recentNotes);
1928
+ } else if (managerNotes.length > 0) {
1929
+ result += `\n---\nMANAGER NOTES:\n`;
1930
+ result += formatEntriesForContext(managerNotes);
1931
+ }
1932
+
1933
+ // In sequential mode, include current round entries so later consultants
1934
+ // can see what earlier consultants said in this round
1935
+ if (includeCurrentRound) {
1936
+ const currentRoundEntries = getEntriesForRound(council.id, currentRound);
1937
+ // Only include consultant entries (not manager entries which are from previous evaluation)
1938
+ const consultantEntries = currentRoundEntries.filter(e => e.authorRole === 'consultant');
1939
+ if (consultantEntries.length > 0) {
1940
+ result += `\n---\nCURRENT ROUND (so far):\n`;
1941
+ result += formatEntriesForContext(consultantEntries);
1942
+ }
1943
+ }
1944
+
1945
+ return result;
1946
+ }
1947
+
1948
+ /**
1949
+ * Build context for Manager evaluation - Section 9.4
1950
+ * Uses summaries for older rounds to keep token usage manageable.
1951
+ * Manager sees: shared context + summarised old rounds + full current round.
1952
+ */
1953
+ buildManagerEvalContext(council: Council): string {
1954
+ const context = getCurrentContext(council.id);
1955
+ const currentRound = council.deliberationState?.currentRound || 1;
1956
+ const roundSummaries = council.deliberationState?.roundSummaries || {};
1957
+
1958
+ let result = `SHARED CONTEXT (v${context?.version || 1}):\n${context?.content || ''}\n\n---\n`;
1959
+
1960
+ // For rounds 1-2 send full history (not much to summarize yet)
1961
+ // For rounds 3+ use summaries for older rounds, full for recent 2 rounds
1962
+ if (currentRound <= 2) {
1963
+ for (let r = 1; r <= currentRound; r++) {
1964
+ const roundEntries = getEntriesForRound(council.id, r);
1965
+ if (roundEntries.length > 0) {
1966
+ result += `\nROUND ${r}:\n`;
1967
+ result += formatEntriesForContext(roundEntries);
1968
+ }
1969
+ }
1970
+ } else {
1971
+ // Old rounds: summaries (rounds 1 to currentRound-2)
1972
+ for (let r = 1; r <= currentRound - 2; r++) {
1973
+ const summary = roundSummaries[r];
1974
+ if (summary) {
1975
+ result += `\nROUND ${r} SUMMARY:\n${summary}\n`;
1976
+ } else {
1977
+ // No summary available yet — use full entries as fallback
1978
+ const roundEntries = getEntriesForRound(council.id, r);
1979
+ result += `\nROUND ${r}:\n`;
1980
+ result += formatEntriesForContext(roundEntries);
1981
+ }
1982
+ }
1983
+
1984
+ // Recent 2 rounds: full entries (manager needs detail for evaluation)
1985
+ for (let r = Math.max(1, currentRound - 1); r <= currentRound; r++) {
1986
+ const roundEntries = getEntriesForRound(council.id, r);
1987
+ if (roundEntries.length > 0) {
1988
+ result += `\nROUND ${r}:\n`;
1989
+ result += formatEntriesForContext(roundEntries);
1990
+ }
1991
+ }
1992
+ }
1993
+
1994
+ // Manager notes for current round only (old notes are captured in summaries)
1995
+ const managerNotes = getManagerNotes(council.id, currentRound);
1996
+ if (managerNotes.length > 0) {
1997
+ result += `\n---\nMANAGER NOTES:\n`;
1998
+ result += formatEntriesForContext(managerNotes);
1999
+ }
2000
+
2001
+ return result;
2002
+ }
2003
+
2004
+ /**
2005
+ * Build context for decision - Section 9.5
2006
+ * Same as manager eval context (uses summaries for older rounds).
2007
+ */
2008
+ buildDecisionContext(council: Council): string {
2009
+ return this.buildManagerEvalContext(council);
2010
+ }
2011
+
2012
+ /**
2013
+ * Build context for directive - Section 9.6
2014
+ */
2015
+ buildDirectiveContext(council: Council): string {
2016
+ const decision = getDecision(council.id);
2017
+ const plan = getPlan(council.id);
2018
+
2019
+ let result = `YOUR DECISION:\n${decision?.content || ''}\n`;
2020
+
2021
+ if (plan) {
2022
+ result += `\nPLAN:\n${plan.content}\n`;
2023
+ }
2024
+
2025
+ return result;
2026
+ }
2027
+
2028
+ /**
2029
+ * Build context for Worker - Section 9.7
2030
+ * ONLY directive, no deliberation history
2031
+ */
2032
+ buildWorkerContext(council: Council): string {
2033
+ const directive = getDirective(council.id);
2034
+ return `DIRECTIVE:\n${directive?.content || ''}`;
2035
+ }
2036
+
2037
+ /**
2038
+ * Build context for revision - Section 9.7.1
2039
+ */
2040
+ buildRevisionContext(council: Council): string {
2041
+ const directive = getDirective(council.id);
2042
+ const previousOutput = getLatestOutput(council.id);
2043
+ const revisionRequest = getLatestOfType(council.id, 'revision_request');
2044
+
2045
+ return `DIRECTIVE:\n${directive?.content || ''}\n\nYOUR PREVIOUS OUTPUT:\n${previousOutput?.content || ''}\n\nREVISION FEEDBACK:\n${revisionRequest?.content || ''}`;
2046
+ }
2047
+
2048
+ /**
2049
+ * Build context for review - Section 9.8
2050
+ */
2051
+ buildReviewContext(council: Council): string {
2052
+ const directive = getDirective(council.id);
2053
+ const output = getLatestOutput(council.id);
2054
+ const decision = getDecision(council.id);
2055
+
2056
+ let result = `WORK DIRECTIVE:\n${directive?.content || ''}\n`;
2057
+
2058
+ if (decision?.acceptanceCriteria) {
2059
+ result += `\nACCEPTANCE CRITERIA:\n${decision.acceptanceCriteria}\n`;
2060
+ }
2061
+
2062
+ result += `\nWORKER OUTPUT:\n${output?.content || ''}`;
2063
+
2064
+ return result;
2065
+ }
2066
+
2067
+ /**
2068
+ * Build context for forced decision - Section 9.9
2069
+ */
2070
+ buildForcedDecisionContext(council: Council): string {
2071
+ const context = getCurrentContext(council.id);
2072
+ const entries = getAllEntries(council.id);
2073
+
2074
+ let result = `SHARED CONTEXT (v${context?.version || 1}):\n${context?.content || ''}\n\n---\n`;
2075
+ result += `DELIBERATION SO FAR (incomplete):\n`;
2076
+ result += formatEntriesForContext(entries);
2077
+
2078
+ return result;
2079
+ }
2080
+
2081
+ // ==========================================================================
2082
+ // Helper Methods
2083
+ // ==========================================================================
2084
+
2085
+ /**
2086
+ * Determine if summarization should be used
2087
+ */
2088
+ shouldSummarize(council: Council): boolean {
2089
+ const config = council.deliberation;
2090
+ if (!config) return false;
2091
+
2092
+ if (config.summaryMode === 'none') return false;
2093
+
2094
+ const currentRound = council.deliberationState?.currentRound || 1;
2095
+ // Always summarize after round 1 completes
2096
+ if (currentRound <= 1) return false;
2097
+
2098
+ // Always summarize from round 2+ (was round 3+, changed for cost efficiency)
2099
+ return true;
2100
+
2101
+ return false;
2102
+ }
2103
+
2104
+ /**
2105
+ * Get the next valid phase based on current state and action
2106
+ */
2107
+ getNextPhase(
2108
+ council: Council,
2109
+ action?: 'continue' | 'decide' | 'redirect' | 'accept' | 'revise' | 're_deliberate'
2110
+ ): DeliberationPhase {
2111
+ const currentPhase = council.deliberationState?.currentPhase || 'created';
2112
+ const validNext = PHASE_TRANSITIONS[currentPhase].validNext;
2113
+
2114
+ if (validNext.length === 0) {
2115
+ return currentPhase; // Terminal state
2116
+ }
2117
+
2118
+ // Determine next based on action
2119
+ switch (currentPhase) {
2120
+ case 'round_waiting_for_manager':
2121
+ if (action === 'decide') return 'deciding';
2122
+ if (action === 'continue' || action === 'redirect') return 'round_interactive';
2123
+ return 'deciding';
2124
+
2125
+ case 'deciding':
2126
+ return council.deliberation?.requirePlan ? 'planning' : 'directing';
2127
+
2128
+ case 'reviewing':
2129
+ if (action === 'accept') return 'completed';
2130
+ if (action === 'revise') return 'revising';
2131
+ if (action === 're_deliberate') return 'round_interactive';
2132
+ return 'completed';
2133
+
2134
+ default:
2135
+ return validNext[0];
2136
+ }
2137
+ }
2138
+
2139
+ /**
2140
+ * Check if current phase is complete and can transition
2141
+ */
2142
+ isPhaseComplete(council: Council): boolean {
2143
+ const phase = council.deliberationState?.currentPhase;
2144
+
2145
+ switch (phase) {
2146
+ case 'round_independent':
2147
+ case 'round_interactive':
2148
+ return councilStore.isRoundComplete(council.id);
2149
+ default:
2150
+ return true;
2151
+ }
2152
+ }
2153
+
2154
+ // ==========================================================================
2155
+ // Private Helpers
2156
+ // ==========================================================================
2157
+
2158
+ private getManager(council: Council): Persona {
2159
+ const managers = getPersonaByRole(council, 'manager');
2160
+ if (managers.length === 0) {
2161
+ throw new Error('No manager assigned to council');
2162
+ }
2163
+ return managers[0];
2164
+ }
2165
+
2166
+ private getConsultants(council: Council): Persona[] {
2167
+ return getPersonaByRole(council, 'consultant');
2168
+ }
2169
+
2170
+ private getWorker(council: Council): Persona {
2171
+ const workers = getPersonaByRole(council, 'worker');
2172
+ if (workers.length === 0) {
2173
+ throw new Error('No worker assigned to council');
2174
+ }
2175
+ return workers[0];
2176
+ }
2177
+
2178
+ private getPersonaDisplayName(council: Council, personaId: string): string {
2179
+ const persona = council.personas.find((p) => p.id === personaId);
2180
+ return persona?.name || personaId;
2181
+ }
2182
+
2183
+ private validatePhase(council: Council, expected: DeliberationPhase): void {
2184
+ // If deliberationState is undefined, treat as 'created'
2185
+ const current = council.deliberationState?.currentPhase ?? 'created';
2186
+ if (current !== expected) {
2187
+ throw new Error(`Invalid phase: expected ${expected}, got ${current}`);
2188
+ }
2189
+ }
2190
+
2191
+ private transitionPhase(councilId: string, to: DeliberationPhase): void {
2192
+ const council = councilStore.get(councilId);
2193
+ const from = council?.deliberationState?.currentPhase || 'created';
2194
+
2195
+ // Validate transition
2196
+ const validNext = PHASE_TRANSITIONS[from].validNext;
2197
+ if (!validNext.includes(to) && from !== 'paused') {
2198
+ console.warn(`[Orchestrator] Invalid phase transition: ${from} -> ${to}`);
2199
+ }
2200
+
2201
+ councilStore.setDeliberationPhase(councilId, to, from);
2202
+ this.config.onPhaseChange?.(from, to);
2203
+
2204
+ console.log(`[Orchestrator] Phase transition: ${from} -> ${to}`);
2205
+ }
2206
+
2207
+ private async invokeAgentSafe(
2208
+ invocation: AgentInvocation,
2209
+ persona: Persona,
2210
+ context: string
2211
+ ): Promise<AgentResponse> {
2212
+ // Scope tool access by phase/role:
2213
+ // Built-in tools (WebSearch, WebFetch) are always included for any tool-enabled phase.
2214
+ // Workers (execution, revision, debug): full tools + search
2215
+ // Planning workers: limited tools (Read, Write, Glob — save plan doc, no code editing)
2216
+ // Consultants (independent_analysis, deliberation_response): read-only + search
2217
+ // Reviewers (code_review): read-only + search
2218
+ // Manager phases: no tools
2219
+ const BUILTIN_TOOLS = ['WebSearch', 'WebFetch'];
2220
+ const FULL_TOOLS = [...BUILTIN_TOOLS, 'Edit', 'Write', 'Read', 'Bash', 'Glob', 'Grep'];
2221
+ const PLAN_TOOLS = [...BUILTIN_TOOLS, 'Read', 'Write', 'Glob'];
2222
+ const READ_ONLY_TOOLS = [...BUILTIN_TOOLS, 'Read', 'Grep', 'Glob'];
2223
+ const MANAGER_TOOLS = [...BUILTIN_TOOLS, 'Read', 'Glob', 'Grep', 'Bash'];
2224
+
2225
+ const council = this.activeCouncilId ? councilStore.get(this.activeCouncilId) : null;
2226
+ const stepType = council?.deliberation?.stepType;
2227
+
2228
+ const workerToolPhases = ['execution', 'revision', 'debug'];
2229
+ const readOnlyToolPhases = ['independent_analysis', 'deliberation_response', 'code_review'];
2230
+ const managerToolPhases = ['problem_framing', 'directive', 'review'];
2231
+
2232
+ if (workerToolPhases.includes(context)) {
2233
+ // Planning workers get limited tools (no Edit, Bash, Grep — prevents code writing)
2234
+ // Coding/other workers get full tools
2235
+ if (stepType === 'code_planning') {
2236
+ invocation = { ...invocation, allowedTools: PLAN_TOOLS };
2237
+ } else {
2238
+ invocation = { ...invocation, allowedTools: FULL_TOOLS };
2239
+ }
2240
+ } else if (readOnlyToolPhases.includes(context)) {
2241
+ invocation = { ...invocation, allowedTools: READ_ONLY_TOOLS };
2242
+ } else if (managerToolPhases.includes(context)) {
2243
+ invocation = { ...invocation, allowedTools: MANAGER_TOOLS };
2244
+ } else if (!invocation.skipTools) {
2245
+ invocation = { ...invocation, skipTools: true };
2246
+ }
2247
+
2248
+ // Compute effective allowed servers (intersection of step + persona)
2249
+ const stepServers = council?.deliberation?.allowedServerIds;
2250
+ const personaServers = persona.allowedServerIds;
2251
+ let effectiveServers: string[] | undefined;
2252
+ if (stepServers && personaServers) {
2253
+ effectiveServers = stepServers.filter(id => personaServers.includes(id));
2254
+ } else {
2255
+ effectiveServers = personaServers || stepServers;
2256
+ }
2257
+ if (effectiveServers) {
2258
+ invocation = { ...invocation, allowedServerIds: effectiveServers };
2259
+ }
2260
+
2261
+ // Apply per-persona tool access overrides from role assignment
2262
+ const roleAssignment = council?.deliberation?.roleAssignments
2263
+ ?.find(r => r.personaId === persona.id);
2264
+
2265
+ console.log(`[Orchestrator:ToolAccess] persona=${persona.name} context=${context} roleAssignment.toolAccess=${roleAssignment?.toolAccess} roleAssignment.found=${!!roleAssignment} totalAssignments=${council?.deliberation?.roleAssignments?.length}`);
2266
+
2267
+ if (roleAssignment?.toolAccess === 'none') {
2268
+ invocation = { ...invocation, skipTools: true };
2269
+ } else if (roleAssignment?.toolAccess === 'full') {
2270
+ const { skipTools: _, ...rest } = invocation;
2271
+ invocation = rest as AgentInvocation;
2272
+ }
2273
+
2274
+ if (roleAssignment?.allowedServerIds) {
2275
+ invocation = { ...invocation, allowedServerIds: roleAssignment.allowedServerIds };
2276
+ }
2277
+
2278
+ // Inject bootstrapped context into every prompt — cacheable if prompt caching is enabled.
2279
+ if (this.bootstrappedContext) {
2280
+ const hasContext = invocation.userMessage.includes(this.bootstrappedContext.slice(0, 100));
2281
+ if (!hasContext) {
2282
+ // Instead of injecting into userMessage, pass as separate cacheableContext
2283
+ // so Anthropic's prompt caching can deduplicate it across calls
2284
+ invocation = {
2285
+ ...invocation,
2286
+ cacheableContext: `## PROJECT CONTEXT\n\nThe complete source code and project structure are provided below. This is your primary source of information. Analyze the code directly from what is provided here.\n\n${this.bootstrappedContext}`,
2287
+ userMessage: invocation.userMessage, // Keep userMessage clean
2288
+ };
2289
+ }
2290
+ }
2291
+
2292
+ // Inject brevity instruction if maxWordsPerResponse is configured
2293
+ const wordLimit = this.activeCouncilId
2294
+ ? councilStore.get(this.activeCouncilId)?.deliberation?.maxWordsPerResponse
2295
+ : undefined;
2296
+ if (wordLimit && wordLimit > 0) {
2297
+ invocation = {
2298
+ ...invocation,
2299
+ userMessage: invocation.userMessage +
2300
+ `\n\nIMPORTANT: Keep your response concise — aim for approximately ${wordLimit} words or fewer. Be direct and avoid unnecessary elaboration.`,
2301
+ };
2302
+ }
2303
+
2304
+ // Thread working directory from council so each invocation uses its own dir
2305
+ const councilDir = council?.deliberation?.workingDirectory;
2306
+ if (councilDir) {
2307
+ invocation = { ...invocation, workingDirectory: councilDir };
2308
+ }
2309
+
2310
+ // Apply role-based default timeouts if not already set
2311
+ const DEFAULT_TIMEOUTS: Record<string, number> = {
2312
+ // Worker contexts — 30 min
2313
+ execution: 1_800_000,
2314
+ revision: 1_800_000,
2315
+ debug: 1_800_000,
2316
+ direct_execution: 1_800_000,
2317
+ // Manager contexts — 10 min
2318
+ problem_framing: 600_000,
2319
+ evaluation: 600_000,
2320
+ manager_evaluation: 600_000,
2321
+ decision: 600_000,
2322
+ forced_decision: 600_000,
2323
+ directive: 600_000,
2324
+ plan: 600_000,
2325
+ review: 600_000,
2326
+ round_summary: 600_000,
2327
+ // Consultant contexts — 10 min
2328
+ independent_analysis: 600_000,
2329
+ deliberation_response: 600_000,
2330
+ final_position: 600_000,
2331
+ consultant_review: 600_000,
2332
+ };
2333
+ invocation = { ...invocation, timeoutMs: invocation.timeoutMs ?? DEFAULT_TIMEOUTS[context] ?? 600_000 };
2334
+
2335
+ // Build context inspection for this call
2336
+ const toolScope: ContextInspection['toolScope'] = invocation.skipTools ? 'none'
2337
+ : invocation.allowedTools === FULL_TOOLS ? 'full'
2338
+ : invocation.allowedTools === PLAN_TOOLS ? 'plan'
2339
+ : invocation.allowedTools === READ_ONLY_TOOLS ? 'read_only'
2340
+ : invocation.allowedTools === MANAGER_TOOLS ? 'manager'
2341
+ : invocation.allowedTools ? 'read_only' // custom list after override
2342
+ : 'none';
2343
+
2344
+ const systemPromptSource = workerToolPhases.includes(context) ? 'minimal_worker'
2345
+ : readOnlyToolPhases.includes(context) && context === 'code_review' ? 'reviewer'
2346
+ : readOnlyToolPhases.includes(context) ? 'persona_predisposition'
2347
+ : 'persona_predisposition';
2348
+
2349
+ const inspection = buildContextInspection({
2350
+ systemPrompt: invocation.systemPrompt,
2351
+ systemPromptSource,
2352
+ userMessage: invocation.userMessage,
2353
+ toolScope,
2354
+ toolNames: invocation.allowedTools,
2355
+ effectiveServerIds: invocation.allowedServerIds,
2356
+ wordLimitApplied: wordLimit && wordLimit > 0 ? wordLimit : undefined,
2357
+ contextTokenBudget: council?.deliberation?.contextTokenBudget,
2358
+ timeoutMs: invocation.timeoutMs,
2359
+ });
2360
+
2361
+ // Retry transient errors (rate limits, overload) with exponential backoff.
2362
+ // This covers ALL roles (manager, consultant, worker) uniformly.
2363
+ // Timeout errors get 1 retry (no backoff — the timeout itself was the wait).
2364
+ const MAX_TRANSIENT_RETRIES = 5;
2365
+ const BASE_DELAY_MS = 15_000; // 15 seconds
2366
+
2367
+ const sysPromptKB = (invocation.systemPrompt.length / 1024).toFixed(1);
2368
+ const userMsgKB = (invocation.userMessage.length / 1024).toFixed(1);
2369
+ const toolCount = invocation.allowedTools?.length || 0;
2370
+ console.log(
2371
+ `[Orchestrator:Call] ${persona.name} (${context}) — ` +
2372
+ `system: ${sysPromptKB}KB, user: ${userMsgKB}KB, tools: ${toolCount}, ` +
2373
+ `timeout: ${Math.round((invocation.timeoutMs || 0) / 1000)}s`
2374
+ );
2375
+
2376
+ for (let attempt = 0; attempt <= MAX_TRANSIENT_RETRIES; attempt++) {
2377
+ try {
2378
+ const startedAt = Date.now();
2379
+ this.config.onAgentThinkingStart?.(persona, startedAt, invocation.userMessage);
2380
+ const response = await this.config.invokeAgent(invocation, persona);
2381
+ this.config.onAgentThinkingEnd?.(persona);
2382
+ const callDuration = ((Date.now() - startedAt) / 1000).toFixed(1);
2383
+ console.log(`[Orchestrator:Call] ${persona.name} (${context}) completed in ${callDuration}s — ${response.tokensUsed} tokens`);
2384
+ // Attach context inspection to the response
2385
+ response.structured = { ...response.structured, contextInspection: inspection };
2386
+ return response;
2387
+ } catch (error) {
2388
+ this.config.onAgentThinkingEnd?.(persona);
2389
+
2390
+ const errMsg = error instanceof Error ? error.message : String(error);
2391
+ const isTransient = /\b(429|529|rate.limit|overloaded|too many requests)\b/i.test(errMsg);
2392
+ const isTimeout = /\btimed?\s*out\b/i.test(errMsg);
2393
+
2394
+ // Timeout: retry once, then fail
2395
+ if (isTimeout && attempt === 0) {
2396
+ const elapsedMs = invocation.timeoutMs || 0;
2397
+ console.warn(`[Orchestrator] ${persona.name} (${context}) timed out after ${Math.round(elapsedMs / 1000)}s, retrying once...`);
2398
+ this.config.onAgentTimeout?.(persona, context, elapsedMs);
2399
+ this.config.onError?.(new Error(`${persona.name} timed out — retrying`), context);
2400
+ continue; // retry once
2401
+ }
2402
+
2403
+ if (isTransient && attempt < MAX_TRANSIENT_RETRIES) {
2404
+ const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
2405
+ console.warn(
2406
+ `[Orchestrator] Transient error for ${persona.name} (${context}), ` +
2407
+ `retrying in ${Math.round(delayMs / 1000)}s (attempt ${attempt + 1}/${MAX_TRANSIENT_RETRIES}): ${errMsg}`
2408
+ );
2409
+ await new Promise(resolve => setTimeout(resolve, delayMs));
2410
+ continue;
2411
+ }
2412
+
2413
+ this.config.onError?.(error as Error, context);
2414
+ throw error;
2415
+ }
2416
+ }
2417
+
2418
+ // Should not reach here
2419
+ throw new Error('Unexpected: exhausted transient retry loop');
2420
+ }
2421
+
2422
+ /**
2423
+ * Clean JSON artifacts from LLM responses for display.
2424
+ * LLMs sometimes respond with JSON when prompted for structured output.
2425
+ * This extracts readable text from those responses.
2426
+ */
2427
+ cleanLLMResponse(content: string): string {
2428
+ if (!content || typeof content !== 'string') return content;
2429
+
2430
+ const trimmed = content.trim();
2431
+
2432
+ // Try to extract JSON from various formats
2433
+ const jsonStr = this.extractJsonString(trimmed);
2434
+ if (jsonStr) {
2435
+ try {
2436
+ const parsed = JSON.parse(jsonStr);
2437
+ const formatted = this.formatParsedJson(parsed);
2438
+ if (formatted) return formatted;
2439
+ } catch {
2440
+ // Not valid JSON, fall through
2441
+ }
2442
+ }
2443
+
2444
+ // Check if content is text followed by a raw JSON object at the end
2445
+ const trailingJsonMatch = trimmed.match(/^([\s\S]+?)\n\s*(\{[\s\S]*\})\s*$/);
2446
+ if (trailingJsonMatch) {
2447
+ const textPart = trailingJsonMatch[1].trim();
2448
+ try {
2449
+ JSON.parse(trailingJsonMatch[2]);
2450
+ if (textPart.length > 20) return textPart;
2451
+ } catch {
2452
+ // Not JSON, return as-is
2453
+ }
2454
+ }
2455
+
2456
+ return content;
2457
+ }
2458
+
2459
+ /**
2460
+ * Extract a JSON string from content that may be raw JSON, markdown-wrapped, etc.
2461
+ */
2462
+ private extractJsonString(trimmed: string): string | null {
2463
+ // Raw JSON object
2464
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
2465
+ return trimmed;
2466
+ }
2467
+
2468
+ // Markdown code block: ```json\n{...}\n```
2469
+ const codeBlockMatch = trimmed.match(/```(?:json)?\s*\n?(\{[\s\S]*?\})\s*\n?```/);
2470
+ if (codeBlockMatch) {
2471
+ return codeBlockMatch[1];
2472
+ }
2473
+
2474
+ return null;
2475
+ }
2476
+
2477
+ /**
2478
+ * Format a parsed JSON object into readable text.
2479
+ * Handles evaluation/review/decision structured responses.
2480
+ */
2481
+ private formatParsedJson(parsed: Record<string, unknown>): string | null {
2482
+ // Check for structured responses FIRST (action/verdict indicate eval/review)
2483
+ // These have multiple important fields that should all be shown
2484
+ if (parsed.action || parsed.verdict) {
2485
+ const parts: string[] = [];
2486
+ if (parsed.verdict && typeof parsed.verdict === 'string')
2487
+ parts.push(`Verdict: ${parsed.verdict}`);
2488
+ if (parsed.action && typeof parsed.action === 'string')
2489
+ parts.push(`Action: ${parsed.action}`);
2490
+ if (parsed.confidence != null)
2491
+ parts.push(`Confidence: ${Math.round((parsed.confidence as number) * 100)}%`);
2492
+ if (parsed.reasoning && typeof parsed.reasoning === 'string')
2493
+ parts.push(parsed.reasoning);
2494
+ if (parsed.feedback && typeof parsed.feedback === 'string')
2495
+ parts.push(`Feedback: ${parsed.feedback}`);
2496
+ if (parsed.question && typeof parsed.question === 'string')
2497
+ parts.push(`Question: ${parsed.question}`);
2498
+ if (parsed.newInformation && typeof parsed.newInformation === 'string')
2499
+ parts.push(`New information: ${parsed.newInformation}`);
2500
+ if (Array.isArray(parsed.missingInformation) && parsed.missingInformation.length)
2501
+ parts.push(`Missing: ${parsed.missingInformation.join(', ')}`);
2502
+ if (parts.length > 0) return parts.join('\n\n');
2503
+ }
2504
+
2505
+ // For plain content responses, extract the main text field
2506
+ const textFields = ['reasoning', 'content', 'message', 'summary', 'analysis', 'feedback', 'explanation'];
2507
+ for (const field of textFields) {
2508
+ if (parsed[field] && typeof parsed[field] === 'string') {
2509
+ return parsed[field] as string;
2510
+ }
2511
+ }
2512
+
2513
+ return null;
2514
+ }
2515
+
2516
+ private createEntry(
2517
+ councilId: string,
2518
+ role: DeliberationRole,
2519
+ authorId: string,
2520
+ entryType: LedgerEntryType,
2521
+ phase: DeliberationPhase,
2522
+ content: string,
2523
+ tokensUsed: number,
2524
+ latencyMs: number,
2525
+ artifactRefs?: ArtifactRef[],
2526
+ roundNumber?: number,
2527
+ structured?: Record<string, unknown>,
2528
+ referencedEntries?: string[],
2529
+ reviewOutcome?: 'accept' | 'revise' | 're_deliberate'
2530
+ ): LedgerEntry {
2531
+ // Clean JSON artifacts from LLM responses before storing
2532
+ const cleanedContent = this.cleanLLMResponse(content);
2533
+
2534
+ const entry: LedgerEntry = {
2535
+ id: crypto.randomUUID(),
2536
+ timestamp: new Date().toISOString(),
2537
+ authorRole: role,
2538
+ authorPersonaId: authorId,
2539
+ entryType,
2540
+ phase,
2541
+ content: cleanedContent,
2542
+ tokensUsed,
2543
+ latencyMs,
2544
+ artifactRefs,
2545
+ roundNumber,
2546
+ referencedEntries,
2547
+ reviewOutcome,
2548
+ structured,
2549
+ };
2550
+
2551
+ // Use ledgerStore.append to notify subscribers (enables real-time UI updates)
2552
+ ledgerStore.append(councilId, entry);
2553
+ this.config.onEntryAdded?.(entry);
2554
+
2555
+ return entry;
2556
+ }
2557
+
2558
+ private extractContextProposal(content: string): { diff: string; rationale: string } | null {
2559
+ const proposalMatch = content.match(/PROPOSED CONTEXT CHANGE:\s*\n*What:\s*(.+?)(?:\n+Why:\s*(.+))?$/is);
2560
+
2561
+ if (proposalMatch) {
2562
+ return {
2563
+ diff: proposalMatch[1].trim(),
2564
+ rationale: proposalMatch[2]?.trim() || 'No rationale provided',
2565
+ };
2566
+ }
2567
+ return null;
2568
+ }
2569
+
2570
+ /**
2571
+ * Append a summary to the shared context document when evolveContext is enabled.
2572
+ * Creates a new context version with the appended information.
2573
+ */
2574
+ private appendToContext(
2575
+ councilId: string,
2576
+ label: string,
2577
+ personaName: string,
2578
+ content: string,
2579
+ role: 'consultant' | 'worker' | 'manager',
2580
+ personaId?: string,
2581
+ ): void {
2582
+ const council = this.activeCouncilId ? councilStore.get(this.activeCouncilId) : null;
2583
+ if (!council?.deliberation?.evolveContext) return;
2584
+
2585
+ const currentContext = getCurrentContext(councilId);
2586
+ if (!currentContext) return;
2587
+
2588
+ // Extract a concise summary — first 2000 chars or up to COMPLETION SUMMARY
2589
+ const summaryMatch = content.match(/## COMPLETION SUMMARY[\s\S]*/i);
2590
+ const summary = summaryMatch
2591
+ ? summaryMatch[0].slice(0, 1500)
2592
+ : content.slice(0, 2000);
2593
+
2594
+ const round = council.deliberationState?.currentRound || 1;
2595
+ const appendText = `\n\n---\n[Round ${round} — ${label} by ${personaName}]:\n${summary}`;
2596
+
2597
+ const updated = createContextVersion(
2598
+ councilId,
2599
+ currentContext.content + appendText,
2600
+ `${label} by ${personaName} (round ${round})`,
2601
+ role,
2602
+ personaId,
2603
+ round,
2604
+ );
2605
+
2606
+ councilStore.setActiveContext(councilId, updated.id, updated.version);
2607
+ console.log(`[Orchestrator] Context evolved to v${updated.version}: ${label} by ${personaName}`);
2608
+ }
2609
+
2610
+ private extractAcceptanceCriteria(content: string): string | undefined {
2611
+ const match = content.match(/ACCEPTANCE CRITERIA[:\s]*\n*([\s\S]*?)(?=\n\n|$)/i);
2612
+ return match?.[1]?.trim();
2613
+ }
2614
+
2615
+ private parseManagerEvaluation(content: string): ManagerEvaluation {
2616
+ try {
2617
+ // Try to extract JSON from the response
2618
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
2619
+ if (jsonMatch) {
2620
+ const parsed = JSON.parse(jsonMatch[0]);
2621
+ // Filter patchDecisions to only include entries with valid UUID patchIds
2622
+ // (the LLM may hallucinate non-UUID strings)
2623
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2624
+ const patchDecisions = Array.isArray(parsed.patchDecisions)
2625
+ ? parsed.patchDecisions.filter(
2626
+ (d: any) => d && typeof d.patchId === 'string' && UUID_RE.test(d.patchId)
2627
+ )
2628
+ : undefined;
2629
+
2630
+ return {
2631
+ action: parsed.action || 'decide',
2632
+ reasoning: parsed.reasoning || content,
2633
+ confidence: parsed.confidence ?? undefined,
2634
+ missingInformation: parsed.missingInformation ?? undefined,
2635
+ question: parsed.question ?? undefined,
2636
+ patchDecisions: patchDecisions && patchDecisions.length > 0 ? patchDecisions : undefined,
2637
+ };
2638
+ }
2639
+ } catch {
2640
+ // Fall back to text parsing
2641
+ }
2642
+
2643
+ // Default interpretation from text
2644
+ const lowerContent = content.toLowerCase();
2645
+ let action: 'continue' | 'decide' | 'redirect' = 'decide';
2646
+
2647
+ if (lowerContent.includes('continue') || lowerContent.includes('another round')) {
2648
+ action = 'continue';
2649
+ } else if (lowerContent.includes('redirect') || lowerContent.includes('refocus')) {
2650
+ action = 'redirect';
2651
+ }
2652
+
2653
+ return {
2654
+ action,
2655
+ reasoning: content,
2656
+ };
2657
+ }
2658
+
2659
+ private parseManagerReview(content: string): ManagerReview {
2660
+ try {
2661
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
2662
+ if (jsonMatch) {
2663
+ const parsed = JSON.parse(jsonMatch[0]);
2664
+ return {
2665
+ verdict: parsed.verdict || 'accept',
2666
+ reasoning: parsed.reasoning || content,
2667
+ feedback: parsed.feedback ?? undefined,
2668
+ newInformation: parsed.newInformation ?? undefined,
2669
+ };
2670
+ }
2671
+ } catch {
2672
+ // Fall back to text parsing
2673
+ }
2674
+
2675
+ // Default interpretation
2676
+ const lowerContent = content.toLowerCase();
2677
+ let verdict: 'accept' | 'revise' | 're_deliberate' = 'accept';
2678
+
2679
+ if (lowerContent.includes('revise') || lowerContent.includes('revision')) {
2680
+ verdict = 'revise';
2681
+ } else if (lowerContent.includes('re-deliberate') || lowerContent.includes('redeliberate')) {
2682
+ verdict = 're_deliberate';
2683
+ }
2684
+
2685
+ return {
2686
+ verdict,
2687
+ reasoning: content,
2688
+ };
2689
+ }
2690
+
2691
+ /**
2692
+ * Run a single consultant's contribution with retry logic.
2693
+ * Ensures every consultant gets a fair chance to contribute:
2694
+ * - 'retry' policy: retries up to maxRetries times with backoff
2695
+ * - 'skip' policy: records error and moves on
2696
+ * - 'fail' policy: fails the entire deliberation
2697
+ *
2698
+ * On final failure, records the submission anyway so the round can complete.
2699
+ */
2700
+ private async runConsultantWithRetry(
2701
+ council: Council,
2702
+ consultant: Persona,
2703
+ runFn: () => Promise<LedgerEntry>
2704
+ ): Promise<LedgerEntry | null> {
2705
+ const maxRetries = council.deliberation?.maxRetries || 2;
2706
+ const policy = council.deliberation?.consultantErrorPolicy || 'retry';
2707
+
2708
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2709
+ try {
2710
+ return await runFn();
2711
+ } catch (error) {
2712
+ const isLastAttempt = attempt >= maxRetries || policy !== 'retry';
2713
+
2714
+ console.error(
2715
+ `[Orchestrator] Consultant ${consultant.name} failed (attempt ${attempt + 1}/${maxRetries + 1}):`,
2716
+ (error as Error).message
2717
+ );
2718
+
2719
+ councilStore.addError(
2720
+ council.id,
2721
+ `Consultant ${consultant.name}: ${(error as Error).message} (attempt ${attempt + 1})`
2722
+ );
2723
+
2724
+ if (policy === 'fail') {
2725
+ this.transitionPhase(council.id, 'failed');
2726
+ throw error;
2727
+ }
2728
+
2729
+ if (isLastAttempt) {
2730
+ // Record submission for failed consultant so the round can still complete
2731
+ councilStore.recordSubmission(council.id, consultant.id);
2732
+
2733
+ // Create an error entry in the ledger so it's visible in the UI
2734
+ this.createEntry(
2735
+ council.id,
2736
+ 'consultant',
2737
+ consultant.id,
2738
+ 'error',
2739
+ council.deliberationState?.currentPhase || 'round_independent',
2740
+ `Failed to contribute after ${attempt + 1} attempt(s): ${(error as Error).message}`,
2741
+ 0,
2742
+ 0,
2743
+ undefined,
2744
+ council.deliberationState?.currentRound
2745
+ );
2746
+
2747
+ return null;
2748
+ }
2749
+
2750
+ // Delay before retry — longer backoff for rate limit errors
2751
+ const errMsg = (error as Error).message || '';
2752
+ const isRateLimit = /\b(429|529|rate.limit|overloaded|too many requests)\b/i.test(errMsg);
2753
+ const delayMs = isRateLimit
2754
+ ? 15_000 * Math.pow(2, attempt) // 15s, 30s, 60s for rate limits
2755
+ : 2_000 * (attempt + 1); // 2s, 4s, 6s for other errors
2756
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
2757
+ }
2758
+ }
2759
+
2760
+ return null;
2761
+ }
2762
+ }