agentxchain 2.155.22 → 2.155.24

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.
package/README.md CHANGED
@@ -205,6 +205,7 @@ Partial coordinator artifacts are first-class here too: `audit` and `report` kee
205
205
  | `multi init\|status\|step\|resume\|approve-gate\|resync` | Run the multi-repo coordinator lifecycle, including blocked-state recovery via `multi resume` |
206
206
  | `intake record\|triage\|approve\|plan\|start\|scan\|resolve` | Continuous-delivery intake: turn delivery signals into governed work items |
207
207
  | `intake handoff` | Bridge a planned intake intent to a coordinator workstream for multi-repo execution |
208
+ | `watch --event-file\|--event-dir\|--listen\|--results\|--result` | Normalize external events into governed intake, poll event-file directories, receive signed HTTP webhooks, and inspect durable watch result records |
208
209
  | `schedule list\|run-due\|daemon\|status` | Run repo-local lights-out scheduling: inspect schedules, execute due runs, poll in a local daemon loop, continue explicitly unblocked schedule-owned runs, or check daemon heartbeat |
209
210
  | `plugin install\|list\|remove` | Install, inspect, or remove governed hook plugins under `.agentxchain/plugins/` |
210
211
  | `plugin list-available` | List bundled built-in plugins installable by short name |
@@ -248,8 +248,20 @@ generateCmd
248
248
 
249
249
  program
250
250
  .command('watch')
251
- .description('Watch lock.json and coordinate agent turns (the referee)')
251
+ .description('Watch lock.json, or ingest an external event into governed intake')
252
252
  .option('--daemon', 'Run in background mode')
253
+ .option('--event-file <path>', 'Normalize one external event JSON file into governed intake')
254
+ .option('--event-dir <path>', 'Poll a directory for external event JSON files')
255
+ .option('--poll-seconds <seconds>', 'With --event-dir, polling interval in seconds', '5')
256
+ .option('--dry-run', 'With --event-file, print the normalized intake payload without writing')
257
+ .option('--listen <port>', 'Start an HTTP webhook listener on the given port')
258
+ .option('--listen-host <host>', 'With --listen, bind to a specific host (default: 127.0.0.1)')
259
+ .option('--webhook-secret <secret>', 'With --listen, HMAC-SHA256 secret for signature verification')
260
+ .option('--allow-unsigned', 'With --listen, accept unsigned payloads (local dev only)')
261
+ .option('--results', 'List all watch result records')
262
+ .option('--result <id>', 'Show a single watch result by ID or filename')
263
+ .option('--limit <n>', 'With --results, limit the number of results shown')
264
+ .option('-j, --json', 'Output JSON')
253
265
  .action(watchCommand);
254
266
 
255
267
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.155.22",
3
+ "version": "2.155.24",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -79,6 +79,7 @@ ALLOWED_RELEASE_PATHS=(
79
79
  ".agentxchain-conformance/capabilities.json"
80
80
  "website-v2/docs/protocol-implementor-guide.mdx"
81
81
  ".planning/LAUNCH_EVIDENCE_REPORT.md"
82
+ ".planning/HN_LAUNCH_SURFACE_ALIGNMENT_SPEC.md"
82
83
  ".planning/SHOW_HN_DRAFT.md"
83
84
  ".planning/MARKETING/TWITTER_THREAD.md"
84
85
  ".planning/MARKETING/LINKEDIN_POST.md"
@@ -88,6 +89,7 @@ ALLOWED_RELEASE_PATHS=(
88
89
  "website-v2/docs/getting-started.mdx"
89
90
  "website-v2/docs/quickstart.mdx"
90
91
  "website-v2/docs/five-minute-tutorial.mdx"
92
+ "cli/test/hn-launch-surface-content.test.js"
91
93
  "cli/homebrew/agentxchain.rb"
92
94
  "cli/homebrew/README.md"
93
95
  )
@@ -1,19 +1,48 @@
1
- import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync } from 'fs';
2
- import { join, dirname } from 'path';
1
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync, mkdirSync, readdirSync, renameSync, statSync } from 'fs';
2
+ import { join, dirname, basename, resolve } from 'path';
3
3
  import { spawn } from 'child_process';
4
4
  import { fileURLToPath } from 'url';
5
5
  import chalk from 'chalk';
6
6
  import { loadConfig, loadLock, LOCK_FILE } from '../lib/config.js';
7
+ import { recordEvent, triageIntent, approveIntent, planIntent, startIntent } from '../lib/intake.js';
8
+ import { normalizeWatchEvent, resolveWatchRoute, writeWatchResult } from '../lib/watch-events.js';
7
9
  import { safeWriteJson } from '../lib/safe-write.js';
8
10
  import { notifyHuman as sendNotification } from '../lib/notify.js';
9
11
  import { validateProject } from '../lib/validation.js';
10
12
  import { resolveNextAgent, resolveExpectedClaimer } from '../lib/next-owner.js';
13
+ import { requireIntakeWorkspaceOrExit } from './intake-workspace.js';
14
+ import { startWebhookListener } from '../lib/watch-listener.js';
11
15
 
12
16
  const PID_FILE = '.agentxchain-watch.pid';
13
17
 
14
18
  export async function watchCommand(opts) {
19
+ if (opts.results || opts.result) {
20
+ listOrShowWatchResults(opts);
21
+ return;
22
+ }
23
+
24
+ if (opts.listen) {
25
+ await listenWebhook(opts);
26
+ return;
27
+ }
28
+
29
+ if (opts.eventFile) {
30
+ await ingestWatchEvent(opts);
31
+ return;
32
+ }
33
+
34
+ if (opts.daemon && opts.eventDir && process.env.AGENTXCHAIN_WATCH_DAEMON !== '1') {
35
+ startWatchDaemon(opts);
36
+ return;
37
+ }
38
+
39
+ if (opts.eventDir) {
40
+ await watchEventDirectory(opts);
41
+ return;
42
+ }
43
+
15
44
  if (opts.daemon && process.env.AGENTXCHAIN_WATCH_DAEMON !== '1') {
16
- startWatchDaemon();
45
+ startWatchDaemon(opts);
17
46
  return;
18
47
  }
19
48
 
@@ -153,6 +182,574 @@ export async function watchCommand(opts) {
153
182
  process.on('SIGTERM', cleanup);
154
183
  }
155
184
 
185
+ async function watchEventDirectory(opts) {
186
+ const root = requireIntakeWorkspaceOrExit(opts);
187
+ const eventDir = resolve(process.cwd(), opts.eventDir);
188
+ const pollMs = parsePollMs(opts.pollSeconds);
189
+ mkdirSync(eventDir, { recursive: true });
190
+ writePidFile(root);
191
+
192
+ console.log('');
193
+ console.log(chalk.bold(' AgentXchain Watch Event Directory'));
194
+ console.log(chalk.dim(` Event dir: ${eventDir}`));
195
+ console.log(chalk.dim(` Poll: ${pollMs}ms`));
196
+ console.log(chalk.dim(' Processed files move to processed/; failed files move to failed/.'));
197
+ console.log('');
198
+
199
+ let processing = false;
200
+ const tick = async () => {
201
+ if (processing) return;
202
+ processing = true;
203
+ try {
204
+ const results = await processPendingEventFiles(root, eventDir);
205
+ for (const result of results) {
206
+ if (result.ok) {
207
+ log('claimed', `Processed event file ${basename(result.source)} -> ${basename(result.destination)}`);
208
+ } else {
209
+ log('warn', `Failed event file ${basename(result.source)} -> ${basename(result.destination)}: ${result.error}`);
210
+ }
211
+ }
212
+ } catch (err) {
213
+ log('error', err.message);
214
+ } finally {
215
+ processing = false;
216
+ }
217
+ };
218
+
219
+ await tick();
220
+ const timer = setInterval(tick, pollMs);
221
+
222
+ const cleanup = () => {
223
+ clearInterval(timer);
224
+ removePidFile(root);
225
+ console.log('');
226
+ log('stop', 'Watch event directory stopped.');
227
+ process.exit(0);
228
+ };
229
+
230
+ process.on('SIGINT', cleanup);
231
+ process.on('SIGTERM', cleanup);
232
+ }
233
+
234
+ async function processPendingEventFiles(root, eventDir) {
235
+ const entries = readdirSync(eventDir, { withFileTypes: true })
236
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
237
+ .map((entry) => entry.name)
238
+ .sort();
239
+
240
+ const results = [];
241
+ for (const name of entries) {
242
+ const source = join(eventDir, name);
243
+ if (!existsSync(source)) continue;
244
+
245
+ const childResult = await runWatchEventFile(root, source);
246
+ const destinationDir = join(eventDir, childResult.status === 0 ? 'processed' : 'failed');
247
+ const destination = moveEventFile(source, destinationDir);
248
+ results.push({
249
+ ok: childResult.status === 0,
250
+ source,
251
+ destination,
252
+ error: childResult.status === 0 ? null : summarizeChildFailure(childResult),
253
+ });
254
+ }
255
+ return results;
256
+ }
257
+
258
+ const DEFAULT_CHILD_TIMEOUT_MS = 30_000;
259
+
260
+ function runWatchEventFile(root, eventFile, timeoutMs = DEFAULT_CHILD_TIMEOUT_MS) {
261
+ const currentDir = dirname(fileURLToPath(import.meta.url));
262
+ const cliBin = join(currentDir, '../../bin/agentxchain.js');
263
+ return new Promise((resolvePromise) => {
264
+ const child = spawn(process.execPath, [cliBin, 'watch', '--event-file', eventFile, '--json'], {
265
+ cwd: root,
266
+ env: { ...process.env, NO_COLOR: '1' },
267
+ stdio: ['ignore', 'pipe', 'pipe'],
268
+ });
269
+ let stdout = '';
270
+ let stderr = '';
271
+ let settled = false;
272
+ const finish = (status, signal) => {
273
+ if (settled) return;
274
+ settled = true;
275
+ clearTimeout(timer);
276
+ resolvePromise({ status: status ?? 1, signal, stdout, stderr });
277
+ };
278
+ const timer = setTimeout(() => {
279
+ if (settled) return;
280
+ try { child.kill('SIGTERM'); } catch {}
281
+ stderr += `\nchild process timed out after ${timeoutMs}ms`;
282
+ finish(1, 'SIGTERM');
283
+ }, timeoutMs);
284
+ child.stdout.on('data', (data) => { stdout += data.toString(); });
285
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
286
+ child.on('close', (status, signal) => finish(status, signal));
287
+ child.on('error', (err) => {
288
+ stderr += err.message;
289
+ finish(1, null);
290
+ });
291
+ });
292
+ }
293
+
294
+ function moveEventFile(source, destinationDir) {
295
+ mkdirSync(destinationDir, { recursive: true });
296
+ const parsed = basename(source);
297
+ let destination = join(destinationDir, parsed);
298
+ if (existsSync(destination)) {
299
+ const suffix = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
300
+ destination = join(destinationDir, `${parsed}.${suffix}`);
301
+ }
302
+ renameSync(source, destination);
303
+ return destination;
304
+ }
305
+
306
+ function summarizeChildFailure(result) {
307
+ try {
308
+ const parsed = JSON.parse(result.stdout);
309
+ if (parsed?.error) return parsed.error;
310
+ } catch {}
311
+ return result.stderr.trim() || result.stdout.trim() || `watch --event-file exited with status ${result.status}`;
312
+ }
313
+
314
+ function parsePollMs(value) {
315
+ const seconds = Number(value);
316
+ if (!Number.isFinite(seconds) || seconds <= 0) return 5000;
317
+ return Math.max(100, Math.round(seconds * 1000));
318
+ }
319
+
320
+ async function listenWebhook(opts) {
321
+ // Mutual exclusion checks
322
+ const incompatible = [
323
+ opts.eventFile && '--event-file',
324
+ opts.eventDir && '--event-dir',
325
+ opts.daemon && '--daemon',
326
+ (opts.results || opts.result) && '--results/--result',
327
+ ].filter(Boolean);
328
+
329
+ if (incompatible.length > 0) {
330
+ const message = `--listen cannot be combined with ${incompatible.join(', ')}`;
331
+ if (opts.json) {
332
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
333
+ } else {
334
+ console.log(chalk.red(` ${message}`));
335
+ }
336
+ process.exit(1);
337
+ }
338
+
339
+ const root = requireIntakeWorkspaceOrExit(opts);
340
+ const port = parseInt(opts.listen, 10);
341
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
342
+ const message = `invalid port: ${opts.listen}`;
343
+ if (opts.json) {
344
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
345
+ } else {
346
+ console.log(chalk.red(` ${message}`));
347
+ }
348
+ process.exit(1);
349
+ }
350
+
351
+ // Resolve webhook secret: CLI flag > env var > config
352
+ let secret = opts.webhookSecret || null;
353
+ if (!secret && process.env.AGENTXCHAIN_WEBHOOK_SECRET) {
354
+ secret = process.env.AGENTXCHAIN_WEBHOOK_SECRET;
355
+ }
356
+ if (!secret) {
357
+ try {
358
+ const rawConfig = JSON.parse(readFileSync(join(root, 'agentxchain.json'), 'utf8'));
359
+ secret = rawConfig?.watch?.webhook_secret || null;
360
+ } catch {}
361
+ }
362
+
363
+ const allowUnsigned = opts.allowUnsigned === true;
364
+ const host = opts.listenHost || '127.0.0.1';
365
+
366
+ try {
367
+ const server = await startWebhookListener({
368
+ root,
369
+ port,
370
+ host,
371
+ secret,
372
+ allowUnsigned,
373
+ dryRun: opts.dryRun === true,
374
+ onReady: ({ port: boundPort, host: boundHost }) => {
375
+ writePidFile(root);
376
+ console.log('');
377
+ console.log(chalk.bold(' AgentXchain Webhook Listener'));
378
+ console.log(chalk.dim(` Listening: http://${boundHost}:${boundPort}`));
379
+ console.log(chalk.dim(` Webhook: POST /webhook`));
380
+ console.log(chalk.dim(` Health: GET /health`));
381
+ console.log(chalk.dim(` Secret: ${secret ? 'configured' : allowUnsigned ? 'none (unsigned allowed)' : 'REQUIRED but missing — POST /webhook will return 403'}`));
382
+ if (opts.dryRun) console.log(chalk.yellow(' Dry-run: events will NOT be persisted'));
383
+ console.log('');
384
+ console.log(chalk.cyan(' Waiting for webhook deliveries... (Ctrl+C to stop)'));
385
+ console.log('');
386
+ },
387
+ });
388
+
389
+ const cleanup = () => {
390
+ server.close();
391
+ removePidFile(root);
392
+ console.log('');
393
+ log('stop', 'Webhook listener stopped.');
394
+ process.exit(0);
395
+ };
396
+ process.on('SIGINT', cleanup);
397
+ process.on('SIGTERM', cleanup);
398
+ } catch (err) {
399
+ if (err.code === 'EADDRINUSE') {
400
+ const message = `port ${port} is already in use`;
401
+ if (opts.json) {
402
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
403
+ } else {
404
+ console.log(chalk.red(` ${message}`));
405
+ }
406
+ } else {
407
+ console.log(chalk.red(` failed to start listener: ${err.message}`));
408
+ }
409
+ process.exit(1);
410
+ }
411
+ }
412
+
413
+ async function ingestWatchEvent(opts) {
414
+ if (opts.daemon) {
415
+ const message = '--daemon cannot be combined with --event-file';
416
+ if (opts.json) {
417
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
418
+ } else {
419
+ console.log(chalk.red(` ${message}`));
420
+ }
421
+ process.exit(1);
422
+ }
423
+
424
+ const root = requireIntakeWorkspaceOrExit(opts);
425
+ let raw;
426
+ let parsed;
427
+ try {
428
+ raw = readFileSync(opts.eventFile, 'utf8');
429
+ } catch (err) {
430
+ const message = `failed to read event file: ${err.message}`;
431
+ if (opts.json) {
432
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
433
+ } else {
434
+ console.log(chalk.red(` ${message}`));
435
+ }
436
+ process.exit(1);
437
+ }
438
+
439
+ try {
440
+ parsed = JSON.parse(raw);
441
+ } catch (err) {
442
+ const message = `event file is not valid JSON: ${err.message}`;
443
+ if (opts.json) {
444
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
445
+ } else {
446
+ console.log(chalk.red(` ${message}`));
447
+ }
448
+ process.exit(1);
449
+ }
450
+
451
+ let payload;
452
+ try {
453
+ payload = normalizeWatchEvent(parsed);
454
+ } catch (err) {
455
+ if (opts.json) {
456
+ console.log(JSON.stringify({ ok: false, error: err.message }, null, 2));
457
+ } else {
458
+ console.log(chalk.red(` ${err.message}`));
459
+ }
460
+ process.exit(1);
461
+ }
462
+
463
+ if (opts.dryRun) {
464
+ if (opts.json) {
465
+ console.log(JSON.stringify({ ok: true, dry_run: true, payload }, null, 2));
466
+ } else {
467
+ console.log('');
468
+ console.log(chalk.green(' Watch event normalized (dry run)'));
469
+ console.log(JSON.stringify(payload, null, 2));
470
+ console.log('');
471
+ }
472
+ process.exit(0);
473
+ }
474
+
475
+ const result = recordEvent(root, payload);
476
+ if (!result.ok) {
477
+ if (opts.json) {
478
+ console.log(JSON.stringify(result, null, 2));
479
+ } else {
480
+ console.log(chalk.red(` ${result.error}`));
481
+ }
482
+ process.exit(result.exitCode);
483
+ }
484
+
485
+ // Route-based auto-triage and auto-approve
486
+ let routed = null;
487
+ if (!result.deduplicated && result.intent) {
488
+ let routes;
489
+ try {
490
+ const rawConfig = JSON.parse(readFileSync(join(root, 'agentxchain.json'), 'utf8'));
491
+ routes = rawConfig?.watch?.routes;
492
+ } catch {
493
+ // non-fatal — no routes if config is unreadable
494
+ }
495
+ const resolved = resolveWatchRoute(payload, routes);
496
+ if (resolved) {
497
+ const triageFields = {
498
+ ...resolved.triage,
499
+ };
500
+ if (resolved.preferred_role) {
501
+ triageFields.preferred_role = resolved.preferred_role;
502
+ }
503
+ const triageResult = triageIntent(root, result.intent.intent_id, triageFields);
504
+ if (triageResult.ok) {
505
+ result.intent = triageResult.intent;
506
+ routed = { triaged: true, approved: false, preferred_role: resolved.preferred_role };
507
+ if (resolved.auto_approve) {
508
+ const approveResult = approveIntent(root, result.intent.intent_id, {
509
+ approver: 'watch_route',
510
+ reason: `auto-approved by watch route matching ${payload.category}`,
511
+ });
512
+ if (approveResult.ok) {
513
+ result.intent = approveResult.intent;
514
+ routed.approved = true;
515
+
516
+ // Auto-start: plan + start the governed run
517
+ if (resolved.auto_start) {
518
+ const planResult = planIntent(root, result.intent.intent_id, {
519
+ force: resolved.overwrite_planning_artifacts === true,
520
+ });
521
+ if (planResult.ok) {
522
+ result.intent = planResult.intent;
523
+ routed.planned = true;
524
+
525
+ const startResult = startIntent(root, result.intent.intent_id, {});
526
+ if (startResult.ok) {
527
+ result.intent = startResult.intent;
528
+ routed.started = true;
529
+ routed.run_id = startResult.run_id || null;
530
+ routed.role = startResult.role || null;
531
+ } else {
532
+ routed.started = false;
533
+ routed.auto_start_error = startResult.error;
534
+ }
535
+ } else {
536
+ routed.planned = false;
537
+ routed.started = false;
538
+ routed.auto_start_error = planResult.error;
539
+ }
540
+ }
541
+ }
542
+ } else if (resolved.auto_start) {
543
+ // auto_start without auto_approve — skip silently
544
+ routed.auto_start_skipped = 'requires auto_approve';
545
+ }
546
+ }
547
+ }
548
+ }
549
+
550
+ if (routed) {
551
+ result.routed = routed;
552
+ }
553
+
554
+ // Write durable watch result file
555
+ const watchResult = writeWatchResult(root, result, payload);
556
+ result.watch_result_id = watchResult.result_id;
557
+
558
+ if (opts.json) {
559
+ console.log(JSON.stringify(result, null, 2));
560
+ } else {
561
+ console.log('');
562
+ if (result.deduplicated) {
563
+ console.log(chalk.yellow(` Watch event already recorded: ${result.event.event_id} (deduplicated)`));
564
+ if (result.intent) {
565
+ console.log(chalk.dim(` Linked intent: ${result.intent.intent_id} (${result.intent.status})`));
566
+ }
567
+ } else {
568
+ console.log(chalk.green(` Recorded watch event ${result.event.event_id}`));
569
+ console.log(chalk.green(` Created intent ${result.intent.intent_id} (${result.intent.status})`));
570
+ if (routed) {
571
+ const parts = [`triaged=${routed.triaged}`, `approved=${routed.approved}`];
572
+ if (routed.preferred_role) parts.push(`role=${routed.preferred_role}`);
573
+ if (routed.planned) parts.push('planned=true');
574
+ if (routed.started) parts.push(`started=true`);
575
+ if (routed.auto_start_error) parts.push(`start_error="${routed.auto_start_error}"`);
576
+ if (routed.auto_start_skipped) parts.push(`auto_start_skipped`);
577
+ console.log(chalk.cyan(` Route matched: ${parts.join(', ')}`));
578
+ }
579
+ }
580
+ console.log('');
581
+ }
582
+ process.exit(result.exitCode);
583
+ }
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // Watch results inspection
587
+ // ---------------------------------------------------------------------------
588
+
589
+ function listOrShowWatchResults(opts) {
590
+ const root = requireIntakeWorkspaceOrExit(opts);
591
+ const resultsDir = join(root, '.agentxchain', 'watch-results');
592
+
593
+ if (!existsSync(resultsDir)) {
594
+ if (opts.json) {
595
+ console.log(JSON.stringify(opts.result ? { ok: false, error: 'no watch results found' } : { ok: true, results: [] }, null, 2));
596
+ } else {
597
+ console.log(chalk.dim(' No watch results yet.'));
598
+ }
599
+ process.exit(opts.result ? 1 : 0);
600
+ }
601
+
602
+ // Show a single result by ID or filename
603
+ if (opts.result) {
604
+ const record = loadWatchResult(resultsDir, opts.result);
605
+ if (!record) {
606
+ if (opts.json) {
607
+ console.log(JSON.stringify({ ok: false, error: `watch result not found: ${opts.result}` }, null, 2));
608
+ } else {
609
+ console.log(chalk.red(` Watch result not found: ${opts.result}`));
610
+ }
611
+ process.exit(1);
612
+ }
613
+ if (opts.json) {
614
+ console.log(JSON.stringify({ ok: true, ...record }, null, 2));
615
+ } else {
616
+ printWatchResult(record);
617
+ }
618
+ process.exit(0);
619
+ }
620
+
621
+ // List all results
622
+ const entries = readdirSync(resultsDir, { withFileTypes: true })
623
+ .filter((e) => e.isFile() && e.name.endsWith('.json'))
624
+ .map((e) => e.name)
625
+ .sort();
626
+
627
+ const records = [];
628
+ for (const name of entries) {
629
+ try {
630
+ const content = JSON.parse(readFileSync(join(resultsDir, name), 'utf8'));
631
+ records.push(content);
632
+ } catch {
633
+ // skip corrupt files
634
+ }
635
+ }
636
+
637
+ // Sort by timestamp descending (most recent first)
638
+ records.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
639
+
640
+ // Apply --limit if provided
641
+ const limit = opts.limit ? parseInt(opts.limit, 10) : 0;
642
+ const display = limit > 0 ? records.slice(0, limit) : records;
643
+
644
+ if (opts.json) {
645
+ console.log(JSON.stringify({ ok: true, total: records.length, results: display }, null, 2));
646
+ process.exit(0);
647
+ }
648
+
649
+ console.log('');
650
+ console.log(chalk.bold(` Watch Results (${records.length} total)`));
651
+ console.log('');
652
+
653
+ if (display.length === 0) {
654
+ console.log(chalk.dim(' No watch results yet.'));
655
+ console.log('');
656
+ process.exit(0);
657
+ }
658
+
659
+ for (const r of display) {
660
+ const status = formatResultStatus(r);
661
+ const category = r.payload?.category || 'unknown';
662
+ const repo = r.payload?.repo || '';
663
+ const ts = r.timestamp ? new Date(r.timestamp).toLocaleString() : 'unknown';
664
+ const dedup = r.deduplicated ? chalk.yellow(' [dedup]') : '';
665
+ const errors = r.errors?.length > 0 ? chalk.red(` (${r.errors.length} error${r.errors.length > 1 ? 's' : ''})`) : '';
666
+
667
+ console.log(` ${chalk.dim(r.result_id)} ${status}${dedup}${errors}`);
668
+ console.log(` ${chalk.cyan(category)} ${repo ? chalk.dim(repo) : ''} ${chalk.dim(ts)}`);
669
+ if (r.route?.run_id) {
670
+ console.log(` ${chalk.dim('run:')} ${r.route.run_id} ${chalk.dim('role:')} ${r.route.role || '?'}`);
671
+ }
672
+ }
673
+
674
+ if (limit > 0 && records.length > limit) {
675
+ console.log(chalk.dim(`\n ... and ${records.length - limit} more. Use --limit 0 to show all.`));
676
+ }
677
+
678
+ console.log('');
679
+ process.exit(0);
680
+ }
681
+
682
+ function loadWatchResult(resultsDir, idOrFile) {
683
+ // Try exact filename first
684
+ const directPath = join(resultsDir, idOrFile.endsWith('.json') ? idOrFile : `${idOrFile}.json`);
685
+ if (existsSync(directPath)) {
686
+ try { return JSON.parse(readFileSync(directPath, 'utf8')); } catch { return null; }
687
+ }
688
+
689
+ // Search by result_id prefix
690
+ const entries = readdirSync(resultsDir, { withFileTypes: true })
691
+ .filter((e) => e.isFile() && e.name.endsWith('.json'));
692
+ for (const entry of entries) {
693
+ try {
694
+ const content = JSON.parse(readFileSync(join(resultsDir, entry.name), 'utf8'));
695
+ if (content.result_id === idOrFile || content.result_id?.startsWith(idOrFile)) {
696
+ return content;
697
+ }
698
+ } catch {
699
+ // skip corrupt
700
+ }
701
+ }
702
+ return null;
703
+ }
704
+
705
+ function formatResultStatus(r) {
706
+ if (r.deduplicated) return chalk.yellow('deduplicated');
707
+ if (!r.route?.matched) return chalk.dim('unrouted');
708
+ if (r.route.started) return chalk.green('started');
709
+ if (r.route.planned) return chalk.blue('planned');
710
+ if (r.route.approved) return chalk.cyan('approved');
711
+ if (r.route.triaged) return chalk.white('triaged');
712
+ return chalk.dim('detected');
713
+ }
714
+
715
+ function printWatchResult(r) {
716
+ console.log('');
717
+ console.log(chalk.bold(' Watch Result'));
718
+ console.log(` ${chalk.dim('ID:')} ${r.result_id}`);
719
+ console.log(` ${chalk.dim('Time:')} ${r.timestamp ? new Date(r.timestamp).toLocaleString() : 'unknown'}`);
720
+ console.log(` ${chalk.dim('Event:')} ${r.event_id || 'none'}`);
721
+ console.log(` ${chalk.dim('Intent:')} ${r.intent_id || 'none'} (${r.intent_status || '?'})`);
722
+ console.log(` ${chalk.dim('Dedup:')} ${r.deduplicated ? 'yes' : 'no'}`);
723
+ console.log('');
724
+ console.log(chalk.bold(' Payload'));
725
+ console.log(` ${chalk.dim('Source:')} ${r.payload?.source || '?'}`);
726
+ console.log(` ${chalk.dim('Category:')} ${r.payload?.category || '?'}`);
727
+ console.log(` ${chalk.dim('Repo:')} ${r.payload?.repo || 'none'}`);
728
+ console.log(` ${chalk.dim('Ref:')} ${r.payload?.ref || 'none'}`);
729
+ console.log('');
730
+ console.log(chalk.bold(' Route'));
731
+ if (!r.route?.matched) {
732
+ console.log(chalk.dim(' No matching route.'));
733
+ } else {
734
+ console.log(` ${chalk.dim('Triaged:')} ${r.route.triaged ? 'yes' : 'no'}`);
735
+ console.log(` ${chalk.dim('Approved:')} ${r.route.approved ? 'yes' : 'no'}`);
736
+ console.log(` ${chalk.dim('Planned:')} ${r.route.planned ? 'yes' : 'no'}`);
737
+ console.log(` ${chalk.dim('Started:')} ${r.route.started ? 'yes' : 'no'}`);
738
+ console.log(` ${chalk.dim('Auto-start:')} ${r.route.auto_start ? 'yes' : 'no'}`);
739
+ if (r.route.preferred_role) console.log(` ${chalk.dim('Role hint:')} ${r.route.preferred_role}`);
740
+ if (r.route.run_id) console.log(` ${chalk.dim('Run ID:')} ${r.route.run_id}`);
741
+ if (r.route.role) console.log(` ${chalk.dim('Role:')} ${r.route.role}`);
742
+ }
743
+ if (r.errors?.length > 0) {
744
+ console.log('');
745
+ console.log(chalk.bold(' Errors'));
746
+ for (const err of r.errors) {
747
+ console.log(` ${chalk.red('•')} ${err}`);
748
+ }
749
+ }
750
+ console.log('');
751
+ }
752
+
156
753
  function pickNextAgent(root, lock, config) {
157
754
  return resolveNextAgent(root, config, lock).next;
158
755
  }
@@ -307,10 +904,15 @@ function log(type, msg) {
307
904
  console.log(` ${chalk.dim(time)} ${tags[type] || chalk.dim(type)} ${msg}`);
308
905
  }
309
906
 
310
- function startWatchDaemon() {
907
+ function startWatchDaemon(opts = {}) {
311
908
  const currentDir = dirname(fileURLToPath(import.meta.url));
312
909
  const cliBin = join(currentDir, '../../bin/agentxchain.js');
313
- const child = spawn(process.execPath, [cliBin, 'watch'], {
910
+ const args = [cliBin, 'watch'];
911
+ if (opts.eventDir) {
912
+ args.push('--event-dir', opts.eventDir);
913
+ if (opts.pollSeconds) args.push('--poll-seconds', String(opts.pollSeconds));
914
+ }
915
+ const child = spawn(process.execPath, args, {
314
916
  cwd: process.cwd(),
315
917
  detached: true,
316
918
  stdio: 'ignore',
@@ -319,8 +921,12 @@ function startWatchDaemon() {
319
921
  child.unref();
320
922
 
321
923
  const result = loadConfig();
322
- if (result) {
323
- writeFileSync(join(result.root, PID_FILE), String(child.pid));
924
+ let pidRoot = result?.root || null;
925
+ if (!pidRoot && opts.eventDir && existsSync(join(process.cwd(), 'agentxchain.json'))) {
926
+ pidRoot = process.cwd();
927
+ }
928
+ if (pidRoot) {
929
+ writeFileSync(join(pidRoot, PID_FILE), String(child.pid));
324
930
  }
325
931
 
326
932
  console.log('');
package/src/lib/intake.js CHANGED
@@ -537,6 +537,9 @@ export function triageIntent(root, intentId, fields) {
537
537
  intent.charter = normalizedFields.charter;
538
538
  intent.acceptance_contract = normalizedFields.acceptance_contract;
539
539
  intent.phase_scope = normalizedFields.phase_scope || null;
540
+ if (normalizedFields.preferred_role) {
541
+ intent.preferred_role = normalizedFields.preferred_role;
542
+ }
540
543
  intent.updated_at = now;
541
544
  intent.history.push({ from: 'detected', to: 'triaged', at: now, reason: 'triage completed' });
542
545
 
@@ -1148,7 +1151,7 @@ export function startIntent(root, intentId, options = {}) {
1148
1151
  }
1149
1152
 
1150
1153
  // Resolve role
1151
- const roleId = resolveIntakeRole(options.role, state, config);
1154
+ const roleId = resolveIntakeRole(options.role, intent, state, config);
1152
1155
  if (!roleId.ok) {
1153
1156
  return { ok: false, error: roleId.error, exitCode: 1 };
1154
1157
  }
@@ -1220,7 +1223,7 @@ export function startIntent(root, intentId, options = {}) {
1220
1223
  };
1221
1224
  }
1222
1225
 
1223
- function resolveIntakeRole(roleOverride, state, config) {
1226
+ function resolveIntakeRole(roleOverride, intent, state, config) {
1224
1227
  const phase = state.phase;
1225
1228
  const routing = config.routing?.[phase];
1226
1229
 
@@ -1232,6 +1235,17 @@ function resolveIntakeRole(roleOverride, state, config) {
1232
1235
  return { ok: true, role: roleOverride };
1233
1236
  }
1234
1237
 
1238
+ const preferredRole = typeof intent?.preferred_role === 'string'
1239
+ ? intent.preferred_role.trim()
1240
+ : '';
1241
+ if (preferredRole) {
1242
+ if (!config.roles?.[preferredRole]) {
1243
+ const available = Object.keys(config.roles || {}).join(', ');
1244
+ return { ok: false, error: `unknown preferred_role on intent: "${preferredRole}". Available: ${available}` };
1245
+ }
1246
+ return { ok: true, role: preferredRole };
1247
+ }
1248
+
1235
1249
  if (routing?.entry_role) {
1236
1250
  return { ok: true, role: routing.entry_role };
1237
1251
  }
@@ -0,0 +1,312 @@
1
+ import { mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { safeWriteJson } from './safe-write.js';
4
+
5
+ export function normalizeWatchEvent(input) {
6
+ const event = unwrapEvent(input);
7
+
8
+ if (isPullRequestEvent(event)) {
9
+ return normalizePullRequestEvent(event);
10
+ }
11
+
12
+ if (isIssueLabeledEvent(event)) {
13
+ return normalizeIssueLabeledEvent(event);
14
+ }
15
+
16
+ if (isWorkflowFailureEvent(event)) {
17
+ return normalizeWorkflowFailureEvent(event);
18
+ }
19
+
20
+ if (isScheduleEvent(event)) {
21
+ return normalizeScheduleEvent(event);
22
+ }
23
+
24
+ throw new Error('unsupported watch event: supported events are GitHub pull_request, issues.labeled, failed workflow_run, and schedule');
25
+ }
26
+
27
+ function unwrapEvent(input) {
28
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
29
+ throw new Error('watch event must be a JSON object');
30
+ }
31
+
32
+ if (input.provider === 'github' && typeof input.event === 'string') {
33
+ return {
34
+ provider: 'github',
35
+ event: input.event,
36
+ ...(input.payload && typeof input.payload === 'object' && !Array.isArray(input.payload) ? input.payload : input),
37
+ };
38
+ }
39
+
40
+ return {
41
+ provider: input.provider || 'github',
42
+ event: inferGitHubEvent(input),
43
+ ...input,
44
+ };
45
+ }
46
+
47
+ function inferGitHubEvent(event) {
48
+ if (event.pull_request) return 'pull_request';
49
+ if (event.issue) return 'issues';
50
+ if (event.workflow_run) return 'workflow_run';
51
+ if (event.schedule || event.event === 'schedule') return 'schedule';
52
+ return event.event || 'unknown';
53
+ }
54
+
55
+ function isPullRequestEvent(event) {
56
+ return event.provider === 'github' && event.event === 'pull_request' && event.pull_request;
57
+ }
58
+
59
+ function isIssueLabeledEvent(event) {
60
+ return event.provider === 'github' && event.event === 'issues' && event.action === 'labeled' && event.issue;
61
+ }
62
+
63
+ function isWorkflowFailureEvent(event) {
64
+ if (event.provider !== 'github' || event.event !== 'workflow_run' || !event.workflow_run) return false;
65
+ const conclusion = event.workflow_run.conclusion;
66
+ return typeof conclusion === 'string' && !['success', 'skipped', 'cancelled'].includes(conclusion);
67
+ }
68
+
69
+ function isScheduleEvent(event) {
70
+ return event.provider === 'github' && event.event === 'schedule';
71
+ }
72
+
73
+ function normalizePullRequestEvent(event) {
74
+ const pr = event.pull_request;
75
+ const repo = repositoryName(event);
76
+ const action = normalizeToken(event.action || 'unknown');
77
+ const signal = removeNullish({
78
+ provider: 'github',
79
+ event: 'pull_request',
80
+ action,
81
+ repository: repo,
82
+ number: pr.number,
83
+ title: pr.title,
84
+ url: pr.html_url,
85
+ head_ref: pr.head?.ref,
86
+ head_sha: pr.head?.sha,
87
+ base_ref: pr.base?.ref,
88
+ draft: pr.draft,
89
+ });
90
+
91
+ return {
92
+ source: 'git_ref_change',
93
+ category: `github_pull_request_${action}`,
94
+ repo,
95
+ ref: pr.head?.ref || null,
96
+ signal,
97
+ evidence: evidenceFor(pr.html_url, pr.title || `Pull request ${pr.number || ''}`.trim()),
98
+ };
99
+ }
100
+
101
+ function normalizeIssueLabeledEvent(event) {
102
+ const issue = event.issue;
103
+ const repo = repositoryName(event);
104
+ const labelName = typeof event.label?.name === 'string' ? event.label.name : null;
105
+ const signal = removeNullish({
106
+ provider: 'github',
107
+ event: 'issues',
108
+ action: 'labeled',
109
+ repository: repo,
110
+ number: issue.number,
111
+ title: issue.title,
112
+ url: issue.html_url,
113
+ label: labelName,
114
+ });
115
+
116
+ return {
117
+ source: 'manual',
118
+ category: 'github_issue_labeled',
119
+ repo,
120
+ ref: null,
121
+ signal,
122
+ evidence: evidenceFor(issue.html_url, issue.title || `Issue ${issue.number || ''}`.trim()),
123
+ };
124
+ }
125
+
126
+ function normalizeWorkflowFailureEvent(event) {
127
+ const workflowRun = event.workflow_run;
128
+ const repo = repositoryName(event);
129
+ const signal = removeNullish({
130
+ provider: 'github',
131
+ event: 'workflow_run',
132
+ action: normalizeToken(event.action || 'completed'),
133
+ repository: repo,
134
+ workflow_name: workflowRun.name,
135
+ conclusion: workflowRun.conclusion,
136
+ status: workflowRun.status,
137
+ run_id: workflowRun.id,
138
+ run_number: workflowRun.run_number,
139
+ url: workflowRun.html_url,
140
+ head_branch: workflowRun.head_branch,
141
+ head_sha: workflowRun.head_sha,
142
+ });
143
+
144
+ return {
145
+ source: 'ci_failure',
146
+ category: 'github_workflow_run_failed',
147
+ repo,
148
+ ref: workflowRun.head_branch || null,
149
+ signal,
150
+ evidence: evidenceFor(workflowRun.html_url, `${workflowRun.name || 'GitHub workflow'} ${workflowRun.conclusion || 'failed'}`),
151
+ };
152
+ }
153
+
154
+ function normalizeScheduleEvent(event) {
155
+ const repo = repositoryName(event);
156
+ const signal = removeNullish({
157
+ provider: 'github',
158
+ event: 'schedule',
159
+ repository: repo,
160
+ schedule: event.schedule,
161
+ workflow: event.workflow,
162
+ ref: event.ref,
163
+ sha: event.sha,
164
+ });
165
+
166
+ return {
167
+ source: 'schedule',
168
+ category: 'github_schedule',
169
+ repo,
170
+ ref: event.ref || null,
171
+ signal,
172
+ evidence: [{ type: 'text', value: `GitHub schedule event${event.schedule ? `: ${event.schedule}` : ''}` }],
173
+ };
174
+ }
175
+
176
+ function repositoryName(event) {
177
+ if (typeof event.repository === 'string') return event.repository;
178
+ if (typeof event.repository?.full_name === 'string') return event.repository.full_name;
179
+ if (typeof event.repository?.name === 'string') return event.repository.name;
180
+ return null;
181
+ }
182
+
183
+ function evidenceFor(url, text) {
184
+ if (typeof url === 'string' && url.trim()) {
185
+ return [{ type: 'url', value: url }];
186
+ }
187
+ return [{ type: 'text', value: text || 'GitHub event' }];
188
+ }
189
+
190
+ function normalizeToken(value) {
191
+ return String(value || 'unknown').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'unknown';
192
+ }
193
+
194
+ function removeNullish(input) {
195
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== null && value !== undefined && value !== ''));
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Route resolution
200
+ // ---------------------------------------------------------------------------
201
+
202
+ export function resolveWatchRoute(payload, routes) {
203
+ if (!Array.isArray(routes) || routes.length === 0) return null;
204
+
205
+ for (const route of routes) {
206
+ if (!route.match || !route.triage) continue;
207
+
208
+ const match = route.match;
209
+ if (match.source && match.source !== payload.source) continue;
210
+ if (match.category) {
211
+ if (match.category.includes('*')) {
212
+ const escaped = match.category.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
213
+ if (!new RegExp(`^${escaped}$`).test(payload.category)) continue;
214
+ } else if (match.category !== payload.category) {
215
+ continue;
216
+ }
217
+ }
218
+
219
+ const charter = interpolateCharter(route.triage.charter, payload.signal);
220
+
221
+ return {
222
+ triage: {
223
+ priority: route.triage.priority || 'p2',
224
+ template: route.triage.template || 'generic',
225
+ charter,
226
+ acceptance_contract: Array.isArray(route.triage.acceptance_contract) && route.triage.acceptance_contract.length > 0
227
+ ? route.triage.acceptance_contract
228
+ : ['Watch event processed under governance'],
229
+ },
230
+ auto_approve: route.auto_approve === true,
231
+ auto_start: route.auto_start === true,
232
+ overwrite_planning_artifacts: route.overwrite_planning_artifacts === true,
233
+ preferred_role: route.preferred_role || null,
234
+ };
235
+ }
236
+
237
+ return null;
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Watch result persistence
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Write a durable watch result file to `.agentxchain/watch-results/`.
246
+ *
247
+ * Every non-dry-run `watch --event-file` invocation writes exactly one result
248
+ * file containing the full pipeline trace: event, intent, route match,
249
+ * triage/approve/plan/start statuses, errors, and timestamps.
250
+ *
251
+ * @param {string} root - project root
252
+ * @param {object} pipelineResult - the result object from ingestWatchEvent
253
+ * @param {object} payload - the normalized watch event payload
254
+ * @returns {{ result_id: string, result_path: string }}
255
+ */
256
+ export function writeWatchResult(root, pipelineResult, payload, metadata = {}) {
257
+ const ts = Date.now();
258
+ const suffix = Math.random().toString(16).slice(2, 10);
259
+ const resultId = `wr_${ts}_${suffix}`;
260
+
261
+ const routed = pipelineResult.routed || null;
262
+ const errors = [];
263
+
264
+ if (routed?.auto_start_error) errors.push(routed.auto_start_error);
265
+ if (routed?.auto_start_skipped) errors.push(`auto_start_skipped: ${routed.auto_start_skipped}`);
266
+
267
+ const record = {
268
+ result_id: resultId,
269
+ timestamp: new Date(ts).toISOString(),
270
+ event_id: pipelineResult.event?.event_id || null,
271
+ intent_id: pipelineResult.intent?.intent_id || null,
272
+ intent_status: pipelineResult.intent?.status || null,
273
+ deduplicated: pipelineResult.deduplicated === true,
274
+ delivery_id: metadata.delivery_id || null,
275
+ payload: {
276
+ source: payload.source,
277
+ category: payload.category,
278
+ repo: payload.repo || null,
279
+ ref: payload.ref || null,
280
+ },
281
+ route: routed
282
+ ? {
283
+ matched: true,
284
+ triaged: routed.triaged === true,
285
+ approved: routed.approved === true,
286
+ planned: routed.planned === true,
287
+ started: routed.started === true,
288
+ auto_start: routed.auto_start === true || routed.started === true,
289
+ preferred_role: routed.preferred_role || null,
290
+ run_id: routed.run_id || null,
291
+ role: routed.role || null,
292
+ }
293
+ : { matched: false },
294
+ errors: errors.length > 0 ? errors : [],
295
+ };
296
+
297
+ const dir = join(root, '.agentxchain', 'watch-results');
298
+ mkdirSync(dir, { recursive: true });
299
+ const resultPath = join(dir, `${resultId}.json`);
300
+ safeWriteJson(resultPath, record);
301
+
302
+ return { result_id: resultId, result_path: resultPath };
303
+ }
304
+
305
+ function interpolateCharter(template, signal) {
306
+ if (!template || typeof template !== 'string') return template || 'Watch event intake';
307
+ if (!signal || typeof signal !== 'object') return template;
308
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
309
+ const value = signal[key];
310
+ return value !== undefined && value !== null ? String(value) : `{{${key}}}`;
311
+ });
312
+ }
@@ -0,0 +1,297 @@
1
+ import { createServer } from 'http';
2
+ import { createHmac, timingSafeEqual } from 'crypto';
3
+ import { readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { recordEvent, triageIntent, approveIntent, planIntent, startIntent } from './intake.js';
6
+ import { normalizeWatchEvent, resolveWatchRoute, writeWatchResult } from './watch-events.js';
7
+
8
+ const MAX_BODY_BYTES = 1_048_576; // 1 MB
9
+
10
+ /**
11
+ * Start an HTTP webhook listener that feeds events through the governed intake pipeline.
12
+ *
13
+ * @param {object} opts
14
+ * @param {string} opts.root - project root
15
+ * @param {number} opts.port - port to bind
16
+ * @param {string} [opts.host='127.0.0.1'] - host to bind
17
+ * @param {string|null} [opts.secret=null] - HMAC-SHA256 webhook secret
18
+ * @param {boolean} [opts.allowUnsigned=false] - accept unsigned payloads
19
+ * @param {boolean} [opts.dryRun=false] - normalize only, do not persist
20
+ * @param {Function} [opts.onReady] - called with { port, host } when listening
21
+ * @returns {Promise<import('http').Server>}
22
+ */
23
+ export function startWebhookListener(opts) {
24
+ const { root, port, host = '127.0.0.1', secret = null, allowUnsigned = false, dryRun = false, onReady } = opts;
25
+ const startedAt = Date.now();
26
+ let eventsProcessed = 0;
27
+
28
+ let version = 'unknown';
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(join(root, 'node_modules', 'agentxchain', 'package.json'), 'utf8'));
31
+ version = pkg.version;
32
+ } catch {
33
+ try {
34
+ // Fallback: try the CLI's own package.json
35
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
36
+ version = pkg.version;
37
+ } catch {}
38
+ }
39
+
40
+ const server = createServer(async (req, res) => {
41
+ try {
42
+ // Health endpoint
43
+ if (req.method === 'GET' && req.url === '/health') {
44
+ writeJson(res, 200, {
45
+ ok: true,
46
+ version,
47
+ uptime_ms: Date.now() - startedAt,
48
+ events_processed: eventsProcessed,
49
+ });
50
+ return;
51
+ }
52
+
53
+ // Webhook endpoint
54
+ if (req.method === 'POST' && req.url === '/webhook') {
55
+ const outcome = await handleWebhook(req, res, { root, secret, allowUnsigned, dryRun, startedAt });
56
+ if (outcome?.counted) eventsProcessed++;
57
+ return;
58
+ }
59
+
60
+ // Method not allowed on known paths
61
+ if (req.url === '/webhook' || req.url === '/health') {
62
+ writeJson(res, 405, { ok: false, error: 'method not allowed' });
63
+ return;
64
+ }
65
+
66
+ // Not found
67
+ writeJson(res, 404, { ok: false, error: 'not found' });
68
+ } catch (err) {
69
+ writeJson(res, 500, { ok: false, error: 'internal error' });
70
+ }
71
+ });
72
+
73
+ return new Promise((resolve, reject) => {
74
+ server.on('error', reject);
75
+ server.listen(port, host, () => {
76
+ server.removeListener('error', reject);
77
+ if (onReady) onReady({ port, host });
78
+ resolve(server);
79
+ });
80
+ });
81
+ }
82
+
83
+ async function handleWebhook(req, res, ctx) {
84
+ const { root, secret, allowUnsigned, dryRun } = ctx;
85
+
86
+ // Content-Type check
87
+ const contentType = req.headers['content-type'] || '';
88
+ if (!contentType.includes('application/json')) {
89
+ writeJson(res, 415, { ok: false, error: 'content type must be application/json' });
90
+ return { counted: false };
91
+ }
92
+
93
+ // Read body with size limit
94
+ let rawBody;
95
+ try {
96
+ rawBody = await readBody(req, MAX_BODY_BYTES);
97
+ } catch (err) {
98
+ if (err.message === 'payload too large') {
99
+ writeJson(res, 413, { ok: false, error: 'payload too large' });
100
+ return { counted: false };
101
+ }
102
+ writeJson(res, 400, { ok: false, error: err.message });
103
+ return { counted: false };
104
+ }
105
+
106
+ // Signature verification
107
+ if (secret) {
108
+ const sigHeader = req.headers['x-hub-signature-256'];
109
+ if (!sigHeader) {
110
+ writeJson(res, 401, { ok: false, error: 'signature verification failed' });
111
+ return { counted: false };
112
+ }
113
+ const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
114
+ if (!constantTimeEqual(expected, sigHeader)) {
115
+ writeJson(res, 401, { ok: false, error: 'signature verification failed' });
116
+ return { counted: false };
117
+ }
118
+ } else if (!allowUnsigned) {
119
+ writeJson(res, 403, { ok: false, error: 'webhook secret required' });
120
+ return { counted: false };
121
+ }
122
+
123
+ // Parse JSON
124
+ let parsed;
125
+ try {
126
+ parsed = JSON.parse(rawBody);
127
+ } catch {
128
+ writeJson(res, 400, { ok: false, error: 'invalid JSON' });
129
+ return { counted: false };
130
+ }
131
+
132
+ // Construct envelope using X-GitHub-Event header if present
133
+ const githubEvent = req.headers['x-github-event'];
134
+ const deliveryId = req.headers['x-github-delivery'] || null;
135
+ let envelope;
136
+ if (parsed.provider && parsed.event) {
137
+ // Already enveloped
138
+ envelope = parsed;
139
+ } else if (githubEvent) {
140
+ envelope = { provider: 'github', event: githubEvent, ...parsed };
141
+ } else {
142
+ envelope = parsed;
143
+ }
144
+
145
+ // Normalize
146
+ let payload;
147
+ try {
148
+ payload = normalizeWatchEvent(envelope);
149
+ } catch (err) {
150
+ writeJson(res, 422, { ok: false, error: err.message });
151
+ return { counted: false };
152
+ }
153
+
154
+ // Dry-run: return normalized payload without persisting
155
+ if (dryRun) {
156
+ writeJson(res, 200, { ok: true, dry_run: true, payload });
157
+ return { counted: true };
158
+ }
159
+
160
+ // Record event through the governed intake pipeline
161
+ const result = recordEvent(root, payload);
162
+ if (!result.ok) {
163
+ writeJson(res, 422, { ok: false, error: result.error || 'event recording failed' });
164
+ return { counted: false };
165
+ }
166
+
167
+ // Route-based auto-triage and auto-approve (same logic as ingestWatchEvent)
168
+ let routed = null;
169
+ if (!result.deduplicated && result.intent) {
170
+ let routes;
171
+ try {
172
+ const rawConfig = JSON.parse(readFileSync(join(root, 'agentxchain.json'), 'utf8'));
173
+ routes = rawConfig?.watch?.routes;
174
+ } catch {}
175
+
176
+ const resolved = resolveWatchRoute(payload, routes);
177
+ if (resolved) {
178
+ const triageFields = { ...resolved.triage };
179
+ if (resolved.preferred_role) triageFields.preferred_role = resolved.preferred_role;
180
+
181
+ const triageResult = triageIntent(root, result.intent.intent_id, triageFields);
182
+ if (triageResult.ok) {
183
+ result.intent = triageResult.intent;
184
+ routed = { triaged: true, approved: false, preferred_role: resolved.preferred_role };
185
+
186
+ if (resolved.auto_approve) {
187
+ const approveResult = approveIntent(root, result.intent.intent_id, {
188
+ approver: 'watch_route',
189
+ reason: `auto-approved by watch route matching ${payload.category}`,
190
+ });
191
+ if (approveResult.ok) {
192
+ result.intent = approveResult.intent;
193
+ routed.approved = true;
194
+
195
+ if (resolved.auto_start) {
196
+ const planResult = planIntent(root, result.intent.intent_id, {
197
+ force: resolved.overwrite_planning_artifacts === true,
198
+ });
199
+ if (planResult.ok) {
200
+ result.intent = planResult.intent;
201
+ routed.planned = true;
202
+ const startResult = startIntent(root, result.intent.intent_id, {});
203
+ if (startResult.ok) {
204
+ result.intent = startResult.intent;
205
+ routed.started = true;
206
+ routed.run_id = startResult.run_id || null;
207
+ routed.role = startResult.role || null;
208
+ } else {
209
+ routed.started = false;
210
+ routed.auto_start_error = startResult.error;
211
+ }
212
+ } else {
213
+ routed.planned = false;
214
+ routed.started = false;
215
+ routed.auto_start_error = planResult.error;
216
+ }
217
+ }
218
+ }
219
+ } else if (resolved.auto_start) {
220
+ routed.auto_start_skipped = 'requires auto_approve';
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ if (routed) result.routed = routed;
227
+
228
+ // Write durable watch result
229
+ const watchResult = writeWatchResult(root, result, payload, { delivery_id: deliveryId });
230
+
231
+ // Build response
232
+ const response = {
233
+ ok: true,
234
+ result_id: watchResult.result_id,
235
+ event_id: result.event?.event_id || null,
236
+ intent_id: result.intent?.intent_id || null,
237
+ intent_status: result.intent?.status || null,
238
+ deduplicated: result.deduplicated === true,
239
+ delivery_id: deliveryId,
240
+ route: routed
241
+ ? {
242
+ matched: true,
243
+ triaged: routed.triaged === true,
244
+ approved: routed.approved === true,
245
+ planned: routed.planned === true,
246
+ started: routed.started === true,
247
+ preferred_role: routed.preferred_role || null,
248
+ run_id: routed.run_id || null,
249
+ role: routed.role || null,
250
+ }
251
+ : { matched: false },
252
+ };
253
+
254
+ writeJson(res, 200, response);
255
+ return { counted: true };
256
+ }
257
+
258
+ function readBody(req, maxBytes) {
259
+ return new Promise((resolve, reject) => {
260
+ const chunks = [];
261
+ let size = 0;
262
+ let rejected = false;
263
+ req.on('data', (chunk) => {
264
+ size += chunk.length;
265
+ if (size > maxBytes && !rejected) {
266
+ rejected = true;
267
+ reject(new Error('payload too large'));
268
+ // Resume to drain remaining data so the response can be sent
269
+ req.resume();
270
+ return;
271
+ }
272
+ if (!rejected) chunks.push(chunk);
273
+ });
274
+ req.on('end', () => {
275
+ if (!rejected) resolve(Buffer.concat(chunks));
276
+ });
277
+ req.on('error', (err) => {
278
+ if (!rejected) reject(err);
279
+ });
280
+ });
281
+ }
282
+
283
+ function constantTimeEqual(a, b) {
284
+ const bufA = Buffer.from(a, 'utf8');
285
+ const bufB = Buffer.from(b, 'utf8');
286
+ if (bufA.length !== bufB.length) return false;
287
+ return timingSafeEqual(bufA, bufB);
288
+ }
289
+
290
+ function writeJson(res, statusCode, payload) {
291
+ if (res.writableEnded) return;
292
+ res.writeHead(statusCode, {
293
+ 'Content-Type': 'application/json; charset=utf-8',
294
+ 'Cache-Control': 'no-cache',
295
+ });
296
+ res.end(JSON.stringify(payload));
297
+ }