The Atlas Lavern's documentation, bound to its code
111 documents

Clawern deep-dive

The autonomous pipeline — watch a folder, read what arrives, remember across documents — and the seven-report eval arc that hardened it.

src/claw/processor.ts580 lines · processDocument L73–578
Outline 4 symbols
1/**
2 * Document Processor — The per-document lifecycle engine.
3 *
4 * For each document that needs work, the processor:
5 * 1. PARSE — Read and parse the document (PDF/DOCX/TXT/MD/RTF/HTML)
6 * 2. INFER — Determine what work is needed (sidecar > LLM > heuristic)
7 * 3. DISPATCH — Run the inferred workflow via the existing engine
8 * 4. DELIVER — Write output bundle to the delivery directory
9 * 5. UPDATE — Update registry with results and cost
10 *
11 * Reuses the entire existing pipeline: dispatch → router → agents →
12 * debate → verify → assemble. The processor is just the Claw Mode
13 * wrapper that connects the watcher/registry to the engine.
14 */
15
16import * as crypto from 'node:crypto';
17import * as fs from 'node:fs';
18import { readFile } from 'node:fs/promises';
19import * as path from 'node:path';
20import { writeJsonFileAtomic } from '../utils/fs-helpers.js';
21import { dispatch } from '../dispatch.js';
22import type { DispatchOptions } from '../dispatch.js';
23import { parseDocument } from '../documents/parser.js';
24import { AutoApproveGateResolver } from '../gates/gate-resolver.js';
25import { inferTask } from './inference.js';
26import { watchmanTriage } from './watchman.js';
27import { ClawDelivery } from './delivery.js';
28import { DocumentRegistry } from './registry.js';
29import { extractSessionFindings } from './types.js';
30import { notify } from './notify.js';
31import { config } from '../config.js';
32import { analyzeLocally, extractLocalFindings, localResultToFindings } from './local-analysis.js';
33import { getPrecedentBoard } from './precedent-board.js';
34import { loadPreviousFindings, computeDiff, diffSummary, type FindingsDiff } from './diff.js';
35import { clawEventBus } from './events.js';
36import { eventTimestamp } from '../events/event-bus.js';
37import type { ClawProfile, ClawJob, ClawConfig } from './types.js';
38import { createLogger } from '../utils/logger.js';
39import { captureError } from '../utils/sentry.js';
40
41const logger = createLogger('CLAW-PROCESSOR');
42
43// ── MIME from extension ──────────────────────────────────────────────────
44
45function mimeFromExt(ext: string): string {
46 const map: Record<string, string> = {
47 '.pdf': 'application/pdf',
48 '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
49 '.doc': 'application/msword',
50 '.txt': 'text/plain',
51 '.md': 'text/markdown',
52 '.rtf': 'text/rtf',
53 '.html': 'text/html',
54 '.htm': 'text/html',
55 };
56 return map[ext] ?? 'text/plain';
57}
58
59// ── Processor ────────────────────────────────────────────────────────────
60
61export interface ProcessResult {
62 sessionId: string;
63 documentPath: string;
64 documentHash: string;
65 success: boolean;
66 costUsd: number;
67 durationMs: number;
68 findings: { critical: number; major: number; minor: number };
69 deliveryDir: string;
70 error?: string;
71}
72
73export async function processDocument(
74 documentPath: string,
75 documentHash: string,
76 profile: ClawProfile,
77 registry: DocumentRegistry,
78 clawConfig: ClawConfig,
79 onProgress?: (message: string) => void,
80 confidential?: boolean,
81): Promise<ProcessResult> {
82 const startTime = Date.now();
83 const sessionId = `shem-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
84 const delivery = new ClawDelivery(clawConfig.dir);
85
86 const log = (msg: string) => {
87 if (onProgress) onProgress(msg);
88 if (clawConfig.debug) logger.info(msg, { sessionId });
89 };
90
91 try {
92 // Mark as processing
93 registry.updateStatus(documentHash, 'processing');
94
95 // ── 1. PARSE ──────────────────────────────────────────────────────
96 log(`Parsing: ${path.basename(documentPath)}`);
97 const buffer = await readFile(documentPath);
98 const ext = path.extname(documentPath).toLowerCase();
99 const mime = mimeFromExt(ext);
100 const parsed = await parseDocument(buffer, path.basename(documentPath), mime);
101
102 // ── 1a. WATCHMAN TRIAGE (lighthouse persona 1) ────────────────────
103 // Runs first on every document. One LLM call (local-first, cloud
104 // fallback, heuristic last). Decides:
105 // route ∈ { skip | quick-scan | deep-read }
106 // documentType (drives Reader template selection)
107 // urgency (drives intensity)
108 // Skip is SOFT: registry is marked 'skipped', user can force re-process.
109 //
110 // PRIVACY: pre-compute routeLocally so the Watchman can be told
111 // localOnly=true when the document would be processed locally
112 // anyway. Without this, a confidential doc's first 1500 chars +
113 // client profile would leak to cloud Haiku during triage if
114 // local Ollama happened to be unavailable. localOnly=true makes
115 // the Watchman fall straight from local→heuristic, never cloud.
116 const processingMode = profile.processing ?? 'local';
117 const localModelConfigured = !!(config.claw.localModel || config.local.defaultModel);
118 const routeLocally = localModelConfigured && (confidential || processingMode === 'local' || processingMode === 'hybrid');
119 const watchman = await watchmanTriage({
120 filename: path.basename(documentPath),
121 documentText: parsed.fullText,
122 profile,
123 localOnly: routeLocally,
124 });
125 log(`👁 Watchman: ${watchman.documentType} · route=${watchman.route} · ${watchman.method} (conf ${watchman.confidence.toFixed(2)})${routeLocally ? ' · local-only' : ''}`);
126
127 if (watchman.route === 'skip') {
128 // Soft-skip: mark in registry, fire a low-noise notification, and exit.
129 // The user can force a re-process by deleting the entry from state.json
130 // or invoking the CLI's `claw rescan --force`.
131 log(`⏭ Skipped: ${watchman.rationale.slice(0, 140)}`);
132 registry.updateStatus(documentHash, 'skipped');
133 const durationMs = Date.now() - startTime;
134 clawEventBus.emitEvent({
135 type: 'claw_job_completed',
136 documentPath,
137 documentHash,
138 costUsd: watchman.costUsd,
139 durationMs,
140 findings: { critical: 0, major: 0, minor: 0 },
141 timestamp: eventTimestamp(),
142 });
143 return {
144 sessionId,
145 documentPath,
146 documentHash,
147 success: true,
148 costUsd: watchman.costUsd,
149 durationMs,
150 findings: { critical: 0, major: 0, minor: 0 },
151 deliveryDir: '',
152 };
153 }
154
155 // ── 1b. CONFIDENTIALITY GATE ──────────────────────────────────────
156 // Three triggers route a doc through the local pipeline:
157 // (a) sensitivity-pattern match (filename / metadata indicates confidential)
158 // (b) profile.processing === 'local' — explicit "everything local" policy
159 // (c) profile.processing === 'hybrid' — local triage + selective frontier
160 // Any of these causes the local-analysis path to run. Without one of them,
161 // the doc falls through to the standard frontier pipeline.
162 // NOTE: processingMode / localModelConfigured / routeLocally are computed
163 // earlier (above the Watchman call) so the Watchman can run in localOnly
164 // mode for confidential documents. Reused here.
165
166 if (routeLocally) {
167 const localModelName = config.claw.localAnalysisModel || config.claw.localModel || config.local.defaultModel;
168
169 // ── HYBRID PATH: local triage + anonymized frontier ──────────
170 if (processingMode === 'hybrid') {
171 log(`🔒🌐 Hybrid — local triage + anonymized frontier (${localModelName})`);
172 try {
173 const { analyzeHybrid } = await import('./hybrid-analysis.js');
174 const hybridBoard = (() => {
175 try { return getPrecedentBoard(clawConfig.dir); } catch { return undefined; }
176 })();
177 const hybridResult = await analyzeHybrid(
178 parsed.fullText, path.basename(documentPath), profile, clawConfig, parsed, log,
179 { watchman, precedentBoard: hybridBoard },
180 );
181
182 // Convert hybrid findings to summary counts
183 const hybridFindings = { critical: 0, major: 0, minor: 0 };
184 for (const f of hybridResult.findings) {
185 const sev = f.severity.toUpperCase();
186 if (sev === 'RED' || sev === 'CRITICAL') hybridFindings.critical++;
187 else if (sev === 'YELLOW' || sev === 'MAJOR') hybridFindings.major++;
188 else hybridFindings.minor++;
189 }
190
191 const deliveryDir = await delivery.deliverHybrid(
192 sessionId, hybridResult, documentPath, documentHash, clawConfig,
193 );
194
195 registry.markReviewed(documentHash, sessionId, hybridFindings, hybridResult.cost.totalUsd, true);
196
197 // Index local-derived findings into precedent board (frontier
198 // hybrid findings aren't typed as Finding[] so we cover the
199 // local half — the more reusable pattern source anyway).
200 if (hybridBoard) {
201 try {
202 const findings = localResultToFindings(hybridResult.localResult);
203 if (findings.length > 0) {
204 const docType = registry.getDocument(documentHash)?.type ?? hybridResult.localResult.documentType;
205 const indexed = hybridBoard.indexFindings(documentHash, docType, profile.jurisdiction, findings);
206 if (indexed > 0) {
207 clawEventBus.emitEvent({ type: 'claw_precedent_indexed', precedentId: documentHash, patternName: docType, documentType: docType, timestamp: eventTimestamp() });
208 }
209 }
210 } catch (indexErr) {
211 logger.warn('Precedent indexing (hybrid) failed (non-fatal)', { sessionId, error: indexErr });
212 }
213 }
214
215 const durationMs = Date.now() - startTime;
216 log(`🔒🌐 Delivered (hybrid) → ${path.relative(clawConfig.dir, deliveryDir)}/`);
217 log(` $${hybridResult.cost.totalUsd.toFixed(2)} · ${(durationMs / 1000).toFixed(0)}s · ${hybridResult.frontierClauseCount}/${hybridResult.totalClauseCount} clauses sent to frontier`);
218 log(` ${hybridFindings.critical} critical, ${hybridFindings.major} major, ${hybridFindings.minor} minor`);
219
220 if (hybridFindings.critical > 0) {
221 notify({
222 type: 'document_flagged',
223 title: `🔒🌐 Critical findings (hybrid): ${path.basename(documentPath)}`,
224 message: `${hybridFindings.critical} critical, ${hybridFindings.major} major — hybrid analysis (${hybridResult.entityCount} entities anonymized)`,
225 details: { documentPath, sessionId, findings: hybridFindings, confidential: true, processing: 'hybrid' },
226 });
227 }
228
229 clawEventBus.emitEvent({ type: 'claw_job_completed', documentPath, documentHash, costUsd: hybridResult.cost.totalUsd, durationMs, findings: hybridFindings, timestamp: eventTimestamp() });
230
231 return {
232 sessionId, documentPath, documentHash,
233 success: true, costUsd: hybridResult.cost.totalUsd, durationMs, findings: hybridFindings, deliveryDir,
234 };
235 } catch (err) {
236 const error = err instanceof Error ? err.message : String(err);
237 log(`🔒🌐 Hybrid failed, falling back to local-only: ${error}`);
238 // Fall through to local-only path below
239 }
240 }
241
242 // ── LOCAL-ONLY PATH ──────────────────────────────────────────
243 log(`🔒 Confidential — processing locally (${localModelName})`);
244
245 try {
246 const localBoard = (() => {
247 try { return getPrecedentBoard(clawConfig.dir); } catch { return undefined; }
248 })();
249 const localResult = await analyzeLocally(parsed.fullText, path.basename(documentPath), profile, log, {
250 watchman,
251 precedentBoard: localBoard,
252 });
253 const localFindings = extractLocalFindings(localResult);
254 const deliveryDir = await delivery.deliverLocal(
255 sessionId, localResult, documentPath, documentHash, clawConfig,
256 );
257
258 registry.markReviewed(documentHash, sessionId, localFindings, 0, true); // $0 — local inference, confidential
259
260 // Index findings into the precedent board so institutional memory
261 // builds up for local-only users (parity with the frontier path).
262 if (localBoard) {
263 try {
264 const findings = localResultToFindings(localResult);
265 if (findings.length > 0) {
266 const docType = registry.getDocument(documentHash)?.type ?? localResult.documentType;
267 const indexed = localBoard.indexFindings(documentHash, docType, profile.jurisdiction, findings);
268 if (indexed > 0) {
269 clawEventBus.emitEvent({ type: 'claw_precedent_indexed', precedentId: documentHash, patternName: docType, documentType: docType, timestamp: eventTimestamp() });
270 }
271 }
272 } catch (indexErr) {
273 logger.warn('Precedent indexing (local) failed (non-fatal)', { sessionId, error: indexErr });
274 }
275 }
276
277 const durationMs = Date.now() - startTime;
278 log(`🔒 Delivered (local) → ${path.relative(clawConfig.dir, deliveryDir)}/`);
279 log(` $0.00 · ${(durationMs / 1000).toFixed(0)}s · ${localFindings.critical} critical, ${localFindings.major} major, ${localFindings.minor} minor`);
280
281 if (localFindings.critical > 0) {
282 notify({
283 type: 'document_flagged',
284 title: `🔒 Critical findings (local): ${path.basename(documentPath)}`,
285 message: `${localFindings.critical} critical, ${localFindings.major} major — analyzed on-device`,
286 details: { documentPath, sessionId, findings: localFindings, confidential: true },
287 });
288 }
289
290 clawEventBus.emitEvent({ type: 'claw_job_completed', documentPath, documentHash, costUsd: 0, durationMs, findings: localFindings, timestamp: eventTimestamp() });
291
292 return {
293 sessionId, documentPath, documentHash,
294 success: true, costUsd: 0, durationMs, findings: localFindings, deliveryDir,
295 };
296 } catch (err) {
297 const error = err instanceof Error ? err.message : String(err);
298 log(`🔒 Local analysis failed: ${error}`);
299 // Don't fall through to frontier — confidential documents MUST NOT leave the machine
300 registry.markFailed(documentHash, `Local analysis failed: ${error}`);
301 clawEventBus.emitEvent({ type: 'claw_job_failed', documentPath, documentHash, error, timestamp: eventTimestamp() });
302 notify({
303 type: 'document_failed',
304 title: `🔒 Local analysis failed: ${path.basename(documentPath)}`,
305 message: `${error.slice(0, 200)} — document NOT sent to API (privileged)`,
306 details: { documentPath, documentHash, sessionId, confidential: true },
307 });
308 return {
309 sessionId, documentPath, documentHash,
310 success: false, costUsd: 0, durationMs: Date.now() - startTime,
311 findings: { critical: 0, major: 0, minor: 0 }, deliveryDir: '',
312 error: `Local analysis failed (confidential document): ${error}`,
313 };
314 }
315 }
316
317 // ── 2. INFER ──────────────────────────────────────────────────────
318 // Watchman already classified the document. inferTask consumes that
319 // result and skips its own LLM call (lighthouse: one triage call, not two).
320 log(`Inferring task...`);
321 const inference = await inferTask(documentPath, parsed.fullText, profile, watchman);
322 log(`${inference.request.type}${inference.method} (${inference.reasoning.slice(0, 80)})`);
323
324 // ── 2b. PRECEDENT LOOKUP ─────────────────────────────────────────
325 const board = getPrecedentBoard(clawConfig.dir);
326 try {
327 const precedentMatches = board.search({
328 documentType: inference.request.type,
329 jurisdiction: profile.jurisdiction,
330 limit: 5,
331 });
332
333 if (precedentMatches.length > 0) {
334 // Sanitize precedent context before injection: strip control chars, cap length
335 const precedentLines = precedentMatches.map(m => {
336 const desc = m.entry.description.replace(/[\x00-\x1f]/g, ' ').slice(0, 200);
337 return `[Precedent ${m.entry.id}] ${m.entry.patternName}: ${desc} (seen ${m.entry.timesUsed}x, effectiveness: ${(m.entry.effectivenessScore * 100).toFixed(0)}%)`;
338 });
339 // Cap total injected context to prevent prompt bloat
340 let context = '';
341 for (const line of precedentLines) {
342 if (context.length + line.length > 1000) break;
343 context += (context ? '\n' : '') + line;
344 }
345 inference.request.requestText = (inference.request.requestText ?? '')
346 + `\n\n--- Precedent Context (from prior engagements) ---\n${context}`
347 + `\n\nIMPORTANT: Precedents are advisory context from prior reviews. The live source document ALWAYS outranks stored precedent. If a precedent says "this clause type is standard" but the actual document contains non-standard language, trust the document. Precedent informs — it does not override.`;
348 log(`Found ${precedentMatches.length} relevant precedent(s)`);
349
350 if (precedentMatches[0].relevanceScore > 0.8) {
351 notify({
352 type: 'precedent_match',
353 title: `Precedent match: ${precedentMatches[0].entry.patternName}`,
354 message: `Strong match (${(precedentMatches[0].relevanceScore * 100).toFixed(0)}%) found for ${path.basename(documentPath)}`,
355 details: { documentPath, precedentId: precedentMatches[0].entry.id },
356 });
357 }
358 }
359 } catch (precErr) {
360 logger.warn('Precedent lookup failed (non-fatal)', { error: precErr });
361 }
362
363 // ── 3. DISPATCH ───────────────────────────────────────────────────
364 log(`Dispatching: ${inference.workflow ?? 'auto-route'}`);
365
366 // yoloMode: Claw Mode is a fully autonomous retainer — no human is present
367 // during batch processing. Gates are auto-approved via AutoApproveGateResolver.
368 // Human review happens post-hoc via the dashboard or delivery bundles.
369 const dispatchOptions: DispatchOptions = {
370 yoloMode: true,
371 gateResolver: new AutoApproveGateResolver(),
372 maxBudgetUsd: clawConfig.perDocBudget,
373 intensity: inference.intensity,
374 forceWorkflow: inference.workflow,
375 // Ethical mode: use EU provider even for non-confidential docs
376 ...(clawConfig.ethicalMode ? { provider: 'mistral' as const } : {}),
377 };
378
379 if (clawConfig.dryRun) {
380 log(`[DRY RUN] Would dispatch ${inference.request.type} for ${path.basename(documentPath)}`);
381 return {
382 sessionId,
383 documentPath,
384 documentHash,
385 success: true,
386 costUsd: 0,
387 durationMs: Date.now() - startTime,
388 findings: { critical: 0, major: 0, minor: 0 },
389 deliveryDir: '',
390 };
391 }
392
393 let session: Awaited<ReturnType<typeof dispatch>>;
394 let retried = false;
395
396 try {
397 session = await dispatch(inference.request, dispatchOptions);
398 } catch (dispatchErr) {
399 const dispatchError = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
400 const isBudgetError = /budget|funds|exhausted/i.test(dispatchError);
401
402 if (isBudgetError) {
403 throw dispatchErr; // No retry for budget exhaustion — rethrow to outer catch
404 }
405
406 // Retry once for transient failures
407 log(`⟳ Dispatch failed (${dispatchError}), retrying in 5s...`);
408 retried = true;
409 await new Promise(resolve => setTimeout(resolve, 5000));
410
411 try {
412 log(`⟳ Retry attempt for ${path.basename(documentPath)}...`);
413 session = await dispatch(inference.request, dispatchOptions);
414 log(`⟳ Retry succeeded`);
415 } catch (retryErr) {
416 log(`⟳ Retry also failed`);
417 throw retryErr; // Let outer catch handle final failure
418 }
419 }
420
421 // ── 4. GROUNDING VERIFICATION ──────────────────────────────────
422 // Run BEFORE delivery so grounding scores are included in findings.json
423 try {
424 const { verifyFindingEvidence } = await import('../mcp/tools/grounding-verifier.js');
425 const { flattenSections } = await import('../mcp/tools/document-reader.js');
426 // Use parsed document directly (not session.documents which may be empty in Claw)
427 const allText = parsed.fullText ?? '';
428 const allHeadings = parsed.sections
429 ? flattenSections(parsed.sections).map(s => s.heading ?? '')
430 : [];
431 for (const finding of session.debate.findings) {
432 const results = verifyFindingEvidence(finding.evidence, allText, allHeadings);
433 const avg = results.length > 0
434 ? results.reduce((s, r) => s + r.score, 0) / results.length
435 : 1.0;
436 finding.groundingScore = Math.round(avg * 100) / 100;
437 }
438 const avgGrounding = session.debate.findings.length > 0
439 ? session.debate.findings.reduce((s, f) => s + (f.groundingScore ?? 0), 0) / session.debate.findings.length
440 : 1.0;
441 log(` Grounding: avg ${(avgGrounding * 100).toFixed(0)}% across ${session.debate.findings.length} findings`);
442 } catch (groundingErr) {
443 logger.warn('Grounding verification failed (non-fatal)', { sessionId, error: groundingErr });
444 }
445
446 // ── 4b. DELIVER ─────────────────────────────────────────────────
447 log(`Delivering results...`);
448 const deliveryDir = await delivery.deliver(
449 sessionId,
450 session,
451 inference,
452 documentPath,
453 documentHash,
454 clawConfig,
455 );
456
457 // ── 5. UPDATE ─────────────────────────────────────────────────────
458 const cost = session.accumulatedCost;
459 const findings = extractSessionFindings(session);
460
461 // Capture previous session BEFORE markReviewed overwrites it
462 const previousSessionId = registry.getDocument(documentHash)?.lastReviewSession;
463
464 registry.markReviewed(documentHash, sessionId, findings, cost);
465
466 // ── 5b. CHANGE DETECTION ─────────────────────────────────────────
467 let diff: FindingsDiff | undefined;
468 if (previousSessionId && previousSessionId !== sessionId) {
469 try {
470 const previousFindings = loadPreviousFindings(clawConfig.dir, previousSessionId);
471 const currentFindings = (session.debate?.findings ?? []).map(f => ({
472 id: f.id,
473 category: f.findingType,
474 severity: f.severity,
475 content: f.content,
476 evidence: f.evidence,
477 }));
478 if (previousFindings.length > 0 || currentFindings.length > 0) {
479 diff = computeDiff(previousFindings, currentFindings, previousSessionId);
480 const ds = diffSummary(diff);
481 log(` Δ ${ds.added} new, ${ds.resolved} resolved, ${ds.changed} changed`);
482
483 // Write diff.json + update manifest with summary
484 try {
485 writeJsonFileAtomic(path.join(deliveryDir, 'diff.json'), diff);
486 const manifestPath = path.join(deliveryDir, 'manifest.json');
487 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
488 manifest.diff = ds;
489 writeJsonFileAtomic(manifestPath, manifest);
490 } catch { /* non-fatal — diff data optional */ }
491 }
492 } catch (diffErr) {
493 logger.warn('Change detection failed (non-fatal)', { sessionId, error: diffErr });
494 captureError(diffErr, { sessionId, phase: 'claw_change_detection' });
495 }
496 }
497
498 // Index findings into precedent board
499 const fullFindings = Array.isArray(session.debate?.findings) ? session.debate.findings : [];
500 if (fullFindings.length > 0) {
501 try {
502 const docEntry = registry.getDocument(documentHash);
503 const docType = docEntry?.type ?? inference.request.type;
504 const indexed = board.indexFindings(
505 documentHash,
506 docType,
507 profile.jurisdiction,
508 fullFindings,
509 );
510 if (indexed > 0) {
511 clawEventBus.emitEvent({ type: 'claw_precedent_indexed', precedentId: documentHash, patternName: docType, documentType: docType, timestamp: eventTimestamp() });
512 }
513 } catch (indexErr) {
514 logger.warn('Precedent indexing failed (non-fatal)', { sessionId, error: indexErr });
515 captureError(indexErr, { sessionId, phase: 'claw_precedent_indexing' });
516 }
517 }
518
519 // Notify on critical findings
520 if (findings.critical > 0) {
521 notify({
522 type: 'document_flagged',
523 title: `Critical findings: ${path.basename(documentPath)}`,
524 message: `${findings.critical} critical, ${findings.major} major, ${findings.minor} minor`,
525 details: { documentPath, sessionId, findings },
526 });
527 }
528
529 const durationMs = Date.now() - startTime;
530 clawEventBus.emitEvent({ type: 'claw_job_completed', documentPath, documentHash, costUsd: cost, durationMs, findings, timestamp: eventTimestamp() });
531 log(`✓ Delivered → ${path.relative(clawConfig.dir, deliveryDir)}/`);
532 log(` $${cost.toFixed(2)} · ${(durationMs / 1000).toFixed(0)}s · ${findings.critical} critical, ${findings.major} major, ${findings.minor} minor`);
533 if (retried) log(` (succeeded on retry)`);
534
535 return {
536 sessionId,
537 documentPath,
538 documentHash,
539 success: true,
540 costUsd: cost,
541 durationMs,
542 findings,
543 deliveryDir,
544 };
545 } catch (err) {
546 const error = err instanceof Error ? err.message : String(err);
547 log(`✗ Failed: ${error}`);
548
549 registry.markFailed(documentHash, error);
550 clawEventBus.emitEvent({ type: 'claw_job_failed', documentPath, documentHash, error, timestamp: eventTimestamp() });
551
552 notify({
553 type: 'document_failed',
554 title: `Failed: ${path.basename(documentPath)}`,
555 message: error.slice(0, 200),
556 details: { documentPath, documentHash, sessionId },
557 });
558
559 // Save partial results to failed/
560 try {
561 delivery.saveFailed(sessionId, documentPath, error, clawConfig.dir);
562 } catch (deliveryErr) {
563 logger.error('Failed to save failure record', { document: path.basename(documentPath), error: deliveryErr });
564 }
565
566 return {
567 sessionId,
568 documentPath,
569 documentHash,
570 success: false,
571 costUsd: 0,
572 durationMs: Date.now() - startTime,
573 findings: { critical: 0, major: 0, minor: 0 },
574 deliveryDir: '',
575 error,
576 };
577 }
578}
579
580