agentxchain 0.8.8 → 2.2.0

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.
Files changed (74) hide show
  1. package/README.md +136 -136
  2. package/bin/agentxchain.js +186 -5
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +14 -6
  13. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  14. package/scripts/publish-from-tag.sh +88 -0
  15. package/scripts/release-postflight.sh +231 -0
  16. package/scripts/release-preflight.sh +167 -0
  17. package/src/commands/accept-turn.js +160 -0
  18. package/src/commands/approve-completion.js +80 -0
  19. package/src/commands/approve-transition.js +85 -0
  20. package/src/commands/dashboard.js +70 -0
  21. package/src/commands/init.js +516 -0
  22. package/src/commands/migrate.js +348 -0
  23. package/src/commands/multi.js +549 -0
  24. package/src/commands/plugin.js +157 -0
  25. package/src/commands/reject-turn.js +204 -0
  26. package/src/commands/resume.js +389 -0
  27. package/src/commands/status.js +196 -3
  28. package/src/commands/step.js +947 -0
  29. package/src/commands/template-list.js +33 -0
  30. package/src/commands/template-set.js +279 -0
  31. package/src/commands/validate.js +20 -11
  32. package/src/commands/verify.js +71 -0
  33. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  34. package/src/lib/adapters/local-cli-adapter.js +337 -0
  35. package/src/lib/adapters/manual-adapter.js +169 -0
  36. package/src/lib/blocked-state.js +94 -0
  37. package/src/lib/config.js +97 -1
  38. package/src/lib/context-compressor.js +121 -0
  39. package/src/lib/context-section-parser.js +220 -0
  40. package/src/lib/coordinator-acceptance.js +428 -0
  41. package/src/lib/coordinator-config.js +461 -0
  42. package/src/lib/coordinator-dispatch.js +276 -0
  43. package/src/lib/coordinator-gates.js +487 -0
  44. package/src/lib/coordinator-hooks.js +239 -0
  45. package/src/lib/coordinator-recovery.js +523 -0
  46. package/src/lib/coordinator-state.js +365 -0
  47. package/src/lib/cross-repo-context.js +247 -0
  48. package/src/lib/dashboard/bridge-server.js +284 -0
  49. package/src/lib/dashboard/file-watcher.js +93 -0
  50. package/src/lib/dashboard/state-reader.js +96 -0
  51. package/src/lib/dispatch-bundle.js +568 -0
  52. package/src/lib/dispatch-manifest.js +252 -0
  53. package/src/lib/gate-evaluator.js +285 -0
  54. package/src/lib/governed-state.js +2139 -0
  55. package/src/lib/governed-templates.js +145 -0
  56. package/src/lib/hook-runner.js +788 -0
  57. package/src/lib/normalized-config.js +539 -0
  58. package/src/lib/plugin-config-schema.js +192 -0
  59. package/src/lib/plugins.js +692 -0
  60. package/src/lib/protocol-conformance.js +291 -0
  61. package/src/lib/reference-conformance-adapter.js +858 -0
  62. package/src/lib/repo-observer.js +597 -0
  63. package/src/lib/repo.js +0 -31
  64. package/src/lib/schema.js +121 -0
  65. package/src/lib/schemas/turn-result.schema.json +205 -0
  66. package/src/lib/token-budget.js +206 -0
  67. package/src/lib/token-counter.js +27 -0
  68. package/src/lib/turn-paths.js +67 -0
  69. package/src/lib/turn-result-validator.js +496 -0
  70. package/src/lib/validation.js +137 -0
  71. package/src/templates/governed/api-service.json +31 -0
  72. package/src/templates/governed/cli-tool.json +30 -0
  73. package/src/templates/governed/generic.json +10 -0
  74. package/src/templates/governed/web-app.json +30 -0
@@ -0,0 +1,204 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { loadProjectContext, loadProjectState } from '../lib/config.js';
5
+ import { getActiveTurns, rejectGovernedTurn } from '../lib/governed-state.js';
6
+ import { validateStagedTurnResult } from '../lib/turn-result-validator.js';
7
+ import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
8
+ import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
9
+ import { getDispatchTurnDir, getTurnStagingResultPath } from '../lib/turn-paths.js';
10
+
11
+ export async function rejectTurnCommand(opts) {
12
+ const context = loadProjectContext();
13
+ if (!context) {
14
+ console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
15
+ process.exit(1);
16
+ }
17
+
18
+ const { root, config } = context;
19
+
20
+ if (config.protocol_mode !== 'governed') {
21
+ console.log(chalk.red('The reject-turn command is only available for governed projects.'));
22
+ console.log(chalk.dim('Legacy projects use: agentxchain claim / release'));
23
+ process.exit(1);
24
+ }
25
+
26
+ const state = loadProjectState(root, config);
27
+ if (!state) {
28
+ console.log(chalk.red('No governed state.json found. Run `agentxchain init --governed` first.'));
29
+ process.exit(1);
30
+ }
31
+
32
+ const validation = buildRejectionValidation(root, state, config, opts);
33
+ if (!validation.ok) {
34
+ console.log(chalk.red(validation.error));
35
+ process.exit(1);
36
+ }
37
+
38
+ const result = rejectGovernedTurn(root, config, validation.validationResult, {
39
+ turnId: validation.turn.turn_id,
40
+ reason: opts.reason,
41
+ reassign: Boolean(opts.reassign),
42
+ });
43
+ if (!result.ok) {
44
+ console.log(chalk.red(`Failed to reject turn: ${result.error}`));
45
+ process.exit(1);
46
+ }
47
+
48
+ if (!result.escalated) {
49
+ const bundleResult = writeDispatchBundle(root, result.state, config, { turnId: validation.turn.turn_id });
50
+ if (!bundleResult.ok) {
51
+ console.log(chalk.red(`Turn rejected but dispatch bundle rewrite failed: ${bundleResult.error}`));
52
+ process.exit(1);
53
+ }
54
+ printDispatchBundleWarnings(bundleResult);
55
+ }
56
+
57
+ const turn = result.turn || result.state?.active_turns?.[validation.turn.turn_id] || result.state?.current_turn;
58
+
59
+ console.log('');
60
+ if (result.escalated) {
61
+ console.log(chalk.yellow(' Turn Rejected And Escalated'));
62
+ } else {
63
+ console.log(chalk.yellow(' Turn Rejected For Retry'));
64
+ }
65
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
66
+ console.log('');
67
+ console.log(` ${chalk.dim('Turn:')} ${turn?.turn_id || '(unknown)'}`);
68
+ console.log(` ${chalk.dim('Role:')} ${turn?.assigned_role || '(unknown)'}`);
69
+ console.log(` ${chalk.dim('Failed stage:')} ${validation.validationResult.failed_stage || 'unknown'}`);
70
+ if (validation.validationResult.errors?.length && !opts.reassign) {
71
+ console.log(` ${chalk.dim('Reason:')} ${validation.validationResult.errors[0]}`);
72
+ }
73
+ if (opts.reason) {
74
+ console.log(` ${chalk.dim('Operator note:')} ${opts.reason}`);
75
+ }
76
+ console.log('');
77
+
78
+ if (result.escalated) {
79
+ const recovery = deriveRecoveryDescriptor(result.state);
80
+ if (recovery) {
81
+ console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
82
+ console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
83
+ console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
84
+ console.log(` ${chalk.dim('Turn:')} ${recovery.turn_retained ? 'retained' : 'cleared'}`);
85
+ if (recovery.detail) {
86
+ console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
87
+ }
88
+ } else {
89
+ console.log(` ${chalk.dim('Blocked on:')} ${result.state.blocked_on}`);
90
+ console.log(chalk.dim(' Resolve the escalation, then run agentxchain step to re-dispatch the failed turn.'));
91
+ }
92
+ } else {
93
+ console.log(` ${chalk.dim('Attempt:')} ${turn?.attempt || '(unknown)'}`);
94
+ console.log(` ${chalk.dim('Dispatch:')} ${getDispatchTurnDir(turn?.turn_id || validation.turn.turn_id)}/`);
95
+ if (opts.reassign) {
96
+ console.log(chalk.dim(' The turn was rejected for conflict and immediately re-dispatched with conflict context.'));
97
+ } else {
98
+ console.log(chalk.dim(' The retry bundle has been rewritten for the same assigned turn.'));
99
+ }
100
+ }
101
+ console.log('');
102
+ }
103
+
104
+ function buildRejectionValidation(root, state, config, opts) {
105
+ const resolution = resolveTargetTurn(state, opts.turn);
106
+ if (!resolution.ok) {
107
+ return resolution;
108
+ }
109
+
110
+ if (opts.reassign && !resolution.turn.conflict_state) {
111
+ return {
112
+ ok: false,
113
+ error: '--reassign is only valid for turns with persisted conflict_state.',
114
+ };
115
+ }
116
+
117
+ if (resolution.turn.conflict_state) {
118
+ return {
119
+ ok: true,
120
+ turn: resolution.turn,
121
+ validationResult: {
122
+ errors: resolution.turn.conflict_state.conflict_error?.conflicting_files?.length
123
+ ? [`File conflict detected: ${resolution.turn.conflict_state.conflict_error.conflicting_files.join(', ')}`]
124
+ : ['File conflict detected'],
125
+ failed_stage: 'conflict',
126
+ },
127
+ };
128
+ }
129
+
130
+ const projectedState = {
131
+ ...state,
132
+ active_turns: {
133
+ [resolution.turn.turn_id]: resolution.turn,
134
+ },
135
+ };
136
+ const validation = validateStagedTurnResult(root, projectedState, config, {
137
+ stagingPath: resolveStagingPath(root, resolution.turn.turn_id),
138
+ });
139
+ if (!validation.ok) {
140
+ return {
141
+ ok: true,
142
+ turn: resolution.turn,
143
+ validationResult: {
144
+ errors: validation.errors,
145
+ failed_stage: validation.stage || 'unknown',
146
+ },
147
+ };
148
+ }
149
+
150
+ if (!opts.reason || !opts.reason.trim()) {
151
+ return {
152
+ ok: false,
153
+ error: 'Staged turn result validates successfully. Supply --reason to reject it anyway.',
154
+ };
155
+ }
156
+
157
+ return {
158
+ ok: true,
159
+ turn: resolution.turn,
160
+ validationResult: {
161
+ errors: [opts.reason.trim()],
162
+ failed_stage: 'human_review',
163
+ },
164
+ };
165
+ }
166
+
167
+ function resolveTargetTurn(state, turnId) {
168
+ const activeTurns = getActiveTurns(state);
169
+
170
+ if (turnId) {
171
+ const turn = activeTurns[turnId];
172
+ if (!turn) {
173
+ return {
174
+ ok: false,
175
+ error: `No active turn found for --turn ${turnId}`,
176
+ };
177
+ }
178
+ return { ok: true, turn };
179
+ }
180
+
181
+ const turns = Object.values(activeTurns);
182
+ if (turns.length === 0) {
183
+ return { ok: false, error: 'No active turn to reject' };
184
+ }
185
+ if (turns.length > 1) {
186
+ return {
187
+ ok: false,
188
+ error: 'Multiple active turns are present. Re-run reject-turn with --turn <turn_id>.',
189
+ };
190
+ }
191
+
192
+ return { ok: true, turn: turns[0] };
193
+ }
194
+
195
+ function resolveStagingPath(root, turnId) {
196
+ const turnScopedPath = getTurnStagingResultPath(turnId);
197
+ return existsSync(join(root, turnScopedPath)) ? turnScopedPath : '.agentxchain/staging/turn-result.json';
198
+ }
199
+
200
+ function printDispatchBundleWarnings(bundleResult) {
201
+ for (const warning of bundleResult.warnings || []) {
202
+ console.log(chalk.yellow(`Dispatch bundle warning: ${warning}`));
203
+ }
204
+ }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * agentxchain resume — governed-only command.
3
+ *
4
+ * Transitions a governed project from idle/paused into an assigned turn
5
+ * with a concrete dispatch bundle. Per §44-§47 of the frozen spec:
6
+ *
7
+ * - governed-only (rejects legacy projects)
8
+ * - resolves target role from routing or --role override
9
+ * - if idle + no run_id → initializeGovernedRun() + assign
10
+ * - if paused + run_id exists → resume same run + assign
11
+ * - if paused + current_turn with failed status → re-dispatch same turn
12
+ * - if active + current_turn exists → reject (no double assignment)
13
+ * - materializes a turn-scoped dispatch bundle under .agentxchain/dispatch/turns/<turn_id>/
14
+ * - exits without waiting for turn completion
15
+ */
16
+
17
+ import chalk from 'chalk';
18
+ import { readFileSync, existsSync } from 'fs';
19
+ import { join } from 'path';
20
+ import { loadProjectContext, loadProjectState } from '../lib/config.js';
21
+ import {
22
+ initializeGovernedRun,
23
+ assignGovernedTurn,
24
+ markRunBlocked,
25
+ getActiveTurns,
26
+ getActiveTurnCount,
27
+ STATE_PATH,
28
+ } from '../lib/governed-state.js';
29
+ import { writeDispatchBundle, getDispatchTurnDir, getTurnStagingResultPath } from '../lib/dispatch-bundle.js';
30
+ import { finalizeDispatchManifest } from '../lib/dispatch-manifest.js';
31
+ import {
32
+ getDispatchAssignmentPath,
33
+ getDispatchContextPath,
34
+ getDispatchEffectiveContextPath,
35
+ getDispatchPromptPath,
36
+ } from '../lib/turn-paths.js';
37
+ import { safeWriteJson } from '../lib/safe-write.js';
38
+ import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
39
+ import { runHooks } from '../lib/hook-runner.js';
40
+
41
+ export async function resumeCommand(opts) {
42
+ const context = loadProjectContext();
43
+ if (!context) {
44
+ console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
45
+ process.exit(1);
46
+ }
47
+
48
+ const { root, config } = context;
49
+
50
+ if (config.protocol_mode !== 'governed') {
51
+ console.log(chalk.red('The resume command is only available for governed projects.'));
52
+ console.log(chalk.dim('Legacy projects use: agentxchain start'));
53
+ process.exit(1);
54
+ }
55
+
56
+ // Load governed state
57
+ const statePath = join(root, STATE_PATH);
58
+ if (!existsSync(statePath)) {
59
+ console.log(chalk.red('No governed state.json found. Run `agentxchain init --governed` first.'));
60
+ process.exit(1);
61
+ }
62
+
63
+ let state = loadProjectState(root, config);
64
+ if (!state) {
65
+ let parseError = 'Failed to parse state.json';
66
+ try {
67
+ JSON.parse(readFileSync(statePath, 'utf8'));
68
+ } catch (err) {
69
+ parseError = `Failed to parse state.json: ${err.message}`;
70
+ }
71
+ console.log(chalk.red(parseError));
72
+ process.exit(1);
73
+ }
74
+
75
+ // §47: active + turns present → reject (resume assigns new turns, not re-dispatches)
76
+ const activeCount = getActiveTurnCount(state);
77
+ const activeTurns = getActiveTurns(state);
78
+
79
+ if (state.status === 'active' && activeCount > 0) {
80
+ if (activeCount === 1) {
81
+ const turn = Object.values(activeTurns)[0];
82
+ console.log(chalk.yellow('A turn is already active:'));
83
+ console.log(` Turn: ${turn.turn_id}`);
84
+ console.log(` Role: ${turn.assigned_role}`);
85
+ console.log(` Phase: ${state.phase}`);
86
+ console.log('');
87
+ console.log(chalk.dim(`The dispatch bundle is at: ${getDispatchTurnDir(turn.turn_id)}/`));
88
+ } else {
89
+ console.log(chalk.yellow(`${activeCount} turns are already active:`));
90
+ for (const turn of Object.values(activeTurns)) {
91
+ const statusLabel = turn.status === 'conflicted' ? chalk.red('conflicted') : turn.status;
92
+ console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${statusLabel})`);
93
+ }
94
+ console.log('');
95
+ }
96
+ console.log(chalk.dim('Complete or accept/reject active turns before resuming.'));
97
+ console.log(chalk.dim('Use agentxchain step --resume to continue waiting for an active turn.'));
98
+ process.exit(1);
99
+ }
100
+
101
+ // §47: paused + retained turn with failed/retrying status → re-dispatch same turn
102
+ if (state.status === 'paused' && activeCount > 0) {
103
+ // Resolve which turn to re-dispatch
104
+ let retainedTurn = null;
105
+ if (opts.turn) {
106
+ retainedTurn = activeTurns[opts.turn];
107
+ if (!retainedTurn) {
108
+ console.log(chalk.red(`No active turn found for --turn ${opts.turn}`));
109
+ process.exit(1);
110
+ }
111
+ } else if (activeCount > 1) {
112
+ console.log(chalk.red('Multiple retained turns exist. Use --turn <id> to specify which to re-dispatch.'));
113
+ for (const turn of Object.values(activeTurns)) {
114
+ console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${turn.status})`);
115
+ }
116
+ console.log('');
117
+ console.log(chalk.dim('Example: agentxchain resume --turn <turn_id>'));
118
+ process.exit(1);
119
+ } else {
120
+ retainedTurn = Object.values(activeTurns)[0];
121
+ }
122
+
123
+ const turnStatus = retainedTurn.status;
124
+ if (turnStatus === 'failed' || turnStatus === 'retrying') {
125
+ console.log(chalk.yellow(`Re-dispatching failed turn: ${retainedTurn.turn_id}`));
126
+ console.log(` Role: ${retainedTurn.assigned_role}`);
127
+ console.log(` Attempt: ${retainedTurn.attempt}`);
128
+ console.log('');
129
+
130
+ // Reactivate the run
131
+ state.status = 'active';
132
+ state.blocked_on = null;
133
+ safeWriteJson(statePath, state);
134
+
135
+ // Write dispatch bundle for the existing turn
136
+ const bundleResult = writeDispatchBundle(root, state, config);
137
+ if (!bundleResult.ok) {
138
+ console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
139
+ process.exit(1);
140
+ }
141
+ printDispatchBundleWarnings(bundleResult);
142
+
143
+ // after_dispatch hooks with bundle-core tamper protection
144
+ const hooksConfig = config.hooks || {};
145
+ if (hooksConfig.after_dispatch?.length > 0) {
146
+ const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, retainedTurn);
147
+ if (!afterDispatchResult.ok) {
148
+ process.exit(1);
149
+ }
150
+ }
151
+
152
+ printDispatchSummary(state, config, retainedTurn);
153
+ return;
154
+ }
155
+ }
156
+
157
+ // §47: idle + no run_id → initialize new run
158
+ if (state.status === 'idle' && !state.run_id) {
159
+ const initResult = initializeGovernedRun(root, config);
160
+ if (!initResult.ok) {
161
+ console.log(chalk.red(`Failed to initialize run: ${initResult.error}`));
162
+ process.exit(1);
163
+ }
164
+ state = initResult.state;
165
+ console.log(chalk.green(`Initialized governed run: ${state.run_id}`));
166
+ }
167
+
168
+ // §47: paused + run_id exists → resume same run
169
+ if (state.status === 'paused' && state.run_id) {
170
+ state.status = 'active';
171
+ state.blocked_on = null;
172
+ state.escalation = null;
173
+ safeWriteJson(statePath, state);
174
+ console.log(chalk.green(`Resumed governed run: ${state.run_id}`));
175
+ }
176
+
177
+ // Resolve target role
178
+ const roleId = resolveTargetRole(opts, state, config);
179
+ if (!roleId) {
180
+ process.exit(1);
181
+ }
182
+
183
+ // Assign the turn
184
+ const assignResult = assignGovernedTurn(root, config, roleId);
185
+ if (!assignResult.ok) {
186
+ if (assignResult.error_code?.startsWith('hook_') || assignResult.error_code === 'hook_blocked') {
187
+ printAssignmentHookFailure(assignResult, roleId);
188
+ }
189
+ console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
190
+ process.exit(1);
191
+ }
192
+ state = assignResult.state;
193
+
194
+ // Write dispatch bundle
195
+ const bundleResult = writeDispatchBundle(root, state, config);
196
+ if (!bundleResult.ok) {
197
+ console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
198
+ process.exit(1);
199
+ }
200
+ printDispatchBundleWarnings(bundleResult);
201
+
202
+ // after_dispatch hooks with bundle-core tamper protection
203
+ const hooksConfig = config.hooks || {};
204
+ const turn = state.current_turn;
205
+ if (hooksConfig.after_dispatch?.length > 0) {
206
+ const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, turn);
207
+ if (!afterDispatchResult.ok) {
208
+ process.exit(1);
209
+ }
210
+ }
211
+
212
+ // Finalize dispatch manifest (seals bundle after hooks)
213
+ const manifestResult = finalizeDispatchManifest(root, turn.turn_id, {
214
+ run_id: state.run_id,
215
+ role: turn.assigned_role,
216
+ });
217
+ if (!manifestResult.ok) {
218
+ console.log(chalk.red(`Failed to finalize dispatch manifest: ${manifestResult.error}`));
219
+ process.exit(1);
220
+ }
221
+
222
+ printDispatchSummary(state, config);
223
+ }
224
+
225
+ // ── Helpers ─────────────────────────────────────────────────────────────────
226
+
227
+ function resolveTargetRole(opts, state, config) {
228
+ const phase = state.phase;
229
+ const routing = config.routing?.[phase];
230
+
231
+ if (opts.role) {
232
+ // Validate the override
233
+ if (!config.roles?.[opts.role]) {
234
+ console.log(chalk.red(`Unknown role: "${opts.role}"`));
235
+ console.log(chalk.dim(`Available roles: ${Object.keys(config.roles || {}).join(', ')}`));
236
+ return null;
237
+ }
238
+ if (routing?.allowed_next_roles && !routing.allowed_next_roles.includes(opts.role) && opts.role !== 'human') {
239
+ console.log(chalk.yellow(`Warning: role "${opts.role}" is not in allowed_next_roles for phase "${phase}".`));
240
+ console.log(chalk.dim(`Allowed: ${routing.allowed_next_roles.join(', ')}`));
241
+ // Allow it as an override, but warn
242
+ }
243
+ return opts.role;
244
+ }
245
+
246
+ // Default: use the phase's entry_role
247
+ if (routing?.entry_role) {
248
+ return routing.entry_role;
249
+ }
250
+
251
+ // Fallback: first role in config
252
+ const roles = Object.keys(config.roles || {});
253
+ if (roles.length > 0) {
254
+ console.log(chalk.yellow(`No entry_role for phase "${phase}". Defaulting to "${roles[0]}".`));
255
+ return roles[0];
256
+ }
257
+
258
+ console.log(chalk.red('No roles defined in config.'));
259
+ return null;
260
+ }
261
+
262
+ function runAfterDispatchHooks(root, hooksConfig, state, turn) {
263
+ const turnId = turn.turn_id;
264
+ const roleId = turn.assigned_role;
265
+
266
+ const afterDispatchHooks = runHooks(root, hooksConfig, 'after_dispatch', {
267
+ turn_id: turnId,
268
+ role_id: roleId,
269
+ bundle_path: getDispatchTurnDir(turnId),
270
+ bundle_files: ['ASSIGNMENT.json', 'PROMPT.md', 'CONTEXT.md'],
271
+ }, {
272
+ run_id: state.run_id,
273
+ turn_id: turnId,
274
+ protectedPaths: [
275
+ getDispatchAssignmentPath(turnId),
276
+ getDispatchPromptPath(turnId),
277
+ getDispatchContextPath(turnId),
278
+ getDispatchEffectiveContextPath(turnId),
279
+ ],
280
+ });
281
+
282
+ if (!afterDispatchHooks.ok) {
283
+ const hookName = afterDispatchHooks.blocker?.hook_name
284
+ || afterDispatchHooks.tamper?.file
285
+ || afterDispatchHooks.results?.find((e) => e.hook_name)?.hook_name
286
+ || 'unknown';
287
+ const detail = afterDispatchHooks.blocker?.message
288
+ || afterDispatchHooks.tamper?.message
289
+ || `after_dispatch hook blocked dispatch for turn ${turnId}`;
290
+ const errorCode = afterDispatchHooks.tamper?.error_code || 'hook_blocked';
291
+
292
+ markRunBlocked(root, {
293
+ blockedOn: `hook:after_dispatch:${hookName}`,
294
+ category: 'dispatch_error',
295
+ recovery: {
296
+ typed_reason: afterDispatchHooks.tamper ? 'hook_tamper' : 'hook_block',
297
+ owner: 'human',
298
+ recovery_action: `Fix or reconfigure the hook, then rerun agentxchain resume`,
299
+ turn_retained: true,
300
+ detail,
301
+ },
302
+ turnId,
303
+ });
304
+
305
+ printDispatchHookFailure({
306
+ turnId,
307
+ roleId,
308
+ hookName,
309
+ error: detail,
310
+ errorCode,
311
+ hookResults: afterDispatchHooks,
312
+ });
313
+
314
+ return { ok: false };
315
+ }
316
+
317
+ return { ok: true };
318
+ }
319
+
320
+ function printDispatchHookFailure({ turnId, roleId, hookName, error, hookResults }) {
321
+ const isTamper = hookResults?.tamper;
322
+ console.log('');
323
+ console.log(chalk.yellow(' Dispatch Blocked By Hook'));
324
+ console.log(chalk.dim(' ' + '-'.repeat(44)));
325
+ console.log('');
326
+ console.log(` ${chalk.dim('Turn:')} ${turnId || '(unknown)'}`);
327
+ console.log(` ${chalk.dim('Role:')} ${roleId || '(unknown)'}`);
328
+ console.log(` ${chalk.dim('Hook:')} ${hookName}`);
329
+ console.log(` ${chalk.dim('Error:')} ${error}`);
330
+ console.log(` ${chalk.dim('Reason:')} ${isTamper ? 'hook_tamper' : 'hook_block'}`);
331
+ console.log(` ${chalk.dim('Owner:')} human`);
332
+ console.log(` ${chalk.dim('Action:')} Fix or reconfigure the hook, then rerun agentxchain resume`);
333
+ console.log('');
334
+ }
335
+
336
+ function printDispatchBundleWarnings(bundleResult) {
337
+ for (const warning of bundleResult.warnings || []) {
338
+ console.log(chalk.yellow(`Dispatch bundle warning: ${warning}`));
339
+ }
340
+ }
341
+
342
+ function printAssignmentHookFailure(result, roleId) {
343
+ const recovery = deriveRecoveryDescriptor(result.state);
344
+ const hookName = result.hookResults?.blocker?.hook_name
345
+ || result.hookResults?.results?.find((entry) => entry.hook_name)?.hook_name
346
+ || '(unknown)';
347
+
348
+ console.log('');
349
+ console.log(chalk.yellow(' Turn Assignment Blocked By Hook'));
350
+ console.log(chalk.dim(' ' + '-'.repeat(44)));
351
+ console.log('');
352
+ console.log(` ${chalk.dim('Role:')} ${roleId || '(unknown)'}`);
353
+ console.log(` ${chalk.dim('Phase:')} ${result.state?.phase || '(unknown)'}`);
354
+ console.log(` ${chalk.dim('Hook:')} ${hookName}`);
355
+ console.log(` ${chalk.dim('Error:')} ${result.error}`);
356
+ if (recovery) {
357
+ console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
358
+ console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
359
+ console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
360
+ if (recovery.detail) {
361
+ console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
362
+ }
363
+ } else {
364
+ console.log(` ${chalk.dim('Action:')} Fix or reconfigure hook "${hookName}", then rerun agentxchain resume --role ${roleId}`);
365
+ }
366
+ console.log('');
367
+ }
368
+
369
+ function printDispatchSummary(state, config, explicitTurn) {
370
+ const turn = explicitTurn || state.current_turn;
371
+ const role = config.roles?.[turn.assigned_role];
372
+
373
+ console.log('');
374
+ console.log(chalk.bold(' Turn Assigned'));
375
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
376
+ console.log('');
377
+ console.log(` ${chalk.dim('Turn:')} ${turn.turn_id}`);
378
+ console.log(` ${chalk.dim('Role:')} ${role?.title || turn.assigned_role}`);
379
+ console.log(` ${chalk.dim('Phase:')} ${state.phase}`);
380
+ console.log(` ${chalk.dim('Runtime:')} ${turn.runtime_id}`);
381
+ console.log(` ${chalk.dim('Attempt:')} ${turn.attempt}`);
382
+ console.log('');
383
+ console.log(` ${chalk.dim('Dispatch bundle:')} ${getDispatchTurnDir(turn.turn_id)}/`);
384
+ console.log(` ${chalk.dim('Prompt:')} ${getDispatchTurnDir(turn.turn_id)}/PROMPT.md`);
385
+ console.log(` ${chalk.dim('Submit result to:')} ${getTurnStagingResultPath(turn.turn_id)}`);
386
+ console.log('');
387
+ console.log(chalk.dim(' When done, run: agentxchain validate --mode turn'));
388
+ console.log('');
389
+ }