zora-agent 0.10.0 → 0.10.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 (166) hide show
  1. package/README.md +171 -65
  2. package/dist/cli/daemon.js +1 -0
  3. package/dist/cli/daemon.js.map +1 -1
  4. package/dist/cli/index.js +27 -0
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/secret-commands.d.ts +16 -0
  7. package/dist/cli/secret-commands.d.ts.map +1 -0
  8. package/dist/cli/secret-commands.js +87 -0
  9. package/dist/cli/secret-commands.js.map +1 -0
  10. package/dist/cli/skill-commands.d.ts.map +1 -1
  11. package/dist/cli/skill-commands.js +75 -0
  12. package/dist/cli/skill-commands.js.map +1 -1
  13. package/dist/cli/subagent-commands.d.ts +13 -0
  14. package/dist/cli/subagent-commands.d.ts.map +1 -0
  15. package/dist/cli/subagent-commands.js +80 -0
  16. package/dist/cli/subagent-commands.js.map +1 -0
  17. package/dist/config/defaults.d.ts +6 -0
  18. package/dist/config/defaults.d.ts.map +1 -1
  19. package/dist/config/defaults.js +6 -0
  20. package/dist/config/defaults.js.map +1 -1
  21. package/dist/dashboard/cost-tracker.d.ts +40 -0
  22. package/dist/dashboard/cost-tracker.d.ts.map +1 -0
  23. package/dist/dashboard/cost-tracker.js +63 -0
  24. package/dist/dashboard/cost-tracker.js.map +1 -0
  25. package/dist/dashboard/frontend/dist/assets/{index-DSXaCp9r.js → index-Bi0V-1ti.js} +83 -83
  26. package/dist/dashboard/frontend/dist/assets/index-SQqtXVeO.css +1 -0
  27. package/dist/dashboard/frontend/dist/index.html +2 -2
  28. package/dist/dashboard/server.d.ts +5 -0
  29. package/dist/dashboard/server.d.ts.map +1 -1
  30. package/dist/dashboard/server.js +17 -0
  31. package/dist/dashboard/server.js.map +1 -1
  32. package/dist/hooks/built-in/audit-log.d.ts +13 -0
  33. package/dist/hooks/built-in/audit-log.d.ts.map +1 -0
  34. package/dist/hooks/built-in/audit-log.js +48 -0
  35. package/dist/hooks/built-in/audit-log.js.map +1 -0
  36. package/dist/hooks/built-in/rate-limit.d.ts +18 -0
  37. package/dist/hooks/built-in/rate-limit.d.ts.map +1 -0
  38. package/dist/hooks/built-in/rate-limit.js +30 -0
  39. package/dist/hooks/built-in/rate-limit.js.map +1 -0
  40. package/dist/hooks/built-in/secret-redact.d.ts +7 -0
  41. package/dist/hooks/built-in/secret-redact.d.ts.map +1 -0
  42. package/dist/hooks/built-in/secret-redact.js +33 -0
  43. package/dist/hooks/built-in/secret-redact.js.map +1 -0
  44. package/dist/hooks/built-in/shell-safety.d.ts +7 -0
  45. package/dist/hooks/built-in/shell-safety.d.ts.map +1 -0
  46. package/dist/hooks/built-in/shell-safety.js +34 -0
  47. package/dist/hooks/built-in/shell-safety.js.map +1 -0
  48. package/dist/hooks/index.d.ts +5 -0
  49. package/dist/hooks/index.d.ts.map +1 -1
  50. package/dist/hooks/index.js +5 -0
  51. package/dist/hooks/index.js.map +1 -1
  52. package/dist/hooks/tool-hook-runner.d.ts +36 -0
  53. package/dist/hooks/tool-hook-runner.d.ts.map +1 -0
  54. package/dist/hooks/tool-hook-runner.js +49 -0
  55. package/dist/hooks/tool-hook-runner.js.map +1 -0
  56. package/dist/lib/error-normalizer.d.ts +53 -0
  57. package/dist/lib/error-normalizer.d.ts.map +1 -0
  58. package/dist/lib/error-normalizer.js +128 -0
  59. package/dist/lib/error-normalizer.js.map +1 -0
  60. package/dist/memory/context-compressor.d.ts +3 -1
  61. package/dist/memory/context-compressor.d.ts.map +1 -1
  62. package/dist/memory/context-compressor.js +18 -3
  63. package/dist/memory/context-compressor.js.map +1 -1
  64. package/dist/memory/plan-cache.d.ts +27 -0
  65. package/dist/memory/plan-cache.d.ts.map +1 -0
  66. package/dist/memory/plan-cache.js +91 -0
  67. package/dist/memory/plan-cache.js.map +1 -0
  68. package/dist/orchestrator/code-tool-runner.d.ts +41 -0
  69. package/dist/orchestrator/code-tool-runner.d.ts.map +1 -0
  70. package/dist/orchestrator/code-tool-runner.js +375 -0
  71. package/dist/orchestrator/code-tool-runner.js.map +1 -0
  72. package/dist/orchestrator/error-pattern-detector.d.ts +54 -0
  73. package/dist/orchestrator/error-pattern-detector.d.ts.map +1 -0
  74. package/dist/orchestrator/error-pattern-detector.js +87 -0
  75. package/dist/orchestrator/error-pattern-detector.js.map +1 -0
  76. package/dist/orchestrator/execution-planner.d.ts +33 -0
  77. package/dist/orchestrator/execution-planner.d.ts.map +1 -0
  78. package/dist/orchestrator/execution-planner.js +67 -0
  79. package/dist/orchestrator/execution-planner.js.map +1 -0
  80. package/dist/orchestrator/orchestrator.d.ts +77 -0
  81. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  82. package/dist/orchestrator/orchestrator.js +595 -29
  83. package/dist/orchestrator/orchestrator.js.map +1 -1
  84. package/dist/orchestrator/retry-queue.d.ts +7 -1
  85. package/dist/orchestrator/retry-queue.d.ts.map +1 -1
  86. package/dist/orchestrator/retry-queue.js +10 -4
  87. package/dist/orchestrator/retry-queue.js.map +1 -1
  88. package/dist/orchestrator/step-classifier.d.ts +26 -0
  89. package/dist/orchestrator/step-classifier.d.ts.map +1 -0
  90. package/dist/orchestrator/step-classifier.js +77 -0
  91. package/dist/orchestrator/step-classifier.js.map +1 -0
  92. package/dist/orchestrator/tlci-dispatcher.d.ts +47 -0
  93. package/dist/orchestrator/tlci-dispatcher.d.ts.map +1 -0
  94. package/dist/orchestrator/tlci-dispatcher.js +116 -0
  95. package/dist/orchestrator/tlci-dispatcher.js.map +1 -0
  96. package/dist/routines/heartbeat.d.ts.map +1 -1
  97. package/dist/routines/heartbeat.js +3 -1
  98. package/dist/routines/heartbeat.js.map +1 -1
  99. package/dist/routines/routine-manager.d.ts.map +1 -1
  100. package/dist/routines/routine-manager.js +3 -1
  101. package/dist/routines/routine-manager.js.map +1 -1
  102. package/dist/security/intent-capsule.d.ts +19 -0
  103. package/dist/security/intent-capsule.d.ts.map +1 -1
  104. package/dist/security/intent-capsule.js +84 -1
  105. package/dist/security/intent-capsule.js.map +1 -1
  106. package/dist/security/policy-engine.d.ts +1 -0
  107. package/dist/security/policy-engine.d.ts.map +1 -1
  108. package/dist/security/policy-engine.js +22 -1
  109. package/dist/security/policy-engine.js.map +1 -1
  110. package/dist/services/negative-cache.d.ts +67 -0
  111. package/dist/services/negative-cache.d.ts.map +1 -0
  112. package/dist/services/negative-cache.js +164 -0
  113. package/dist/services/negative-cache.js.map +1 -0
  114. package/dist/skills/skill-auditor.d.ts +36 -0
  115. package/dist/skills/skill-auditor.d.ts.map +1 -0
  116. package/dist/skills/skill-auditor.js +89 -0
  117. package/dist/skills/skill-auditor.js.map +1 -0
  118. package/dist/skills/skill-installer.d.ts +41 -0
  119. package/dist/skills/skill-installer.d.ts.map +1 -0
  120. package/dist/skills/skill-installer.js +141 -0
  121. package/dist/skills/skill-installer.js.map +1 -0
  122. package/dist/skills/skill-scanner.d.ts +30 -0
  123. package/dist/skills/skill-scanner.d.ts.map +1 -0
  124. package/dist/skills/skill-scanner.js +249 -0
  125. package/dist/skills/skill-scanner.js.map +1 -0
  126. package/dist/tools/index.d.ts +2 -0
  127. package/dist/tools/index.d.ts.map +1 -1
  128. package/dist/tools/index.js +2 -0
  129. package/dist/tools/index.js.map +1 -1
  130. package/dist/tools/planning-tool.d.ts +95 -0
  131. package/dist/tools/planning-tool.d.ts.map +1 -0
  132. package/dist/tools/planning-tool.js +117 -0
  133. package/dist/tools/planning-tool.js.map +1 -0
  134. package/dist/tools/skill-tool.d.ts +94 -0
  135. package/dist/tools/skill-tool.d.ts.map +1 -0
  136. package/dist/tools/skill-tool.js +202 -0
  137. package/dist/tools/skill-tool.js.map +1 -0
  138. package/dist/tools/subagent-tool.d.ts +11 -0
  139. package/dist/tools/subagent-tool.d.ts.map +1 -0
  140. package/dist/tools/subagent-tool.js +87 -0
  141. package/dist/tools/subagent-tool.js.map +1 -0
  142. package/dist/types.d.ts +22 -0
  143. package/dist/types.d.ts.map +1 -1
  144. package/dist/utils/args.d.ts +5 -0
  145. package/dist/utils/args.d.ts.map +1 -0
  146. package/dist/utils/args.js +20 -0
  147. package/dist/utils/args.js.map +1 -0
  148. package/package.json +4 -1
  149. package/dist/dashboard/frontend/dist/assets/index-2FaDr6iS.js +0 -277
  150. package/dist/dashboard/frontend/dist/assets/index-B9-KXW14.css +0 -1
  151. package/dist/dashboard/frontend/dist/assets/index-BMxbyTer.css +0 -1
  152. package/dist/dashboard/frontend/dist/assets/index-BXUfB9iR.js +0 -253
  153. package/dist/dashboard/frontend/dist/assets/index-BcOGj1EF.css +0 -1
  154. package/dist/dashboard/frontend/dist/assets/index-BtiFO9YN.js +0 -261
  155. package/dist/dashboard/frontend/dist/assets/index-CQmpMTLW.js +0 -253
  156. package/dist/dashboard/frontend/dist/assets/index-Cfjy5acU.css +0 -1
  157. package/dist/dashboard/frontend/dist/assets/index-D41hcjgc.js +0 -253
  158. package/dist/dashboard/frontend/dist/assets/index-D83BawFd.css +0 -1
  159. package/dist/dashboard/frontend/dist/assets/index-DAODjoxu.css +0 -1
  160. package/dist/dashboard/frontend/dist/assets/index-DB-Eu5oV.js +0 -253
  161. package/dist/dashboard/frontend/dist/assets/index-W0VVEDu6.js +0 -253
  162. package/dist/dashboard/frontend/dist/assets/index-aK9PWl6w.js +0 -253
  163. package/dist/dashboard/frontend/vite.config.d.ts +0 -3
  164. package/dist/dashboard/frontend/vite.config.d.ts.map +0 -1
  165. package/dist/dashboard/frontend/vite.config.js +0 -11
  166. package/dist/dashboard/frontend/vite.config.js.map +0 -1
@@ -13,7 +13,15 @@ import crypto from 'node:crypto';
13
13
  import path from 'node:path';
14
14
  import os from 'node:os';
15
15
  import fs from 'node:fs';
16
+ import { ErrorNormalizer } from '../lib/error-normalizer.js';
17
+ import { NegativeCache } from '../services/negative-cache.js';
18
+ import { ErrorPatternDetector } from './error-pattern-detector.js';
16
19
  import { HookRunner } from '../hooks/hook-runner.js';
20
+ import { ToolHookRunner } from '../hooks/tool-hook-runner.js';
21
+ import { ShellSafetyHook } from '../hooks/built-in/shell-safety.js';
22
+ import { AuditLogHook } from '../hooks/built-in/audit-log.js';
23
+ import { RateLimitHook } from '../hooks/built-in/rate-limit.js';
24
+ import { SecretRedactHook } from '../hooks/built-in/secret-redact.js';
17
25
  import { Router } from './router.js';
18
26
  import { FailoverController } from './failover-controller.js';
19
27
  import { RetryQueue } from './retry-queue.js';
@@ -24,9 +32,12 @@ import { SteeringManager } from '../steering/steering-manager.js';
24
32
  import { MemoryManager } from '../memory/memory-manager.js';
25
33
  import { ExtractionPipeline } from '../memory/extraction-pipeline.js';
26
34
  import { createMemoryTools } from '../tools/memory-tools.js';
35
+ import { createSkillTools } from '../tools/skill-tool.js';
36
+ import { createSubagentTools } from '../tools/subagent-tool.js';
27
37
  import { ValidationPipeline } from '../memory/validation-pipeline.js';
28
38
  import { ContextCompressor } from '../memory/context-compressor.js';
29
39
  import { ObservationStore } from '../memory/observation-store.js';
40
+ import { ReflectorWorker } from '../memory/reflector-worker.js';
30
41
  import { HeartbeatSystem } from '../routines/heartbeat.js';
31
42
  import { RoutineManager } from '../routines/routine-manager.js';
32
43
  import { NotificationTools } from '../tools/notifications.js';
@@ -34,7 +45,15 @@ import { PolicyEngine } from '../security/policy-engine.js';
34
45
  import { IntentCapsuleManager } from '../security/intent-capsule.js';
35
46
  import { LeakDetector } from '../security/leak-detector.js';
36
47
  import { sanitizeInput } from '../security/prompt-defense.js';
48
+ import { createCapabilityToken, enforceCapability } from '../security/capability-tokens.js';
49
+ import { IntegrityGuardian } from '../security/integrity-guardian.js';
50
+ import { SecretsManager } from '../security/secrets-manager.js';
37
51
  import { createLogger } from '../utils/logger.js';
52
+ import { TLCIDispatcher } from './tlci-dispatcher.js';
53
+ import { PlanCache } from '../memory/plan-cache.js';
54
+ import { runCodeToolStep } from './code-tool-runner.js';
55
+ import { CostTracker } from '../dashboard/cost-tracker.js';
56
+ import { createPlanWorkflowTool } from '../tools/planning-tool.js';
38
57
  const log = createLogger('orchestrator');
39
58
  export class Orchestrator {
40
59
  _config;
@@ -54,6 +73,10 @@ export class Orchestrator {
54
73
  // Security
55
74
  _intentCapsuleManager;
56
75
  _leakDetector;
76
+ _integrityGuardian;
77
+ _secretsManager;
78
+ // Per-job capability tokens (keyed by jobId) for worker isolation enforcement
79
+ _activeTokens = new Map();
57
80
  // Background systems
58
81
  _heartbeatSystem = null;
59
82
  _routineManager = null;
@@ -61,15 +84,27 @@ export class Orchestrator {
61
84
  _validationPipeline;
62
85
  // ORCH-12: Lifecycle hooks
63
86
  _hookRunner = new HookRunner();
87
+ // Tool-level lifecycle hooks
88
+ _toolHookRunner = new ToolHookRunner();
89
+ // ERR-07: Error normalizer for safe error replay
90
+ _errorNormalizer = new ErrorNormalizer();
91
+ // ERR-12 Lite: Global negative cache for cross-session learning
92
+ _negativeCache;
64
93
  // ORCH-14: Context transform callback
65
94
  _transformContext = defaultTransformContext;
66
95
  // Context compression
67
96
  _observationStore;
97
+ _reflectorWorker;
68
98
  // Background intervals
69
99
  _authCheckTimeout = null;
70
100
  _retryPollTimeout = null;
71
101
  _consolidationTimeout = null;
72
102
  _booted = false;
103
+ // TLCI: lazy-initialized dispatcher and plan cache (additive — does not affect submitTask)
104
+ _planCache;
105
+ _tlciDispatcher;
106
+ _tlciCostTracker;
107
+ _tlciInitP; // Promise guard prevents double-init on concurrent calls
73
108
  constructor(options) {
74
109
  this._config = options.config;
75
110
  this._policy = options.policy;
@@ -105,6 +140,44 @@ export class Orchestrator {
105
140
  this._policyEngine.setIntentCapsuleManager(this._intentCapsuleManager);
106
141
  // SEC-03: Wire LeakDetector for scanning tool outputs
107
142
  this._leakDetector = new LeakDetector();
143
+ // SEC-11: IntegrityGuardian — baseline + tamper detection for critical config files
144
+ this._integrityGuardian = new IntegrityGuardian(this._baseDir);
145
+ const baselinesPath = path.join(this._baseDir, 'state', 'integrity-baselines.json');
146
+ let baselinesExist = false;
147
+ try {
148
+ await fs.promises.access(baselinesPath);
149
+ baselinesExist = true;
150
+ }
151
+ catch {
152
+ // first boot — baselines don't exist yet
153
+ }
154
+ if (!baselinesExist) {
155
+ await this._integrityGuardian.saveBaseline();
156
+ log.info('Integrity baselines established on first boot');
157
+ }
158
+ else {
159
+ const integrityResult = await this._integrityGuardian.checkIntegrity();
160
+ if (!integrityResult.valid) {
161
+ for (const mismatch of integrityResult.mismatches) {
162
+ log.warn({ file: mismatch.file, expected: mismatch.expected.slice(0, 8), actual: mismatch.actual.slice(0, 8) }, 'Integrity mismatch — possible tampering detected');
163
+ }
164
+ }
165
+ else {
166
+ log.info('Config integrity: clean');
167
+ }
168
+ }
169
+ // SEC-12: SecretsManager — AES-256-GCM encrypted secrets at rest
170
+ const masterPassword = process.env['ZORA_MASTER_PASSWORD'];
171
+ if (masterPassword) {
172
+ this._secretsManager = new SecretsManager(this._baseDir, masterPassword);
173
+ await this._secretsManager.init();
174
+ log.info('SecretsManager initialized');
175
+ // TODO: wire secret values into SecretRedactHook.addPattern() once that
176
+ // method is added to the hook's interface (SecretRedactHook has no addPattern yet)
177
+ }
178
+ else {
179
+ log.warn('ZORA_MASTER_PASSWORD not set — encrypted secrets storage unavailable. Set this env var to enable.');
180
+ }
108
181
  this._sessionManager = new SessionManager(this._baseDir);
109
182
  this._steeringManager = new SteeringManager(this._baseDir);
110
183
  await this._steeringManager.init();
@@ -114,6 +187,12 @@ export class Orchestrator {
114
187
  // Initialize observation store for context compression
115
188
  this._observationStore = new ObservationStore(path.join(this._baseDir, 'memory', 'observations'));
116
189
  await this._observationStore.init();
190
+ // MEM-20: Initialize ReflectorWorker if compression is enabled
191
+ if (this._config.memory?.compression?.enabled) {
192
+ const compressFn = this._buildCompressFn();
193
+ this._reflectorWorker = new ReflectorWorker(compressFn, this._memoryManager);
194
+ log.info('ReflectorWorker initialized');
195
+ }
117
196
  // R2: Wire Router
118
197
  this._router = new Router({
119
198
  providers: this._providers,
@@ -125,6 +204,9 @@ export class Orchestrator {
125
204
  // R5: Initialize RetryQueue
126
205
  this._retryQueue = new RetryQueue(this._baseDir);
127
206
  await this._retryQueue.init();
207
+ // ERR-12 Lite: Initialize global negative cache
208
+ this._negativeCache = new NegativeCache(this._baseDir);
209
+ await this._negativeCache.init();
128
210
  // R4: Schedule AuthMonitor
129
211
  this._authMonitor = new AuthMonitor({
130
212
  providers: this._providers,
@@ -145,18 +227,29 @@ export class Orchestrator {
145
227
  }, 5 * 60 * 1000);
146
228
  };
147
229
  scheduleAuthCheck();
148
- // R5: Poll RetryQueue (every 30 seconds) — remove task only after successful re-submission
230
+ // R5 / ERR-08: Poll RetryQueue (every 30 seconds) — use _resumeTask to preserve full
231
+ // TaskContext (state continuity) instead of re-submitting just the original prompt.
149
232
  const scheduleRetryPoll = () => {
150
233
  this._retryPollTimeout = setTimeout(async () => {
151
234
  try {
152
- const readyTasks = this._retryQueue.getReadyTasks();
153
- for (const task of readyTasks) {
235
+ const readyEntries = this._retryQueue.getReadyEntries();
236
+ for (const entry of readyEntries) {
154
237
  try {
155
- await this.submitTask({ prompt: task.task, jobId: task.jobId });
156
- await this._retryQueue.remove(task.jobId);
238
+ // ERR-08: Increment budgetConsumed before re-executing to track retry depth
239
+ if (entry.task.errorBudget) {
240
+ entry.task.errorBudget.budgetConsumed += 1;
241
+ // Skip tasks with exhausted budget
242
+ if (entry.task.errorBudget.budgetConsumed >= entry.task.errorBudget.maxBudget) {
243
+ log.warn({ jobId: entry.task.jobId }, 'Retry skipped: error budget exhausted');
244
+ await this._retryQueue.remove(entry.task.jobId);
245
+ continue;
246
+ }
247
+ }
248
+ await this._resumeTask(entry.task);
249
+ await this._retryQueue.remove(entry.task.jobId);
157
250
  }
158
251
  catch (err) {
159
- log.error({ jobId: task.jobId, err }, 'Retry failed');
252
+ log.error({ jobId: entry.task.jobId, err }, 'Retry failed');
160
253
  // Leave task in queue for next poll cycle
161
254
  }
162
255
  }
@@ -192,7 +285,12 @@ export class Orchestrator {
192
285
  const scheduleConsolidation = () => {
193
286
  this._consolidationTimeout = setTimeout(async () => {
194
287
  try {
195
- const count = await this._memoryManager.consolidateDailyNotes(7);
288
+ const reflectFn = this._reflectorWorker
289
+ ? async (content) => {
290
+ await this._reflectorWorker.reflect(content, `consolidation_${Date.now()}`);
291
+ }
292
+ : undefined;
293
+ const count = await this._memoryManager.consolidateDailyNotes(7, reflectFn);
196
294
  if (count > 0) {
197
295
  log.info({ consolidated: count }, 'Daily notes consolidated');
198
296
  }
@@ -206,13 +304,26 @@ export class Orchestrator {
206
304
  // Run first check shortly after boot (30 seconds), then daily
207
305
  this._consolidationTimeout = setTimeout(async () => {
208
306
  try {
209
- await this._memoryManager.consolidateDailyNotes(7);
307
+ const reflectFn = this._reflectorWorker
308
+ ? async (content) => {
309
+ await this._reflectorWorker.reflect(content, `consolidation_${Date.now()}`);
310
+ }
311
+ : undefined;
312
+ await this._memoryManager.consolidateDailyNotes(7, reflectFn);
210
313
  }
211
314
  catch (err) {
212
315
  log.warn({ err }, 'Initial daily note consolidation failed');
213
316
  }
214
317
  scheduleConsolidation();
215
318
  }, 30 * 1000);
319
+ // Register default tool-level hooks
320
+ this._toolHookRunner.register(ShellSafetyHook);
321
+ this._toolHookRunner.register(new AuditLogHook());
322
+ this._toolHookRunner.register(new RateLimitHook([
323
+ { tool: 'bash', maxCalls: 60, windowMs: 60_000 },
324
+ { tool: 'http_request', maxCalls: 100, windowMs: 60_000 },
325
+ ]));
326
+ this._toolHookRunner.register(SecretRedactHook);
216
327
  this._booted = true;
217
328
  }
218
329
  /**
@@ -262,6 +373,9 @@ export class Orchestrator {
262
373
  */
263
374
  async submitTask(options) {
264
375
  const jobId = options.jobId ?? `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
376
+ // SEC-10: Create a scoped capability token for this job
377
+ const capToken = createCapabilityToken(jobId, this._policy);
378
+ this._activeTokens.set(jobId, capToken);
265
379
  // Reset per-task state: ValidationPipeline rate limit is per-session, not per-orchestrator-lifetime.
266
380
  // Without this, after MAX_SAVES_PER_SESSION saves across all tasks, memory_save permanently blocks.
267
381
  this._validationPipeline.resetSession();
@@ -288,17 +402,12 @@ export class Orchestrator {
288
402
  // Create per-task context compressor if compression is enabled
289
403
  let compressor = null;
290
404
  if (this._config.memory?.compression?.enabled) {
291
- const compressFn = async (prompt) => {
292
- const compressLoop = new ExecutionLoop({
293
- systemPrompt: 'You are a conversation observer. Compress messages into concise, dated observations. Respond with ONLY the observations.',
294
- permissionMode: 'default',
295
- cwd: process.cwd(),
296
- maxTurns: 1,
297
- model: this._config.memory.compression.model,
298
- });
299
- return compressLoop.run(prompt);
300
- };
301
- compressor = new ContextCompressor(this._config.memory.compression, this._observationStore, compressFn, jobId);
405
+ const compressFn = this._buildCompressFn();
406
+ compressor = new ContextCompressor(this._config.memory.compression, this._observationStore, compressFn, jobId, this._reflectorWorker
407
+ ? async (obs, sid) => {
408
+ await this._reflectorWorker.reflectAndPersist(obs, sid, this._observationStore);
409
+ }
410
+ : undefined);
302
411
  await compressor.loadExisting();
303
412
  }
304
413
  // Build cross-session context from observations
@@ -326,12 +435,29 @@ export class Orchestrator {
326
435
  }
327
436
  // ASI01: Create signed intent capsule for goal drift detection
328
437
  if (this._intentCapsuleManager) {
329
- this._intentCapsuleManager.createCapsule(sanitizedPrompt);
438
+ const inferredCategories = this._intentCapsuleManager.inferCategories(sanitizedPrompt);
439
+ this._intentCapsuleManager.createCapsule(sanitizedPrompt, {
440
+ // Only set allowedActionCategories when constraints were actually inferred.
441
+ // Passing an empty array would block all actions; undefined means no restriction.
442
+ allowedActionCategories: inferredCategories.length > 0 ? inferredCategories : undefined,
443
+ });
444
+ if (inferredCategories.length > 0) {
445
+ log.info({ jobId, inferredCategories }, 'Intent capsule created with inferred action categories');
446
+ }
330
447
  }
331
448
  // Classify task for routing
332
449
  const classification = this._router.classifyTask(sanitizedPrompt);
333
450
  // Build custom tools (permissions + memory tools + recall_context)
334
451
  const customTools = this._createCustomTools();
452
+ // ERR-09: Build initial error budget — maxBudget from failover config, maxTurns from options
453
+ const maxRetries = this._config.failover.max_retries ?? 3;
454
+ const maxTurns = options.maxTurns ?? 0;
455
+ const errorBudget = {
456
+ maxBudget: maxRetries,
457
+ budgetConsumed: 0,
458
+ maxTurns,
459
+ turnsConsumed: 0,
460
+ };
335
461
  // Build task context
336
462
  const taskContext = {
337
463
  jobId,
@@ -345,8 +471,9 @@ export class Orchestrator {
345
471
  modelPreference: options.model,
346
472
  maxCostTier: options.maxCostTier,
347
473
  maxTurns: options.maxTurns,
474
+ errorBudget,
348
475
  customTools,
349
- canUseTool: this._policyEngine.createCanUseTool(),
476
+ canUseTool: this._buildTokenAwareCanUseTool(jobId),
350
477
  };
351
478
  // ORCH-12: Run onTaskStart hooks (can modify context before routing)
352
479
  const hookedContext = await this._hookRunner.runOnTaskStart(taskContext);
@@ -356,10 +483,17 @@ export class Orchestrator {
356
483
  selectedProvider = await this._router.selectProvider(hookedContext);
357
484
  }
358
485
  catch (err) {
486
+ this._activeTokens.delete(jobId);
359
487
  throw new Error(`No provider available: ${err instanceof Error ? err.message : String(err)}`);
360
488
  }
361
489
  // Execute with the selected provider (injectionDepth=0 for initial call)
362
- return this._executeWithProvider(selectedProvider, hookedContext, options.onEvent, 0, 0, compressor);
490
+ // SEC-10: Clean up capability token after task completes (success or failure)
491
+ try {
492
+ return await this._executeWithProvider(selectedProvider, hookedContext, options.onEvent, 0, 0, compressor);
493
+ }
494
+ finally {
495
+ this._activeTokens.delete(jobId);
496
+ }
363
497
  }
364
498
  /** Tracks errors that have already been through the failover path */
365
499
  static _failoverErrors = new WeakSet();
@@ -381,10 +515,37 @@ export class Orchestrator {
381
515
  * - The _failoverErrors WeakSet prevents double-failover: errors already processed
382
516
  * by the failover path are not re-triggered in the outer catch block.
383
517
  */
384
- async _executeWithProvider(provider, taskContext, onEvent, failoverDepth = 0, injectionDepth = 0, compressor) {
518
+ async _executeWithProvider(provider, taskContext, onEvent, failoverDepth = 0, injectionDepth = 0, compressor, patternDetectorIn) {
519
+ // ERR-09: Check error budget before every provider call
520
+ if (taskContext.errorBudget) {
521
+ const budget = taskContext.errorBudget;
522
+ if (budget.budgetConsumed >= budget.maxBudget) {
523
+ const errEvent = {
524
+ type: 'error',
525
+ timestamp: new Date(),
526
+ source: 'orchestrator',
527
+ content: {
528
+ message: `Error budget exceeded: ${budget.budgetConsumed}/${budget.maxBudget} retries consumed`,
529
+ code: 'error_budget_exceeded',
530
+ subtype: 'budget_consumed',
531
+ },
532
+ };
533
+ if (onEvent)
534
+ onEvent(errEvent);
535
+ throw new Error(`error_budget_exceeded: retry budget exhausted (${budget.budgetConsumed}/${budget.maxBudget})`);
536
+ }
537
+ }
385
538
  let result = '';
386
539
  let eventsSinceLastTick = 0;
387
540
  const TICK_INTERVAL = 10; // Check compression thresholds every N events
541
+ // ERR-10: Per-execution pattern detector (in-session circuit breaker)
542
+ const patternDetector = patternDetectorIn ?? new ErrorPatternDetector();
543
+ // ERR-09: Stale-state loop detection — track whether a tool was called this turn
544
+ let toolCalledThisTurn = false;
545
+ let consecutiveNonToolTurns = 0;
546
+ const STALE_LOOP_THRESHOLD = 3;
547
+ // Tool-level hook tracking: map toolCallId → call start timestamp
548
+ const _toolCallStartTimes = new Map();
388
549
  // Event batching: buffer session writes, flush every 500ms or on done/error.
389
550
  // Wrapped in try/finally to ensure close() runs on ALL exit paths including failover.
390
551
  const bufferedWriter = new BufferedSessionWriter(this._sessionManager, taskContext.jobId, 500);
@@ -392,8 +553,12 @@ export class Orchestrator {
392
553
  try {
393
554
  // Execute via the provider's async generator
394
555
  for await (const event of provider.execute(taskContext)) {
395
- // R8: Persist events via buffered writer (batched disk I/O)
396
- bufferedWriter.append(event);
556
+ // R8: Persist events via buffered writer (batched disk I/O).
557
+ // tool_call events are deferred until after before-hooks run so
558
+ // SecretRedactHook can modify args before they hit the log.
559
+ if (event.type !== 'tool_call') {
560
+ bufferedWriter.append(event);
561
+ }
397
562
  // Feed events to context compressor for rolling compression
398
563
  if (compressor) {
399
564
  compressor.ingest(event);
@@ -425,7 +590,125 @@ export class Orchestrator {
425
590
  if (leaks.length > 0) {
426
591
  log.warn({ jobId: taskContext.jobId, tool: toolCallContent.tool, leaks: leaks.map(l => ({ pattern: l.pattern, severity: l.severity })) }, 'Potential secret leak detected in tool call arguments');
427
592
  }
593
+ // ERR-12 Lite: Check NegativeCache for hot-failing tool signatures
594
+ const args = toolCallContent.arguments ?? {};
595
+ try {
596
+ const cacheResult = await this._negativeCache.check(toolCallContent.tool, args);
597
+ if (cacheResult.isHotFailing && cacheResult.hint) {
598
+ log.warn({ jobId: taskContext.jobId, tool: toolCallContent.tool, failures: cacheResult.failureCount }, 'NegativeCache: hot-failing tool detected — injecting system hint');
599
+ const hintEvent = {
600
+ type: 'steering',
601
+ timestamp: new Date(),
602
+ source: 'negative-cache',
603
+ content: { text: cacheResult.hint, source: 'negative-cache', author: 'system' },
604
+ };
605
+ taskContext.history.push(hintEvent);
606
+ if (onEvent)
607
+ onEvent(hintEvent);
608
+ }
609
+ }
610
+ catch (err) {
611
+ log.debug({ err }, 'NegativeCache check failed (non-critical)');
612
+ }
613
+ // ERR-09: Stale-state loop — tool call resets the consecutive counter
614
+ toolCalledThisTurn = true;
615
+ consecutiveNonToolTurns = 0;
616
+ // Tool-level before-hooks: record start time and run before-hooks.
617
+ // Logging happens AFTER hooks so SecretRedactHook can redact args
618
+ // before they are written to disk or the session log.
619
+ _toolCallStartTimes.set(toolCallContent.toolCallId, Date.now());
620
+ try {
621
+ const hookBefore = await this._toolHookRunner.runBefore({
622
+ jobId: taskContext.jobId,
623
+ tool: toolCallContent.tool,
624
+ arguments: toolCallContent.arguments ?? {},
625
+ });
626
+ // Log the event now (with potentially-redacted args from hooks)
627
+ const hookedArgs = hookBefore.args;
628
+ const loggedEvent = hookedArgs !== (toolCallContent.arguments ?? {})
629
+ ? { ...event, content: { ...toolCallContent, arguments: hookedArgs } }
630
+ : event;
631
+ bufferedWriter.append(loggedEvent);
632
+ log.debug({ jobId: taskContext.jobId, tool: toolCallContent.tool, arguments: hookedArgs }, 'tool call');
633
+ if (!hookBefore.allow) {
634
+ // Inject a synthetic tool_result indicating the tool was blocked
635
+ const blockedEvent = {
636
+ type: 'tool_result',
637
+ timestamp: new Date(),
638
+ source: 'tool-hook-runner',
639
+ content: {
640
+ toolCallId: toolCallContent.toolCallId,
641
+ result: null,
642
+ error: `Tool call blocked by hook: ${toolCallContent.tool}`,
643
+ },
644
+ };
645
+ bufferedWriter.append(blockedEvent);
646
+ taskContext.history.push(blockedEvent);
647
+ if (onEvent)
648
+ onEvent(blockedEvent);
649
+ }
650
+ }
651
+ catch (err) {
652
+ // If hooks fail, still log the original event so the call isn't lost
653
+ bufferedWriter.append(event);
654
+ log.error({ err, tool: toolCallContent.tool, jobId: taskContext.jobId }, 'tool-hook runBefore error (non-critical)');
655
+ }
656
+ }
657
+ // ERR-10: Pattern detection on tool results + ERR-12: record failures/successes
658
+ if (event.type === 'tool_result') {
659
+ const toolResultContent = event.content;
660
+ const hasFailed = Boolean(toolResultContent.error);
661
+ // Find the matching tool_call in history to get name + args
662
+ const matchingCall = [...taskContext.history].reverse().find(e => e.type === 'tool_call' &&
663
+ e.content.toolCallId === toolResultContent.toolCallId);
664
+ if (matchingCall) {
665
+ const callContent = matchingCall.content;
666
+ const args = callContent.arguments ?? {};
667
+ // ERR-12: Record failure/success in persistent negative cache
668
+ if (hasFailed) {
669
+ this._negativeCache.recordFailure(callContent.tool, args).catch(err => {
670
+ log.debug({ err }, 'NegativeCache recordFailure failed (non-critical)');
671
+ });
672
+ }
673
+ else {
674
+ this._negativeCache.recordSuccess(callContent.tool, args).catch(err => {
675
+ log.debug({ err }, 'NegativeCache recordSuccess failed (non-critical)');
676
+ });
677
+ }
678
+ // ERR-10: In-session circuit breaker — detect repeat failures
679
+ const detection = patternDetector.record(callContent.tool, args, !hasFailed);
680
+ if (detection.isRepeating && detection.hint) {
681
+ log.warn({ jobId: taskContext.jobId, tool: detection.toolName }, 'ERR-10: Repeat tool failure detected — injecting hard steering hint');
682
+ const hintEvent = {
683
+ type: 'steering',
684
+ timestamp: new Date(),
685
+ source: 'error-pattern-detector',
686
+ content: { text: detection.hint, source: 'error-pattern-detector', author: 'system' },
687
+ };
688
+ bufferedWriter.append(hintEvent);
689
+ taskContext.history.push(hintEvent);
690
+ if (onEvent)
691
+ onEvent(hintEvent);
692
+ }
693
+ }
694
+ // ERR-09: Stale-state — tool_result is a "turn", but only counts if no tool call followed
695
+ // (tracked by text events below)
696
+ // Tool-level after-hooks: run after every tool result
697
+ const _startMs = _toolCallStartTimes.get(toolResultContent.toolCallId);
698
+ _toolCallStartTimes.delete(toolResultContent.toolCallId);
699
+ const _matchingCallForHook = matchingCall;
700
+ if (_matchingCallForHook) {
701
+ const _callContentForHook = _matchingCallForHook.content;
702
+ await this._toolHookRunner.runAfter({
703
+ jobId: taskContext.jobId,
704
+ tool: _callContentForHook.tool,
705
+ arguments: _callContentForHook.arguments ?? {},
706
+ result: toolResultContent.result,
707
+ durationMs: _startMs !== undefined ? Date.now() - _startMs : undefined,
708
+ });
709
+ }
428
710
  }
711
+ // (ERR-09: stale-state tracking is done on 'done' events below)
429
712
  // R7: Poll SteeringManager with debouncing (max once per 2 seconds)
430
713
  if (event.type === 'text' || event.type === 'tool_result') {
431
714
  const pendingMessages = await this._steeringManager.cachedGetPendingMessages(taskContext.jobId, 2000);
@@ -450,13 +733,82 @@ export class Orchestrator {
450
733
  onEvent(event);
451
734
  // Track history for failover handoff
452
735
  taskContext.history.push(event);
453
- // Capture result text
736
+ // Capture result text and enforce turn budget
454
737
  if (event.type === 'done') {
455
738
  result = event.content.text ?? '';
739
+ // ERR-09: Increment turnsConsumed and check maxTurns limit
740
+ if (taskContext.errorBudget) {
741
+ const budget = taskContext.errorBudget;
742
+ budget.turnsConsumed += 1;
743
+ // Stale-state detection: count consecutive turns with no tool calls
744
+ if (!toolCalledThisTurn) {
745
+ consecutiveNonToolTurns += 1;
746
+ if (consecutiveNonToolTurns >= STALE_LOOP_THRESHOLD) {
747
+ const staleEvent = {
748
+ type: 'error',
749
+ timestamp: new Date(),
750
+ source: 'orchestrator',
751
+ content: {
752
+ message: `Stale state loop: ${consecutiveNonToolTurns} consecutive turns without tool calls`,
753
+ code: 'error_budget_exceeded',
754
+ subtype: 'stale_state_loop',
755
+ },
756
+ };
757
+ bufferedWriter.append(staleEvent);
758
+ if (onEvent)
759
+ onEvent(staleEvent);
760
+ throw new Error(`error_budget_exceeded: stale state loop detected (${consecutiveNonToolTurns} turns without tool calls)`);
761
+ }
762
+ }
763
+ else {
764
+ consecutiveNonToolTurns = 0;
765
+ }
766
+ // Reset per-turn flag
767
+ toolCalledThisTurn = false;
768
+ // Check hard maxTurns limit
769
+ if (budget.maxTurns > 0 && budget.turnsConsumed >= budget.maxTurns) {
770
+ const turnErrEvent = {
771
+ type: 'error',
772
+ timestamp: new Date(),
773
+ source: 'orchestrator',
774
+ content: {
775
+ message: `Turn limit exceeded: ${budget.turnsConsumed}/${budget.maxTurns} turns consumed`,
776
+ code: 'error_budget_exceeded',
777
+ subtype: 'turn_limit_exceeded',
778
+ },
779
+ };
780
+ if (onEvent)
781
+ onEvent(turnErrEvent);
782
+ throw new Error(`error_budget_exceeded: turn limit exhausted (${budget.turnsConsumed}/${budget.maxTurns})`);
783
+ }
784
+ }
785
+ else {
786
+ // Reset per-turn flag even without budget tracking
787
+ toolCalledThisTurn = false;
788
+ }
456
789
  }
457
790
  // Handle errors — trigger failover (R3)
458
791
  if (event.type === 'error') {
459
792
  const errorContent = event.content;
793
+ // ERR-07: Normalize error for structured logging (safe message, category)
794
+ const normalized = this._errorNormalizer.normalize(errorContent.message ?? 'Unknown provider error');
795
+ log.warn({ jobId: taskContext.jobId, category: normalized.category, message: normalized.safeMessage }, 'ERR-07: Provider error normalized');
796
+ // ERR-07: Inject failure_report as a tool_result event into history for LLM context
797
+ const failureToolCallId = `err_${Date.now()}`;
798
+ const failureReport = this._errorNormalizer.toFailureReport(failureToolCallId, normalized);
799
+ const failureEvent = {
800
+ type: 'tool_result',
801
+ timestamp: new Date(),
802
+ source: 'orchestrator',
803
+ content: {
804
+ toolCallId: failureToolCallId,
805
+ result: failureReport,
806
+ error: normalized.safeMessage,
807
+ },
808
+ };
809
+ taskContext.history.push(failureEvent);
810
+ if (onEvent)
811
+ onEvent(failureEvent);
460
812
  const error = new Error(errorContent.message ?? 'Unknown provider error');
461
813
  // Guard: skip failover if depth exceeded
462
814
  if (failoverDepth >= Orchestrator.MAX_FAILOVER_DEPTH) {
@@ -466,7 +818,8 @@ export class Orchestrator {
466
818
  const failoverResult = await this._failoverController.handleFailure(taskContext, provider, error);
467
819
  if (failoverResult) {
468
820
  // Re-execute with the failover provider (increment depth)
469
- return this._executeWithProvider(failoverResult.nextProvider, taskContext, onEvent, failoverDepth + 1, injectionDepth, compressor);
821
+ // Preserve intent capsule across failover and pass same patternDetector
822
+ return this._executeWithFailoverProvider(failoverResult.nextProvider, taskContext, onEvent, failoverDepth + 1, injectionDepth, compressor, patternDetector);
470
823
  }
471
824
  // R5: Enqueue for retry if no failover available
472
825
  try {
@@ -490,7 +843,8 @@ export class Orchestrator {
490
843
  if (failoverResult) {
491
844
  // Mark the error so downstream doesn't re-trigger failover
492
845
  Orchestrator._failoverErrors.add(err);
493
- return this._executeWithProvider(failoverResult.nextProvider, taskContext, onEvent, failoverDepth + 1, injectionDepth, compressor);
846
+ // Preserve intent capsule across failover and pass same patternDetector
847
+ return this._executeWithFailoverProvider(failoverResult.nextProvider, taskContext, onEvent, failoverDepth + 1, injectionDepth, compressor, patternDetector);
494
848
  }
495
849
  // R5: Enqueue for retry
496
850
  try {
@@ -549,6 +903,133 @@ export class Orchestrator {
549
903
  }
550
904
  return result;
551
905
  }
906
+ /**
907
+ * submitWorkflow — TLCI-aware multi-step workflow dispatch.
908
+ *
909
+ * Routes each step to the cheapest capable tier:
910
+ * Tier 1 (code): deterministic code tools — httpFetch, transform, fileOp, etc.
911
+ * Tier 2 (slm): local Ollama model (free cost-tier provider), falls back to frontier
912
+ * Tier 3 (frontier): existing Zora provider stack (Claude/Gemini)
913
+ *
914
+ * Pass structured parameters for code-tool steps via WorkflowStep.context:
915
+ * { id: '1', description: 'fetch user data', context: { url: 'https://api.example.com/users' } }
916
+ *
917
+ * Additive — does not touch submitTask.
918
+ */
919
+ async submitWorkflow(steps, opts) {
920
+ await this._ensureTLCI();
921
+ return this._tlciDispatcher.dispatch(steps, opts ?? {});
922
+ }
923
+ /** Expose CostTracker for DashboardServer /api/tlci-stats wiring. */
924
+ getTLCICostTracker() {
925
+ return this._tlciCostTracker;
926
+ }
927
+ /**
928
+ * Initialize TLCI subsystem exactly once, even under concurrent submitWorkflow calls.
929
+ * Uses a promise guard to prevent double-initialization race conditions.
930
+ */
931
+ _ensureTLCI() {
932
+ if (!this._tlciInitP) {
933
+ this._tlciInitP = this._initTLCI();
934
+ }
935
+ return this._tlciInitP;
936
+ }
937
+ async _initTLCI() {
938
+ this._planCache = new PlanCache();
939
+ await this._planCache.init();
940
+ this._tlciCostTracker = new CostTracker(this._planCache);
941
+ const self = this;
942
+ const ollamaProvider = this._providers.find(p => p.costTier === 'free');
943
+ this._tlciDispatcher = new TLCIDispatcher(this._planCache, { autonomyLevel: 'full' },
944
+ // Tier 1: real code tools — no LLM call, no token cost
945
+ async (step) => {
946
+ const classifiedStep = step;
947
+ const result = await runCodeToolStep({
948
+ id: step.id,
949
+ suggestedCodeTool: classifiedStep.suggestedCodeTool,
950
+ context: step.context,
951
+ description: step.description,
952
+ });
953
+ if (!result.success) {
954
+ log.warn({ stepId: step.id, tool: result.tool, error: result.error }, 'code-tool step failed');
955
+ }
956
+ return result;
957
+ },
958
+ // Tier 2: Ollama (local, free) — route via maxCostTier:'free'
959
+ // Falls back to frontier only on connection refusal / unavailability
960
+ async (step) => {
961
+ if (ollamaProvider) {
962
+ try {
963
+ const available = await ollamaProvider.isAvailable();
964
+ if (available) {
965
+ return await self.submitTask({ prompt: step.description, maxCostTier: 'free' });
966
+ }
967
+ }
968
+ catch (err) {
969
+ const msg = err instanceof Error ? err.message : String(err);
970
+ // Only fall back for connection errors, not execution failures
971
+ if (/ECONNREFUSED|ENOTFOUND|timeout|unavailable/i.test(msg)) {
972
+ log.warn({ stepId: step.id, err: msg }, 'Ollama unreachable — falling back to frontier');
973
+ }
974
+ else {
975
+ throw err; // real execution error — propagate
976
+ }
977
+ }
978
+ }
979
+ log.warn({ stepId: step.id }, 'Ollama unavailable — falling back to frontier for SLM step');
980
+ return self.submitTask({ prompt: step.description });
981
+ },
982
+ // Tier 3: frontier — existing provider stack
983
+ async (step) => self.submitTask({ prompt: step.description }),
984
+ // Approval — auto in full-autonomy mode
985
+ async (_message) => true,
986
+ // Wire CostTracker so it records every dispatch
987
+ this._tlciCostTracker);
988
+ }
989
+ /**
990
+ * ERR-08: Resume a previously-failed task using its full serialized TaskContext.
991
+ *
992
+ * Unlike submitTask(), this skips the "Planning/Classification" phase entirely.
993
+ * The existing history and memoryContext are preserved so the provider can
994
+ * continue from the exact point of failure — State Continuity.
995
+ *
996
+ * Called by the RetryQueue poll to resume persisted task contexts.
997
+ *
998
+ * @param context - The full TaskContext as persisted by the RetryQueue
999
+ * @returns The final text result from the resumed execution
1000
+ */
1001
+ async _resumeTask(context, onEvent, compressor) {
1002
+ if (!this._booted)
1003
+ throw new Error('Orchestrator.boot() must be called before _resumeTask');
1004
+ log.info({ jobId: context.jobId, historyLength: context.history.length }, 'ERR-08: Resuming task with preserved context (skipping classification)');
1005
+ // Reset ValidationPipeline rate limit for the resumed session
1006
+ this._validationPipeline?.resetSession();
1007
+ // Refresh canUseTool — the original closure may be stale after a restart
1008
+ // SEC-10: Re-issue a capability token for the resumed job and apply token-aware enforcement
1009
+ const resumeJobId = context.jobId;
1010
+ const resumeCapToken = createCapabilityToken(resumeJobId, this._policy);
1011
+ this._activeTokens.set(resumeJobId, resumeCapToken);
1012
+ const resumeContext = {
1013
+ ...context,
1014
+ canUseTool: this._buildTokenAwareCanUseTool(resumeJobId),
1015
+ };
1016
+ // Route to provider using the preserved classification (no re-classification)
1017
+ let selectedProvider;
1018
+ try {
1019
+ selectedProvider = await this._router.selectProvider(resumeContext);
1020
+ }
1021
+ catch (err) {
1022
+ this._activeTokens.delete(resumeJobId);
1023
+ throw new Error(`No provider available for resume: ${err instanceof Error ? err.message : String(err)}`);
1024
+ }
1025
+ // SEC-10: Clean up capability token after resumed task completes (success or failure)
1026
+ try {
1027
+ return await this._executeWithProvider(selectedProvider, resumeContext, onEvent, 0, 0, compressor);
1028
+ }
1029
+ finally {
1030
+ this._activeTokens.delete(resumeJobId);
1031
+ }
1032
+ }
552
1033
  /**
553
1034
  * MEM-09: Runs memory extraction asynchronously after job completion.
554
1035
  *
@@ -619,6 +1100,22 @@ export class Orchestrator {
619
1100
  }
620
1101
  log.info({ jobId: taskContext.jobId, extracted: result.items.length, saved: savedCount }, 'Memory extraction complete');
621
1102
  }
1103
+ /**
1104
+ * Execute with a failover provider while preserving the active intent capsule.
1105
+ * Serializes the capsule before handing off to the next provider and restores
1106
+ * it afterwards if the execution cleared it (defensive measure).
1107
+ */
1108
+ async _executeWithFailoverProvider(nextProvider, taskContext, onEvent, failoverDepth, injectionDepth, compressor, patternDetector) {
1109
+ const capsuleSnapshot = this._intentCapsuleManager?.serializeActiveCapsule() ?? null;
1110
+ const result = await this._executeWithProvider(nextProvider, taskContext, onEvent, failoverDepth, injectionDepth, compressor, patternDetector);
1111
+ if (capsuleSnapshot && this._intentCapsuleManager && !this._intentCapsuleManager.getActiveCapsule()) {
1112
+ const restored = this._intentCapsuleManager.restoreCapsule(capsuleSnapshot);
1113
+ if (!restored) {
1114
+ log.warn({ jobId: taskContext.jobId }, 'Failed to restore intent capsule after failover — drift detection disabled for remainder of task');
1115
+ }
1116
+ }
1117
+ return result;
1118
+ }
622
1119
  /**
623
1120
  * Creates custom tools available to the agent during execution.
624
1121
  * Includes: permission tools, memory tools (search/save/forget), recall_context.
@@ -706,7 +1203,40 @@ export class Orchestrator {
706
1203
  return { notes, count: notes.length, days };
707
1204
  },
708
1205
  };
709
- return [...permissionTools, ...memoryTools, recallContextTool];
1206
+ // Skill tools: list_skills and invoke_skill for agent-callable skill library
1207
+ const skillTools = createSkillTools(this._policyEngine);
1208
+ // Wire plan_workflow tool — bound to submitWorkflow so the LLM can decompose
1209
+ // and optionally execute TLCI workflows mid-conversation.
1210
+ const planWorkflowTool = createPlanWorkflowTool((steps, opts) => this.submitWorkflow(steps, opts));
1211
+ // Subagent tools: list_subagents and delegate_to_subagent
1212
+ const subagentTools = createSubagentTools((opts) => this.submitTask({ prompt: opts.prompt }));
1213
+ return [...permissionTools, ...memoryTools, recallContextTool, ...skillTools, planWorkflowTool, ...subagentTools];
1214
+ }
1215
+ /**
1216
+ * SEC-10: Builds a token-aware canUseTool function that enforces capability
1217
+ * token restrictions on path and command inputs before delegating to the
1218
+ * policy engine. Call this once per job to get a closure bound to jobId.
1219
+ */
1220
+ _buildTokenAwareCanUseTool(jobId) {
1221
+ const policyCanUseTool = this._policyEngine.createCanUseTool();
1222
+ return async (tool, input, options) => {
1223
+ const token = this._activeTokens.get(jobId);
1224
+ if (token) {
1225
+ const pathArg = input['path'];
1226
+ if (pathArg) {
1227
+ const capResult = enforceCapability(token, { type: 'path', target: pathArg });
1228
+ if (!capResult.allowed)
1229
+ return { behavior: 'deny', message: capResult.reason ?? 'Path denied by capability token' };
1230
+ }
1231
+ const cmdArg = input['command'];
1232
+ if (cmdArg) {
1233
+ const capResult = enforceCapability(token, { type: 'command', target: cmdArg });
1234
+ if (!capResult.allowed)
1235
+ return { behavior: 'deny', message: capResult.reason ?? 'Command denied by capability token' };
1236
+ }
1237
+ }
1238
+ return policyCanUseTool(tool, input, options);
1239
+ };
710
1240
  }
711
1241
  /**
712
1242
  * Parse interval strings like "30m", "1h" to minutes.
@@ -763,6 +1293,10 @@ export class Orchestrator {
763
1293
  get hookRunner() {
764
1294
  return this._hookRunner;
765
1295
  }
1296
+ /** Register a tool-level lifecycle hook (fires before/after every tool call) */
1297
+ registerToolHook(hook) {
1298
+ this._toolHookRunner.register(hook);
1299
+ }
766
1300
  /** ORCH-14: Set a custom context transform function */
767
1301
  set transformContext(fn) {
768
1302
  this._transformContext = fn;
@@ -771,11 +1305,43 @@ export class Orchestrator {
771
1305
  get transformContext() {
772
1306
  return this._transformContext;
773
1307
  }
1308
+ /** SEC-11: Access the IntegrityGuardian for baseline and tamper checks */
1309
+ get integrityGuardian() {
1310
+ this._assertBooted();
1311
+ return this._integrityGuardian;
1312
+ }
1313
+ /** SEC-11: Re-save integrity baselines after intentional config changes */
1314
+ async rebaselineIntegrity() {
1315
+ this._assertBooted();
1316
+ await this._integrityGuardian.saveBaseline();
1317
+ log.info('Integrity baselines updated');
1318
+ }
1319
+ /** SEC-12: Access the SecretsManager (undefined if ZORA_MASTER_PASSWORD not set) */
1320
+ get secretsManager() {
1321
+ return this._secretsManager;
1322
+ }
774
1323
  get config() {
775
1324
  return this._config;
776
1325
  }
777
1326
  get providers() {
778
1327
  return this._providers;
779
1328
  }
1329
+ // ── Private helpers ──────────────────────────────────────────────
1330
+ /**
1331
+ * MEM-20: Build the compressFn used by ContextCompressor and ReflectorWorker.
1332
+ * Extracted so it can be reused in boot() (for ReflectorWorker) and submitTask().
1333
+ */
1334
+ _buildCompressFn() {
1335
+ return async (prompt) => {
1336
+ const compressLoop = new ExecutionLoop({
1337
+ systemPrompt: 'You are a conversation observer. Compress messages into concise, dated observations. Respond with ONLY the observations.',
1338
+ permissionMode: 'default',
1339
+ cwd: process.cwd(),
1340
+ maxTurns: 1,
1341
+ model: this._config.memory?.compression?.model,
1342
+ });
1343
+ return compressLoop.run(prompt);
1344
+ };
1345
+ }
780
1346
  }
781
1347
  //# sourceMappingURL=orchestrator.js.map