fix(bd-vqd): Align NeedleLogEntry and parser with NEEDLE telemetry format

- NeedleLogEntry.worker: accept string | NeedleWorkerObject (NEEDLE emits
  flat runner-provider-model-identifier strings)
- Add optional level field to NeedleLogEntry (NEEDLE always includes it)
- parseNeedleFormat: reconstruct worker as runner-provider-model-identifier
  from legacy object form (was missing provider and model)
- parseNeedleFormat: gate provider/model extraction on object form (was
  accessing .provider/.model unconditionally, yielding undefined)
- parseNeedleFormat: prefer entry.level over inferred level
- inferLogLevel: match NEEDLE's _needle_telemetry_infer_level rules exactly
  (prefix/suffix matching: error.*, *.failed, *.retry, debug.*)
- Add NeedleEventType, NeedleWorkerStatus types to types.ts
- Add session/provider/model optional fields to LogEvent
- Fix store.ts status detection: match on exact NEEDLE event types instead
  of substring-based heuristics
- Update all tests to match corrected behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-16 22:39:04 -04:00
parent 68005c4578
commit 374be30dac
6 changed files with 246 additions and 129 deletions

View file

@ -33,7 +33,7 @@ describe('NEEDLE-FABRIC Integration', () => {
expect(result).not.toBeNull();
expect(result?.ts).toBe(new Date('2026-03-04T16:17:34.008Z').getTime());
expect(result?.worker).toBe('claude-test12');
expect(result?.worker).toBe('claude-anthropic-sonnet-test12');
expect(result?.level).toBe('info');
expect(result?.msg).toBe('worker.started');
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
@ -153,9 +153,11 @@ describe('NEEDLE-FABRIC Integration', () => {
});
it('should parse bead.claim_retry event with warn level', () => {
// NEEDLE emits bead.claim_retry with explicit level: "warn"
const log = JSON.stringify({
ts: '2026-03-04T19:37:22.192Z',
event: 'bead.claim_retry',
level: 'warn',
session: 'forge-glm-test',
worker: {
runner: 'claude',
@ -313,19 +315,15 @@ describe('NEEDLE-FABRIC Integration', () => {
});
describe('error events', () => {
it('should infer error level for events with "error" in name', () => {
it('should parse error.* events with error level (explicit in NEEDLE output)', () => {
// NEEDLE emits error.* events with explicit level: "error"
const log = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'bead.error',
event: 'error.agent_crash',
level: 'error',
session: 'test-session',
worker: {
runner: 'claude',
provider: 'code',
model: 'sonnet',
identifier: 'test',
},
worker: 'claude-code-sonnet-test',
data: {
bead_id: 'bd-abc',
error: 'Failed to process bead',
},
});
@ -334,14 +332,16 @@ describe('NEEDLE-FABRIC Integration', () => {
expect(result).not.toBeNull();
expect(result?.level).toBe('error');
expect(result?.msg).toBe('bead.error');
expect(result?.msg).toBe('error.agent_crash');
expect(result?.error).toBe('Failed to process bead');
});
it('should infer error level for events with "fail" in name', () => {
it('should parse bead.failed with error level (explicit in NEEDLE output)', () => {
// NEEDLE emits bead.failed with explicit level: "error"
const log = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'bead.failed',
level: 'error',
session: 'test-session',
worker: 'claude-test',
data: {
@ -356,20 +356,22 @@ describe('NEEDLE-FABRIC Integration', () => {
expect(result?.msg).toBe('bead.failed');
});
it('should infer error level for queue.exhausted events', () => {
it('should parse bead.claim_exhausted with error level (explicit in NEEDLE output)', () => {
// NEEDLE emits bead.claim_exhausted with explicit level: "error"
const log = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'queue.exhausted',
event: 'bead.claim_exhausted',
level: 'error',
session: 'test-session',
worker: 'claude-test',
data: {},
data: { max_retries: 5 },
});
const result = parseLogLine(log);
expect(result).not.toBeNull();
expect(result?.level).toBe('error');
expect(result?.msg).toBe('queue.exhausted');
expect(result?.msg).toBe('bead.claim_exhausted');
});
});
@ -388,7 +390,7 @@ describe('NEEDLE-FABRIC Integration', () => {
// Verify first event
expect(results[0].msg).toBe('worker.started');
expect(results[0].worker).toBe('claude-w1');
expect(results[0].worker).toBe('claude-code-sonnet-w1');
expect(results[0].pid).toBe(123);
// Verify second event
@ -405,7 +407,7 @@ describe('NEEDLE-FABRIC Integration', () => {
expect(results[3].consecutive_empty).toBe(1);
// All events should have same worker
expect(results.every((r) => r.worker === 'claude-w1')).toBe(true);
expect(results.every((r) => r.worker === 'claude-code-sonnet-w1')).toBe(true);
// All events should have same session
expect(results.every((r) => r.session === 'test')).toBe(true);
@ -573,7 +575,7 @@ describe('NEEDLE-FABRIC Integration', () => {
expect(events).toHaveLength(7);
// Verify all events have correct worker
expect(events.every((e) => e.worker === 'claude-test')).toBe(true);
expect(events.every((e) => e.worker === 'claude-code-glm-4.7-test')).toBe(true);
// Verify all events have correct session
expect(events.every((e) => e.session === 'forge-glm-test')).toBe(true);

View file

@ -126,7 +126,7 @@ describe('parseLogLine', () => {
expect(result).not.toBeNull();
expect(result?.ts).toBe(1772641054008); // Unix ms from '2026-03-04T16:17:34.008Z'
expect(result?.worker).toBe('claude-test');
expect(result?.worker).toBe('claude-code-glm-4.7-test');
expect(result?.msg).toBe('worker.started');
expect(result?.level).toBe('info');
expect(result?.session).toBe('forge-glm-test');
@ -184,10 +184,11 @@ describe('parseLogLine', () => {
});
it('should infer error level from event name', () => {
// NEEDLE rule: events with error.* prefix -> error
const errorEvents = [
'bead.error',
'worker.failed',
'bead.claim_exhausted',
'error.claim_failed',
'error.agent_crash',
'error.timeout',
];
for (const eventName of errorEvents) {
@ -210,7 +211,8 @@ describe('parseLogLine', () => {
});
it('should infer warn level from event name', () => {
const warnEvents = ['bead.claim_retry', 'worker.warning'];
// NEEDLE rule: events with *.failed or *.retry suffix -> warn
const warnEvents = ['bead.failed', 'hook.failed'];
for (const eventName of warnEvents) {
const line = JSON.stringify({
@ -232,9 +234,10 @@ describe('parseLogLine', () => {
});
it('should infer debug level from event name', () => {
// NEEDLE rule: events with debug.* prefix -> debug
const line = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'worker.debug',
event: 'debug.probe',
session: 'test',
worker: {
runner: 'claude',
@ -304,7 +307,7 @@ describe('parseLogLine', () => {
expect(result?.workspace).toBe('/home/coder/test');
});
it('should flatten worker to runner-identifier format', () => {
it('should flatten worker to runner-provider-model-identifier format', () => {
const line = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'worker.started',
@ -320,7 +323,7 @@ describe('parseLogLine', () => {
const result = parseLogLine(line);
expect(result?.worker).toBe('needle-prod-worker-1');
expect(result?.worker).toBe('needle-anthropic-opus-prod-worker-1');
});
});
@ -1818,7 +1821,7 @@ describe('parseLogLine - NEEDLE format', () => {
expect(result).not.toBeNull();
expect(result?.ts).toBe(new Date('2026-03-04T16:17:34.008Z').getTime());
expect(result?.worker).toBe('claude-test12');
expect(result?.worker).toBe('claude-anthropic-sonnet-test12');
expect(result?.level).toBe('info');
expect(result?.msg).toBe('worker.started');
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
@ -1849,7 +1852,7 @@ describe('parseLogLine - NEEDLE format', () => {
const result = parseLogLine(line);
expect(result).not.toBeNull();
expect(result?.worker).toBe('claude-test');
expect(result?.worker).toBe('claude-code-glm-4.7-test');
expect(result?.provider).toBe('code');
expect(result?.model).toBe('glm-4.7');
expect(result?.session).toBe('forge-glm-test');
@ -1886,7 +1889,7 @@ describe('parseLogLine - NEEDLE format', () => {
expect(result?.msg).toBe('bead.claimed');
expect(result?.level).toBe('info');
expect(result?.bead).toBe('bd-2ok0');
expect(result?.worker).toBe('claude-test');
expect(result?.worker).toBe('claude-code-glm-4.7-test');
expect(result?.attempt).toBe(1);
expect(result?.actor).toBe('forge-glm-test');
});
@ -1952,10 +1955,11 @@ describe('parseLogLine - NEEDLE format', () => {
describe('bead.claim_retry event', () => {
it('should parse bead.claim_retry event with warn level', () => {
// Sample from ~/.needle/logs/forge-glm-test.log
// NEEDLE emits bead.claim_retry with explicit level: "warn"
const line = JSON.stringify({
ts: '2026-03-04T19:37:22.192Z',
event: 'bead.claim_retry',
level: 'warn',
session: 'forge-glm-test',
worker: {
runner: 'claude',
@ -1975,7 +1979,7 @@ describe('parseLogLine - NEEDLE format', () => {
expect(result).not.toBeNull();
expect(result?.msg).toBe('bead.claim_retry');
expect(result?.level).toBe('warn'); // 'retry' in event name triggers warn level
expect(result?.level).toBe('warn'); // NEEDLE emits this with explicit level: "warn"
expect(result?.bead).toBe('bd-e6jq');
expect(result?.attempt).toBe(1);
expect(result?.max_retries).toBe(5);
@ -1993,6 +1997,7 @@ describe('parseLogLine - NEEDLE format', () => {
const line = JSON.stringify({
ts: '2026-03-04T19:37:22.536Z',
event: 'bead.claim_retry',
level: 'warn',
session: 'forge-glm-test',
worker: {
runner: 'claude',
@ -2018,10 +2023,11 @@ describe('parseLogLine - NEEDLE format', () => {
describe('bead.claim_exhausted event', () => {
it('should parse bead.claim_exhausted event with error level', () => {
// Sample from ~/.needle/logs/forge-glm-test.log
// NEEDLE emits bead.claim_exhausted with explicit level: "error"
const line = JSON.stringify({
ts: '2026-03-04T19:37:23.647Z',
event: 'bead.claim_exhausted',
level: 'error',
session: 'forge-glm-test',
worker: {
runner: 'claude',
@ -2040,7 +2046,7 @@ describe('parseLogLine - NEEDLE format', () => {
expect(result).not.toBeNull();
expect(result?.msg).toBe('bead.claim_exhausted');
expect(result?.level).toBe('error'); // 'exhausted' in event name triggers error level
expect(result?.level).toBe('error'); // NEEDLE emits this with explicit level: "error"
expect(result?.max_retries).toBe(5);
});
});
@ -2070,7 +2076,7 @@ describe('parseLogLine - NEEDLE format', () => {
expect(result).not.toBeNull();
expect(result?.msg).toBe('heartbeat.emitted');
expect(result?.level).toBe('info');
expect(result?.worker).toBe('claude-test12');
expect(result?.worker).toBe('claude-anthropic-sonnet-test12');
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
expect(result?.uptime_seconds).toBe(3600);
expect(result?.beads_completed).toBe(5);
@ -2137,10 +2143,15 @@ describe('parseLogLine - NEEDLE format', () => {
});
describe('level inference from event names', () => {
it('should infer error level for events with "error"', () => {
// inferLogLevel is the fallback used when no explicit level field is present (legacy logs).
// NEEDLE always includes level explicitly; these tests verify the inference rules match
// NEEDLE's _needle_telemetry_infer_level: error.* -> error, *.failed/*.retry -> warn,
// debug.* -> debug, else -> info.
it('should infer error level for error.* prefix events', () => {
const line = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'worker.error',
event: 'error.agent_crash',
session: 'test-session',
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
data: {}
@ -2150,7 +2161,7 @@ describe('parseLogLine - NEEDLE format', () => {
expect(result?.level).toBe('error');
});
it('should infer error level for events with "fail"', () => {
it('should infer warn level for *.failed suffix events', () => {
const line = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'bead.failed',
@ -2160,13 +2171,13 @@ describe('parseLogLine - NEEDLE format', () => {
});
const result = parseLogLine(line);
expect(result?.level).toBe('error');
expect(result?.level).toBe('warn');
});
it('should infer warn level for events with "retry"', () => {
it('should infer warn level for *.retry suffix events', () => {
const line = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'bead.claim_retry',
event: 'claim.retry',
session: 'test-session',
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
data: {}
@ -2176,23 +2187,10 @@ describe('parseLogLine - NEEDLE format', () => {
expect(result?.level).toBe('warn');
});
it('should infer warn level for events with "warn"', () => {
it('should infer debug level for debug.* prefix events', () => {
const line = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'worker.warning',
session: 'test-session',
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
data: {}
});
const result = parseLogLine(line);
expect(result?.level).toBe('warn');
});
it('should infer debug level for events with "debug"', () => {
const line = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'worker.debug',
event: 'debug.probe',
session: 'test-session',
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
data: {}
@ -2214,6 +2212,22 @@ describe('parseLogLine - NEEDLE format', () => {
const result = parseLogLine(line);
expect(result?.level).toBe('info');
});
it('should use explicit level field when present rather than inferring', () => {
// bead.claim_retry would infer info (ends with _retry not .retry) but NEEDLE
// always emits it with explicit level: "warn"
const line = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'bead.claim_retry',
level: 'warn',
session: 'test-session',
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
data: {}
});
const result = parseLogLine(line);
expect(result?.level).toBe('warn');
});
});
describe('timestamp conversion', () => {
@ -2248,7 +2262,7 @@ describe('parseLogLine - NEEDLE format', () => {
});
describe('worker identifier flattening', () => {
it('should flatten worker object to runner-identifier format', () => {
it('should flatten worker object to runner-provider-model-identifier format', () => {
const line = JSON.stringify({
ts: '2026-03-04T16:17:34.008Z',
event: 'worker.started',
@ -2258,7 +2272,7 @@ describe('parseLogLine - NEEDLE format', () => {
});
const result = parseLogLine(line);
expect(result?.worker).toBe('claude-prod');
expect(result?.worker).toBe('claude-anthropic-opus-prod');
});
it('should preserve provider and model as separate fields', () => {
@ -2296,7 +2310,7 @@ describe('parseLogLine - NEEDLE format', () => {
const needleResult = parseLogLine(needleLine);
const legacyResult = parseLogLine(legacyLine);
expect(needleResult?.worker).toBe('claude-test');
expect(needleResult?.worker).toBe('claude-code-sonnet-test');
expect(needleResult?.msg).toBe('worker.started');
expect(legacyResult?.worker).toBe('w-legacy');

View file

@ -85,19 +85,28 @@ export function parseLogLine(line: string): LogEvent | null {
}
/**
* NEEDLE log format interface
* NEEDLE worker object legacy format only, present in some tests.
* Production NEEDLE emits worker as a flat string: runner-provider-model-identifier.
*/
interface NeedleWorkerObject {
runner: string; // e.g., "claude"
provider: string; // e.g., "anthropic", "openai"
model: string; // e.g., "sonnet", "gpt-4o"
identifier: string; // e.g., "alpha", "bravo"
}
/**
* NEEDLE log format interface.
* worker is a flat string in current NEEDLE output (runner-provider-model-identifier).
* The object form is retained for backward compat with legacy test fixtures.
*/
interface NeedleLogEntry {
ts: string; // ISO 8601 timestamp
event: string; // Event type (e.g., "worker.started", "bead.claimed")
session: string; // Session identifier
worker: {
runner: string; // e.g., "claude"
provider: string; // e.g., "code", "anthropic"
model: string; // e.g., "glm-4.7", "sonnet"
identifier: string; // e.g., "test", "align"
};
data: Record<string, unknown>; // Event-specific payload
ts: string; // ISO 8601 timestamp
event: string; // Event type (e.g., "worker.started", "bead.claimed")
level?: string; // Log level — always present in current NEEDLE output
session: string; // Session identifier
worker: string | NeedleWorkerObject; // Flat string in production; object in legacy fixtures
data: Record<string, unknown>; // Event-specific payload
}
/**
@ -125,16 +134,16 @@ function parseNeedleFormat(entry: NeedleLogEntry): LogEvent {
// Convert ISO timestamp to Unix milliseconds
const ts = new Date(entry.ts).getTime();
// Handle worker as string (aligned format) or object (legacy)
// Handle worker as string (current NEEDLE format) or object (legacy test fixtures)
const worker = typeof entry.worker === 'string'
? entry.worker
: `${entry.worker.runner}-${entry.worker.identifier}`;
: `${entry.worker.runner}-${entry.worker.provider}-${entry.worker.model}-${entry.worker.identifier}`;
// Use event as message
// Use event type as message
const msg = entry.event;
// Infer log level from event name
const level = inferLogLevel(entry.event);
// Use the level NEEDLE provides; fall back to inference only for legacy entries without it
const level = isValidLogLevel(entry.level) ? entry.level as LogLevel : inferLogLevel(entry.event);
// Build LogEvent
const event: LogEvent = {
@ -172,10 +181,12 @@ function parseNeedleFormat(entry: NeedleLogEntry): LogEvent {
event.path = data.path;
}
// Copy session and other NEEDLE-specific fields
// Copy session and, when available from the object form, provider/model
event.session = entry.session;
event.provider = entry.worker.provider;
event.model = entry.worker.model;
if (typeof entry.worker !== 'string') {
event.provider = entry.worker.provider;
event.model = entry.worker.model;
}
// Copy remaining data fields (excluding already extracted ones)
const extractedFields = ['bead_id', 'duration_ms', 'error', 'tool', 'path'];
@ -189,33 +200,22 @@ function parseNeedleFormat(entry: NeedleLogEntry): LogEvent {
}
/**
* Infer log level from event name
* Infer log level from event name.
*
* Maps NEEDLE event types to log levels:
* - error: events containing "error", "fail", "exhausted"
* - warn: events containing "retry", "warn"
* - debug: events containing "debug"
* - info: everything else
* Mirrors NEEDLE's _needle_telemetry_infer_level rules exactly:
* error.* error
* *.failed warn
* *.retry warn
* debug.* debug
* everything else info
*
* This is only used as a fallback when the event's level field is absent
* (legacy log entries). Current NEEDLE always includes level explicitly.
*/
function inferLogLevel(eventName: string): LogLevel {
const lower = eventName.toLowerCase();
// Error-level events
if (lower.includes('error') || lower.includes('fail') || lower.includes('exhausted')) {
return 'error';
}
// Warn-level events
if (lower.includes('retry') || lower.includes('warn')) {
return 'warn';
}
// Debug-level events
if (lower.includes('debug')) {
return 'debug';
}
// Default to info
if (eventName.startsWith('error.')) return 'error';
if (eventName.endsWith('.failed') || eventName.endsWith('.retry')) return 'warn';
if (eventName.startsWith('debug.')) return 'debug';
return 'info';
}

View file

@ -168,33 +168,33 @@ describe('InMemoryEventStore', () => {
expect(worker?.status).toBe('error');
});
it('should set status to idle on completed message', () => {
store.add(createEvent({ worker: 'w-test', msg: 'Task completed successfully' }));
it('should set status to idle on bead.completed event', () => {
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed' }));
const worker = store.getWorker('w-test');
expect(worker?.status).toBe('idle');
});
it('should set status to idle on complete message', () => {
store.add(createEvent({ worker: 'w-test', msg: 'Task complete' }));
it('should set status to idle on worker.idle event', () => {
store.add(createEvent({ worker: 'w-test', msg: 'worker.idle' }));
const worker = store.getWorker('w-test');
expect(worker?.status).toBe('idle');
});
it('should set status to active on Starting message', () => {
it('should set status to active on worker.started event', () => {
// First make it idle
store.add(createEvent({ worker: 'w-test', msg: 'Task completed' }));
// Then starting
store.add(createEvent({ worker: 'w-test', msg: 'Starting new task' }));
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed' }));
// Then active
store.add(createEvent({ worker: 'w-test', msg: 'worker.started' }));
const worker = store.getWorker('w-test');
expect(worker?.status).toBe('active');
});
it('should increment beadsCompleted when task completes with bead', () => {
store.add(createEvent({ worker: 'w-test', msg: 'Task completed', bead: 'bd-1' }));
store.add(createEvent({ worker: 'w-test', msg: 'Task completed', bead: 'bd-2' }));
it('should increment beadsCompleted when bead.completed event has bead', () => {
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed', bead: 'bd-1' }));
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed', bead: 'bd-2' }));
const worker = store.getWorker('w-test');
expect(worker?.beadsCompleted).toBe(2);

View file

@ -411,18 +411,30 @@ export class InMemoryEventStore implements EventStore {
}
}
// Update status based on event
// Update status based on NEEDLE event type (event.msg holds the event type string)
const needleEvent = event.msg;
if (event.level === 'error') {
worker.status = 'error';
} else if (event.msg.includes('completed') || event.msg.includes('complete')) {
} else if (
needleEvent === 'bead.completed' ||
needleEvent === 'worker.idle' ||
needleEvent === 'worker.stopped' ||
needleEvent === 'worker.draining'
) {
worker.status = 'idle';
if (event.bead) {
if (needleEvent === 'bead.completed' && event.bead) {
worker.beadsCompleted++;
}
// Clear active files and bead on completion
worker.activeFiles = [];
worker.activeBead = undefined;
} else if (event.msg.includes('Starting') || event.msg.includes('starting')) {
if (needleEvent === 'bead.completed') {
worker.activeFiles = [];
worker.activeBead = undefined;
}
} else if (
needleEvent === 'worker.started' ||
needleEvent === 'bead.claimed' ||
needleEvent === 'bead.agent_started' ||
needleEvent === 'execution.started'
) {
worker.status = 'active';
}
@ -455,15 +467,9 @@ export class InMemoryEventStore implements EventStore {
this.taskStartTimes.set(beadId, event.ts);
}
// Check for task completion
const msg = event.msg?.toLowerCase() || '';
if (
msg.includes('completed') ||
msg.includes('finished') ||
msg.includes('done') ||
msg.includes('success') ||
msg.includes('closed')
) {
// Check for task completion — match on NEEDLE event type exactly
const msg = event.msg || '';
if (msg === 'bead.completed' || msg === 'bead.failed') {
const startTime = this.taskStartTimes.get(beadId);
if (startTime) {
const durationMs = event.ts - startTime;

View file

@ -8,6 +8,92 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type WorkerStatus = 'active' | 'idle' | 'error';
/**
* NEEDLE worker status values as emitted in heartbeat files and worker.* events.
* FABRIC maps these to the simpler WorkerStatus for display.
*/
export type NeedleWorkerStatus = 'idle' | 'executing' | 'draining' | 'starting';
/**
* All event types emitted by NEEDLE's telemetry pipeline.
* Format: category.action matches NEEDLE's _needle_telemetry_emit event_type argument.
*/
export type NeedleEventType =
// Worker lifecycle
| 'worker.started'
| 'worker.idle'
| 'worker.stopped'
| 'worker.draining'
// Bead lifecycle
| 'bead.claimed'
| 'bead.prompt_built'
| 'bead.agent_started'
| 'bead.agent_completed'
| 'bead.completed'
| 'bead.failed'
| 'bead.released'
| 'bead.claim_retry'
| 'bead.claim_exhausted'
// Bead mitosis
| 'bead.mitosis.check'
| 'bead.mitosis.started'
| 'bead.mitosis.child_created'
| 'bead.mitosis.complete'
| 'bead.mitosis.failed'
| 'bead.mitosis.skipped'
// Strand lifecycle
| 'strand.started'
| 'strand.completed'
| 'strand.fallthrough'
| 'strand.skipped'
// Hook lifecycle
| 'hook.started'
| 'hook.completed'
| 'hook.failed'
// Heartbeat
| 'heartbeat.emitted'
| 'heartbeat.stuck_detected'
| 'heartbeat.recovery'
// Mend (maintenance)
| 'mend.orphan_released'
| 'mend.heartbeat_cleaned'
| 'mend.logs_pruned'
| 'mend.completed'
// Unravel (alternatives)
| 'unravel.alternatives_created'
| 'unravel.alternative_created'
| 'unravel.analysis_started'
| 'unravel.analysis_completed'
// Weave (documentation gaps)
| 'weave.bead_created'
| 'weave.analysis_started'
| 'weave.analysis_completed'
// Pulse (health monitoring)
| 'pulse.bead_created'
| 'pulse.scan_started'
| 'pulse.scan_completed'
| 'pulse.issue_detected'
| 'pulse.detector_started'
| 'pulse.detector_completed'
// Error events
| 'error.claim_failed'
| 'error.agent_crash'
| 'error.timeout'
| 'error.release_failed'
// Effort & budget
| 'effort.recorded'
| 'budget.warning'
| 'budget.exceeded'
| 'budget.per_bead_exceeded'
// File locks
| 'file.checkout'
| 'file.conflict'
| 'file.release'
| 'file.stale'
| 'lock.priority_bump'
| 'lock.priority_bump_received'
| 'lock.expired';
// ============================================
// Conversation Event Types
// ============================================
@ -258,6 +344,15 @@ export interface LogEvent {
/** Optional: Error details */
error?: string;
/** NEEDLE session identifier (e.g. 'needle-claude-anthropic-sonnet-alpha') */
session?: string;
/** AI provider extracted from NEEDLE worker string (e.g. 'anthropic', 'openai') */
provider?: string;
/** AI model identifier extracted from NEEDLE worker string (e.g. 'sonnet', 'gpt-4o') */
model?: string;
/** Any additional fields */
[key: string]: unknown;
}