agentxchain 2.155.22 → 2.155.23

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\|--results\|--result` | Normalize external events into governed intake, poll event-file directories, 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,16 @@ 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('--results', 'List all watch result records')
258
+ .option('--result <id>', 'Show a single watch result by ID or filename')
259
+ .option('--limit <n>', 'With --results, limit the number of results shown')
260
+ .option('-j, --json', 'Output JSON')
253
261
  .action(watchCommand);
254
262
 
255
263
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.155.22",
3
+ "version": "2.155.23",
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,42 @@
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';
11
14
 
12
15
  const PID_FILE = '.agentxchain-watch.pid';
13
16
 
14
17
  export async function watchCommand(opts) {
18
+ if (opts.results || opts.result) {
19
+ listOrShowWatchResults(opts);
20
+ return;
21
+ }
22
+
23
+ if (opts.eventFile) {
24
+ await ingestWatchEvent(opts);
25
+ return;
26
+ }
27
+
28
+ if (opts.daemon && opts.eventDir && process.env.AGENTXCHAIN_WATCH_DAEMON !== '1') {
29
+ startWatchDaemon(opts);
30
+ return;
31
+ }
32
+
33
+ if (opts.eventDir) {
34
+ await watchEventDirectory(opts);
35
+ return;
36
+ }
37
+
15
38
  if (opts.daemon && process.env.AGENTXCHAIN_WATCH_DAEMON !== '1') {
16
- startWatchDaemon();
39
+ startWatchDaemon(opts);
17
40
  return;
18
41
  }
19
42
 
@@ -153,6 +176,481 @@ export async function watchCommand(opts) {
153
176
  process.on('SIGTERM', cleanup);
154
177
  }
155
178
 
179
+ async function watchEventDirectory(opts) {
180
+ const root = requireIntakeWorkspaceOrExit(opts);
181
+ const eventDir = resolve(process.cwd(), opts.eventDir);
182
+ const pollMs = parsePollMs(opts.pollSeconds);
183
+ mkdirSync(eventDir, { recursive: true });
184
+ writePidFile(root);
185
+
186
+ console.log('');
187
+ console.log(chalk.bold(' AgentXchain Watch Event Directory'));
188
+ console.log(chalk.dim(` Event dir: ${eventDir}`));
189
+ console.log(chalk.dim(` Poll: ${pollMs}ms`));
190
+ console.log(chalk.dim(' Processed files move to processed/; failed files move to failed/.'));
191
+ console.log('');
192
+
193
+ let processing = false;
194
+ const tick = async () => {
195
+ if (processing) return;
196
+ processing = true;
197
+ try {
198
+ const results = await processPendingEventFiles(root, eventDir);
199
+ for (const result of results) {
200
+ if (result.ok) {
201
+ log('claimed', `Processed event file ${basename(result.source)} -> ${basename(result.destination)}`);
202
+ } else {
203
+ log('warn', `Failed event file ${basename(result.source)} -> ${basename(result.destination)}: ${result.error}`);
204
+ }
205
+ }
206
+ } catch (err) {
207
+ log('error', err.message);
208
+ } finally {
209
+ processing = false;
210
+ }
211
+ };
212
+
213
+ await tick();
214
+ const timer = setInterval(tick, pollMs);
215
+
216
+ const cleanup = () => {
217
+ clearInterval(timer);
218
+ removePidFile(root);
219
+ console.log('');
220
+ log('stop', 'Watch event directory stopped.');
221
+ process.exit(0);
222
+ };
223
+
224
+ process.on('SIGINT', cleanup);
225
+ process.on('SIGTERM', cleanup);
226
+ }
227
+
228
+ async function processPendingEventFiles(root, eventDir) {
229
+ const entries = readdirSync(eventDir, { withFileTypes: true })
230
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
231
+ .map((entry) => entry.name)
232
+ .sort();
233
+
234
+ const results = [];
235
+ for (const name of entries) {
236
+ const source = join(eventDir, name);
237
+ if (!existsSync(source)) continue;
238
+
239
+ const childResult = await runWatchEventFile(root, source);
240
+ const destinationDir = join(eventDir, childResult.status === 0 ? 'processed' : 'failed');
241
+ const destination = moveEventFile(source, destinationDir);
242
+ results.push({
243
+ ok: childResult.status === 0,
244
+ source,
245
+ destination,
246
+ error: childResult.status === 0 ? null : summarizeChildFailure(childResult),
247
+ });
248
+ }
249
+ return results;
250
+ }
251
+
252
+ const DEFAULT_CHILD_TIMEOUT_MS = 30_000;
253
+
254
+ function runWatchEventFile(root, eventFile, timeoutMs = DEFAULT_CHILD_TIMEOUT_MS) {
255
+ const currentDir = dirname(fileURLToPath(import.meta.url));
256
+ const cliBin = join(currentDir, '../../bin/agentxchain.js');
257
+ return new Promise((resolvePromise) => {
258
+ const child = spawn(process.execPath, [cliBin, 'watch', '--event-file', eventFile, '--json'], {
259
+ cwd: root,
260
+ env: { ...process.env, NO_COLOR: '1' },
261
+ stdio: ['ignore', 'pipe', 'pipe'],
262
+ });
263
+ let stdout = '';
264
+ let stderr = '';
265
+ let settled = false;
266
+ const finish = (status, signal) => {
267
+ if (settled) return;
268
+ settled = true;
269
+ clearTimeout(timer);
270
+ resolvePromise({ status: status ?? 1, signal, stdout, stderr });
271
+ };
272
+ const timer = setTimeout(() => {
273
+ if (settled) return;
274
+ try { child.kill('SIGTERM'); } catch {}
275
+ stderr += `\nchild process timed out after ${timeoutMs}ms`;
276
+ finish(1, 'SIGTERM');
277
+ }, timeoutMs);
278
+ child.stdout.on('data', (data) => { stdout += data.toString(); });
279
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
280
+ child.on('close', (status, signal) => finish(status, signal));
281
+ child.on('error', (err) => {
282
+ stderr += err.message;
283
+ finish(1, null);
284
+ });
285
+ });
286
+ }
287
+
288
+ function moveEventFile(source, destinationDir) {
289
+ mkdirSync(destinationDir, { recursive: true });
290
+ const parsed = basename(source);
291
+ let destination = join(destinationDir, parsed);
292
+ if (existsSync(destination)) {
293
+ const suffix = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
294
+ destination = join(destinationDir, `${parsed}.${suffix}`);
295
+ }
296
+ renameSync(source, destination);
297
+ return destination;
298
+ }
299
+
300
+ function summarizeChildFailure(result) {
301
+ try {
302
+ const parsed = JSON.parse(result.stdout);
303
+ if (parsed?.error) return parsed.error;
304
+ } catch {}
305
+ return result.stderr.trim() || result.stdout.trim() || `watch --event-file exited with status ${result.status}`;
306
+ }
307
+
308
+ function parsePollMs(value) {
309
+ const seconds = Number(value);
310
+ if (!Number.isFinite(seconds) || seconds <= 0) return 5000;
311
+ return Math.max(100, Math.round(seconds * 1000));
312
+ }
313
+
314
+ async function ingestWatchEvent(opts) {
315
+ if (opts.daemon) {
316
+ const message = '--daemon cannot be combined with --event-file';
317
+ if (opts.json) {
318
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
319
+ } else {
320
+ console.log(chalk.red(` ${message}`));
321
+ }
322
+ process.exit(1);
323
+ }
324
+
325
+ const root = requireIntakeWorkspaceOrExit(opts);
326
+ let raw;
327
+ let parsed;
328
+ try {
329
+ raw = readFileSync(opts.eventFile, 'utf8');
330
+ } catch (err) {
331
+ const message = `failed to read event file: ${err.message}`;
332
+ if (opts.json) {
333
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
334
+ } else {
335
+ console.log(chalk.red(` ${message}`));
336
+ }
337
+ process.exit(1);
338
+ }
339
+
340
+ try {
341
+ parsed = JSON.parse(raw);
342
+ } catch (err) {
343
+ const message = `event file is not valid JSON: ${err.message}`;
344
+ if (opts.json) {
345
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
346
+ } else {
347
+ console.log(chalk.red(` ${message}`));
348
+ }
349
+ process.exit(1);
350
+ }
351
+
352
+ let payload;
353
+ try {
354
+ payload = normalizeWatchEvent(parsed);
355
+ } catch (err) {
356
+ if (opts.json) {
357
+ console.log(JSON.stringify({ ok: false, error: err.message }, null, 2));
358
+ } else {
359
+ console.log(chalk.red(` ${err.message}`));
360
+ }
361
+ process.exit(1);
362
+ }
363
+
364
+ if (opts.dryRun) {
365
+ if (opts.json) {
366
+ console.log(JSON.stringify({ ok: true, dry_run: true, payload }, null, 2));
367
+ } else {
368
+ console.log('');
369
+ console.log(chalk.green(' Watch event normalized (dry run)'));
370
+ console.log(JSON.stringify(payload, null, 2));
371
+ console.log('');
372
+ }
373
+ process.exit(0);
374
+ }
375
+
376
+ const result = recordEvent(root, payload);
377
+ if (!result.ok) {
378
+ if (opts.json) {
379
+ console.log(JSON.stringify(result, null, 2));
380
+ } else {
381
+ console.log(chalk.red(` ${result.error}`));
382
+ }
383
+ process.exit(result.exitCode);
384
+ }
385
+
386
+ // Route-based auto-triage and auto-approve
387
+ let routed = null;
388
+ if (!result.deduplicated && result.intent) {
389
+ let routes;
390
+ try {
391
+ const rawConfig = JSON.parse(readFileSync(join(root, 'agentxchain.json'), 'utf8'));
392
+ routes = rawConfig?.watch?.routes;
393
+ } catch {
394
+ // non-fatal — no routes if config is unreadable
395
+ }
396
+ const resolved = resolveWatchRoute(payload, routes);
397
+ if (resolved) {
398
+ const triageFields = {
399
+ ...resolved.triage,
400
+ };
401
+ if (resolved.preferred_role) {
402
+ triageFields.preferred_role = resolved.preferred_role;
403
+ }
404
+ const triageResult = triageIntent(root, result.intent.intent_id, triageFields);
405
+ if (triageResult.ok) {
406
+ result.intent = triageResult.intent;
407
+ routed = { triaged: true, approved: false, preferred_role: resolved.preferred_role };
408
+ if (resolved.auto_approve) {
409
+ const approveResult = approveIntent(root, result.intent.intent_id, {
410
+ approver: 'watch_route',
411
+ reason: `auto-approved by watch route matching ${payload.category}`,
412
+ });
413
+ if (approveResult.ok) {
414
+ result.intent = approveResult.intent;
415
+ routed.approved = true;
416
+
417
+ // Auto-start: plan + start the governed run
418
+ if (resolved.auto_start) {
419
+ const planResult = planIntent(root, result.intent.intent_id, {
420
+ force: resolved.overwrite_planning_artifacts === true,
421
+ });
422
+ if (planResult.ok) {
423
+ result.intent = planResult.intent;
424
+ routed.planned = true;
425
+
426
+ const startResult = startIntent(root, result.intent.intent_id, {});
427
+ if (startResult.ok) {
428
+ result.intent = startResult.intent;
429
+ routed.started = true;
430
+ routed.run_id = startResult.run_id || null;
431
+ routed.role = startResult.role || null;
432
+ } else {
433
+ routed.started = false;
434
+ routed.auto_start_error = startResult.error;
435
+ }
436
+ } else {
437
+ routed.planned = false;
438
+ routed.started = false;
439
+ routed.auto_start_error = planResult.error;
440
+ }
441
+ }
442
+ }
443
+ } else if (resolved.auto_start) {
444
+ // auto_start without auto_approve — skip silently
445
+ routed.auto_start_skipped = 'requires auto_approve';
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ if (routed) {
452
+ result.routed = routed;
453
+ }
454
+
455
+ // Write durable watch result file
456
+ const watchResult = writeWatchResult(root, result, payload);
457
+ result.watch_result_id = watchResult.result_id;
458
+
459
+ if (opts.json) {
460
+ console.log(JSON.stringify(result, null, 2));
461
+ } else {
462
+ console.log('');
463
+ if (result.deduplicated) {
464
+ console.log(chalk.yellow(` Watch event already recorded: ${result.event.event_id} (deduplicated)`));
465
+ if (result.intent) {
466
+ console.log(chalk.dim(` Linked intent: ${result.intent.intent_id} (${result.intent.status})`));
467
+ }
468
+ } else {
469
+ console.log(chalk.green(` Recorded watch event ${result.event.event_id}`));
470
+ console.log(chalk.green(` Created intent ${result.intent.intent_id} (${result.intent.status})`));
471
+ if (routed) {
472
+ const parts = [`triaged=${routed.triaged}`, `approved=${routed.approved}`];
473
+ if (routed.preferred_role) parts.push(`role=${routed.preferred_role}`);
474
+ if (routed.planned) parts.push('planned=true');
475
+ if (routed.started) parts.push(`started=true`);
476
+ if (routed.auto_start_error) parts.push(`start_error="${routed.auto_start_error}"`);
477
+ if (routed.auto_start_skipped) parts.push(`auto_start_skipped`);
478
+ console.log(chalk.cyan(` Route matched: ${parts.join(', ')}`));
479
+ }
480
+ }
481
+ console.log('');
482
+ }
483
+ process.exit(result.exitCode);
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Watch results inspection
488
+ // ---------------------------------------------------------------------------
489
+
490
+ function listOrShowWatchResults(opts) {
491
+ const root = requireIntakeWorkspaceOrExit(opts);
492
+ const resultsDir = join(root, '.agentxchain', 'watch-results');
493
+
494
+ if (!existsSync(resultsDir)) {
495
+ if (opts.json) {
496
+ console.log(JSON.stringify(opts.result ? { ok: false, error: 'no watch results found' } : { ok: true, results: [] }, null, 2));
497
+ } else {
498
+ console.log(chalk.dim(' No watch results yet.'));
499
+ }
500
+ process.exit(opts.result ? 1 : 0);
501
+ }
502
+
503
+ // Show a single result by ID or filename
504
+ if (opts.result) {
505
+ const record = loadWatchResult(resultsDir, opts.result);
506
+ if (!record) {
507
+ if (opts.json) {
508
+ console.log(JSON.stringify({ ok: false, error: `watch result not found: ${opts.result}` }, null, 2));
509
+ } else {
510
+ console.log(chalk.red(` Watch result not found: ${opts.result}`));
511
+ }
512
+ process.exit(1);
513
+ }
514
+ if (opts.json) {
515
+ console.log(JSON.stringify({ ok: true, ...record }, null, 2));
516
+ } else {
517
+ printWatchResult(record);
518
+ }
519
+ process.exit(0);
520
+ }
521
+
522
+ // List all results
523
+ const entries = readdirSync(resultsDir, { withFileTypes: true })
524
+ .filter((e) => e.isFile() && e.name.endsWith('.json'))
525
+ .map((e) => e.name)
526
+ .sort();
527
+
528
+ const records = [];
529
+ for (const name of entries) {
530
+ try {
531
+ const content = JSON.parse(readFileSync(join(resultsDir, name), 'utf8'));
532
+ records.push(content);
533
+ } catch {
534
+ // skip corrupt files
535
+ }
536
+ }
537
+
538
+ // Sort by timestamp descending (most recent first)
539
+ records.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
540
+
541
+ // Apply --limit if provided
542
+ const limit = opts.limit ? parseInt(opts.limit, 10) : 0;
543
+ const display = limit > 0 ? records.slice(0, limit) : records;
544
+
545
+ if (opts.json) {
546
+ console.log(JSON.stringify({ ok: true, total: records.length, results: display }, null, 2));
547
+ process.exit(0);
548
+ }
549
+
550
+ console.log('');
551
+ console.log(chalk.bold(` Watch Results (${records.length} total)`));
552
+ console.log('');
553
+
554
+ if (display.length === 0) {
555
+ console.log(chalk.dim(' No watch results yet.'));
556
+ console.log('');
557
+ process.exit(0);
558
+ }
559
+
560
+ for (const r of display) {
561
+ const status = formatResultStatus(r);
562
+ const category = r.payload?.category || 'unknown';
563
+ const repo = r.payload?.repo || '';
564
+ const ts = r.timestamp ? new Date(r.timestamp).toLocaleString() : 'unknown';
565
+ const dedup = r.deduplicated ? chalk.yellow(' [dedup]') : '';
566
+ const errors = r.errors?.length > 0 ? chalk.red(` (${r.errors.length} error${r.errors.length > 1 ? 's' : ''})`) : '';
567
+
568
+ console.log(` ${chalk.dim(r.result_id)} ${status}${dedup}${errors}`);
569
+ console.log(` ${chalk.cyan(category)} ${repo ? chalk.dim(repo) : ''} ${chalk.dim(ts)}`);
570
+ if (r.route?.run_id) {
571
+ console.log(` ${chalk.dim('run:')} ${r.route.run_id} ${chalk.dim('role:')} ${r.route.role || '?'}`);
572
+ }
573
+ }
574
+
575
+ if (limit > 0 && records.length > limit) {
576
+ console.log(chalk.dim(`\n ... and ${records.length - limit} more. Use --limit 0 to show all.`));
577
+ }
578
+
579
+ console.log('');
580
+ process.exit(0);
581
+ }
582
+
583
+ function loadWatchResult(resultsDir, idOrFile) {
584
+ // Try exact filename first
585
+ const directPath = join(resultsDir, idOrFile.endsWith('.json') ? idOrFile : `${idOrFile}.json`);
586
+ if (existsSync(directPath)) {
587
+ try { return JSON.parse(readFileSync(directPath, 'utf8')); } catch { return null; }
588
+ }
589
+
590
+ // Search by result_id prefix
591
+ const entries = readdirSync(resultsDir, { withFileTypes: true })
592
+ .filter((e) => e.isFile() && e.name.endsWith('.json'));
593
+ for (const entry of entries) {
594
+ try {
595
+ const content = JSON.parse(readFileSync(join(resultsDir, entry.name), 'utf8'));
596
+ if (content.result_id === idOrFile || content.result_id?.startsWith(idOrFile)) {
597
+ return content;
598
+ }
599
+ } catch {
600
+ // skip corrupt
601
+ }
602
+ }
603
+ return null;
604
+ }
605
+
606
+ function formatResultStatus(r) {
607
+ if (r.deduplicated) return chalk.yellow('deduplicated');
608
+ if (!r.route?.matched) return chalk.dim('unrouted');
609
+ if (r.route.started) return chalk.green('started');
610
+ if (r.route.planned) return chalk.blue('planned');
611
+ if (r.route.approved) return chalk.cyan('approved');
612
+ if (r.route.triaged) return chalk.white('triaged');
613
+ return chalk.dim('detected');
614
+ }
615
+
616
+ function printWatchResult(r) {
617
+ console.log('');
618
+ console.log(chalk.bold(' Watch Result'));
619
+ console.log(` ${chalk.dim('ID:')} ${r.result_id}`);
620
+ console.log(` ${chalk.dim('Time:')} ${r.timestamp ? new Date(r.timestamp).toLocaleString() : 'unknown'}`);
621
+ console.log(` ${chalk.dim('Event:')} ${r.event_id || 'none'}`);
622
+ console.log(` ${chalk.dim('Intent:')} ${r.intent_id || 'none'} (${r.intent_status || '?'})`);
623
+ console.log(` ${chalk.dim('Dedup:')} ${r.deduplicated ? 'yes' : 'no'}`);
624
+ console.log('');
625
+ console.log(chalk.bold(' Payload'));
626
+ console.log(` ${chalk.dim('Source:')} ${r.payload?.source || '?'}`);
627
+ console.log(` ${chalk.dim('Category:')} ${r.payload?.category || '?'}`);
628
+ console.log(` ${chalk.dim('Repo:')} ${r.payload?.repo || 'none'}`);
629
+ console.log(` ${chalk.dim('Ref:')} ${r.payload?.ref || 'none'}`);
630
+ console.log('');
631
+ console.log(chalk.bold(' Route'));
632
+ if (!r.route?.matched) {
633
+ console.log(chalk.dim(' No matching route.'));
634
+ } else {
635
+ console.log(` ${chalk.dim('Triaged:')} ${r.route.triaged ? 'yes' : 'no'}`);
636
+ console.log(` ${chalk.dim('Approved:')} ${r.route.approved ? 'yes' : 'no'}`);
637
+ console.log(` ${chalk.dim('Planned:')} ${r.route.planned ? 'yes' : 'no'}`);
638
+ console.log(` ${chalk.dim('Started:')} ${r.route.started ? 'yes' : 'no'}`);
639
+ console.log(` ${chalk.dim('Auto-start:')} ${r.route.auto_start ? 'yes' : 'no'}`);
640
+ if (r.route.preferred_role) console.log(` ${chalk.dim('Role hint:')} ${r.route.preferred_role}`);
641
+ if (r.route.run_id) console.log(` ${chalk.dim('Run ID:')} ${r.route.run_id}`);
642
+ if (r.route.role) console.log(` ${chalk.dim('Role:')} ${r.route.role}`);
643
+ }
644
+ if (r.errors?.length > 0) {
645
+ console.log('');
646
+ console.log(chalk.bold(' Errors'));
647
+ for (const err of r.errors) {
648
+ console.log(` ${chalk.red('•')} ${err}`);
649
+ }
650
+ }
651
+ console.log('');
652
+ }
653
+
156
654
  function pickNextAgent(root, lock, config) {
157
655
  return resolveNextAgent(root, config, lock).next;
158
656
  }
@@ -307,10 +805,15 @@ function log(type, msg) {
307
805
  console.log(` ${chalk.dim(time)} ${tags[type] || chalk.dim(type)} ${msg}`);
308
806
  }
309
807
 
310
- function startWatchDaemon() {
808
+ function startWatchDaemon(opts = {}) {
311
809
  const currentDir = dirname(fileURLToPath(import.meta.url));
312
810
  const cliBin = join(currentDir, '../../bin/agentxchain.js');
313
- const child = spawn(process.execPath, [cliBin, 'watch'], {
811
+ const args = [cliBin, 'watch'];
812
+ if (opts.eventDir) {
813
+ args.push('--event-dir', opts.eventDir);
814
+ if (opts.pollSeconds) args.push('--poll-seconds', String(opts.pollSeconds));
815
+ }
816
+ const child = spawn(process.execPath, args, {
314
817
  cwd: process.cwd(),
315
818
  detached: true,
316
819
  stdio: 'ignore',
@@ -319,8 +822,12 @@ function startWatchDaemon() {
319
822
  child.unref();
320
823
 
321
824
  const result = loadConfig();
322
- if (result) {
323
- writeFileSync(join(result.root, PID_FILE), String(child.pid));
825
+ let pidRoot = result?.root || null;
826
+ if (!pidRoot && opts.eventDir && existsSync(join(process.cwd(), 'agentxchain.json'))) {
827
+ pidRoot = process.cwd();
828
+ }
829
+ if (pidRoot) {
830
+ writeFileSync(join(pidRoot, PID_FILE), String(child.pid));
324
831
  }
325
832
 
326
833
  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,311 @@
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) {
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
+ payload: {
275
+ source: payload.source,
276
+ category: payload.category,
277
+ repo: payload.repo || null,
278
+ ref: payload.ref || null,
279
+ },
280
+ route: routed
281
+ ? {
282
+ matched: true,
283
+ triaged: routed.triaged === true,
284
+ approved: routed.approved === true,
285
+ planned: routed.planned === true,
286
+ started: routed.started === true,
287
+ auto_start: routed.auto_start === true || routed.started === true,
288
+ preferred_role: routed.preferred_role || null,
289
+ run_id: routed.run_id || null,
290
+ role: routed.role || null,
291
+ }
292
+ : { matched: false },
293
+ errors: errors.length > 0 ? errors : [],
294
+ };
295
+
296
+ const dir = join(root, '.agentxchain', 'watch-results');
297
+ mkdirSync(dir, { recursive: true });
298
+ const resultPath = join(dir, `${resultId}.json`);
299
+ safeWriteJson(resultPath, record);
300
+
301
+ return { result_id: resultId, result_path: resultPath };
302
+ }
303
+
304
+ function interpolateCharter(template, signal) {
305
+ if (!template || typeof template !== 'string') return template || 'Watch event intake';
306
+ if (!signal || typeof signal !== 'object') return template;
307
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
308
+ const value = signal[key];
309
+ return value !== undefined && value !== null ? String(value) : `{{${key}}}`;
310
+ });
311
+ }