agentxchain 2.155.21 → 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 +1 -0
- package/bin/agentxchain.js +9 -1
- package/package.json +1 -1
- package/scripts/release-bump.sh +2 -0
- package/src/commands/watch.js +514 -7
- package/src/lib/config.js +5 -1
- package/src/lib/governed-state.js +93 -0
- package/src/lib/intake.js +16 -2
- package/src/lib/watch-events.js +311 -0
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 |
|
package/bin/agentxchain.js
CHANGED
|
@@ -248,8 +248,16 @@ generateCmd
|
|
|
248
248
|
|
|
249
249
|
program
|
|
250
250
|
.command('watch')
|
|
251
|
-
.description('Watch lock.json
|
|
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
package/scripts/release-bump.sh
CHANGED
|
@@ -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
|
)
|
package/src/commands/watch.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
323
|
-
|
|
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/config.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
reconcileApprovalPausesWithConfig,
|
|
10
10
|
reconcileBudgetStatusWithConfig,
|
|
11
11
|
reconcileRecoveryActionsWithConfig,
|
|
12
|
+
recoverStaleIdleExpansionRun,
|
|
12
13
|
} from './governed-state.js';
|
|
13
14
|
|
|
14
15
|
function attachLegacyCurrentTurnAlias(state) {
|
|
@@ -160,7 +161,10 @@ export function loadProjectState(root, config) {
|
|
|
160
161
|
stateData = reconciledBudget.state;
|
|
161
162
|
const reconciledRecovery = reconcileRecoveryActionsWithConfig(stateData, config);
|
|
162
163
|
stateData = reconciledRecovery.state;
|
|
163
|
-
|
|
164
|
+
// BUG-75: recover stale idle-expansion runs missing charter_materialization_pending
|
|
165
|
+
const staleRecovery = recoverStaleIdleExpansionRun(root, stateData);
|
|
166
|
+
stateData = staleRecovery.state;
|
|
167
|
+
if (normalized.changed || reconciledApprovals.changed || reconciledBudget.changed || reconciledRecovery.changed || staleRecovery.changed) {
|
|
164
168
|
safeWriteJson(filePath, stateData);
|
|
165
169
|
}
|
|
166
170
|
}
|
|
@@ -2500,6 +2500,99 @@ export function normalizeGovernedStateShape(state) {
|
|
|
2500
2500
|
return { state: stripLegacyCurrentTurn(nextState), changed };
|
|
2501
2501
|
}
|
|
2502
2502
|
|
|
2503
|
+
// BUG-75: Recover stale idle-expansion runs created before BUG-74 that lack
|
|
2504
|
+
// charter_materialization_pending. These runs loop on gate_semantic_coverage
|
|
2505
|
+
// because the PM prompt never receives the materialization directive.
|
|
2506
|
+
const INTAKE_EVENTS_DIR = '.agentxchain/intake/events';
|
|
2507
|
+
|
|
2508
|
+
export function recoverStaleIdleExpansionRun(root, state) {
|
|
2509
|
+
if (!state || typeof state !== 'object') {
|
|
2510
|
+
return { state, changed: false };
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
// Only recover active planning runs missing the materialization flag
|
|
2514
|
+
if (
|
|
2515
|
+
state.status !== 'active'
|
|
2516
|
+
|| state.phase !== 'planning'
|
|
2517
|
+
|| state.charter_materialization_pending
|
|
2518
|
+
) {
|
|
2519
|
+
return { state, changed: false };
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// Trace the run back to its intake intent via provenance
|
|
2523
|
+
const intentId = state.provenance?.intake_intent_id;
|
|
2524
|
+
if (!intentId) {
|
|
2525
|
+
return { state, changed: false };
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// Read the intent file
|
|
2529
|
+
const intentPath = join(root, INTAKE_INTENTS_DIR, `${intentId}.json`);
|
|
2530
|
+
if (!existsSync(intentPath)) {
|
|
2531
|
+
return { state, changed: false };
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
let intent;
|
|
2535
|
+
try {
|
|
2536
|
+
intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
2537
|
+
} catch {
|
|
2538
|
+
return { state, changed: false };
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
const eventId = intent.event_id;
|
|
2542
|
+
if (!eventId) {
|
|
2543
|
+
return { state, changed: false };
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// Read the event file to check the category
|
|
2547
|
+
const eventPath = join(root, INTAKE_EVENTS_DIR, `${eventId}.json`);
|
|
2548
|
+
if (!existsSync(eventPath)) {
|
|
2549
|
+
return { state, changed: false };
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
let event;
|
|
2553
|
+
try {
|
|
2554
|
+
event = JSON.parse(readFileSync(eventPath, 'utf8'));
|
|
2555
|
+
} catch {
|
|
2556
|
+
return { state, changed: false };
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
if (event.category !== 'pm_idle_expansion_derived') {
|
|
2560
|
+
return { state, changed: false };
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// Reconstruct charter_materialization_pending from the intake context
|
|
2564
|
+
const recoveredState = {
|
|
2565
|
+
...state,
|
|
2566
|
+
charter_materialization_pending: {
|
|
2567
|
+
charter: intent.charter || null,
|
|
2568
|
+
acceptance_contract: Array.isArray(intent.acceptance_contract)
|
|
2569
|
+
? intent.acceptance_contract
|
|
2570
|
+
: [],
|
|
2571
|
+
suppressed_transition: 'implementation',
|
|
2572
|
+
source_turn_id: null,
|
|
2573
|
+
recorded_at: new Date().toISOString(),
|
|
2574
|
+
},
|
|
2575
|
+
};
|
|
2576
|
+
|
|
2577
|
+
// Emit recovery event
|
|
2578
|
+
emitRunEvent(root, 'charter_materialization_required', {
|
|
2579
|
+
run_id: state.run_id,
|
|
2580
|
+
phase: state.phase,
|
|
2581
|
+
status: state.status,
|
|
2582
|
+
payload: {
|
|
2583
|
+
suppressed_transition: 'implementation',
|
|
2584
|
+
reason: 'Stale run from idle-expansion-derived intent recovered missing charter_materialization_pending flag.',
|
|
2585
|
+
new_intake_charter: intent.charter || null,
|
|
2586
|
+
source: 'stale_run_recovery',
|
|
2587
|
+
recovered_missing_flag: true,
|
|
2588
|
+
intent_id: intentId,
|
|
2589
|
+
event_id: eventId,
|
|
2590
|
+
},
|
|
2591
|
+
});
|
|
2592
|
+
|
|
2593
|
+
return { state: recoveredState, changed: true };
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2503
2596
|
export function markRunBlocked(root, details) {
|
|
2504
2597
|
const state = readState(root);
|
|
2505
2598
|
if (!state) {
|
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
|
+
}
|