agentxchain 2.47.0 → 2.49.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.
@@ -0,0 +1,265 @@
1
+ import chalk from 'chalk';
2
+ import { loadProjectContext } from '../lib/config.js';
3
+ import {
4
+ SCHEDULE_STATE_PATH,
5
+ listSchedules,
6
+ updateScheduleState,
7
+ evaluateScheduleLaunchEligibility,
8
+ } from '../lib/run-schedule.js';
9
+ import { executeGovernedRun } from './run.js';
10
+
11
+ function loadScheduleContext() {
12
+ const context = loadProjectContext();
13
+ if (!context) {
14
+ console.error(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
15
+ process.exitCode = 1;
16
+ return null;
17
+ }
18
+ if (context.config.protocol_mode !== 'governed') {
19
+ console.error(chalk.red('The schedule command is only available for governed projects.'));
20
+ process.exitCode = 1;
21
+ return null;
22
+ }
23
+ return context;
24
+ }
25
+
26
+ function resolveScheduleEntries(context, scheduleId, at) {
27
+ const entries = listSchedules(context.root, context.config, { at });
28
+ if (!scheduleId) {
29
+ return { ok: true, entries };
30
+ }
31
+
32
+ const matched = entries.find((entry) => entry.id === scheduleId);
33
+ if (!matched) {
34
+ return { ok: false, error: `Unknown schedule: ${scheduleId}` };
35
+ }
36
+
37
+ return { ok: true, entries: [matched] };
38
+ }
39
+
40
+ function printScheduleTable(entries) {
41
+ if (entries.length === 0) {
42
+ console.log(chalk.dim('No schedules configured.'));
43
+ return;
44
+ }
45
+
46
+ const header = [
47
+ pad('Schedule', 24),
48
+ pad('Enabled', 8),
49
+ pad('Every', 8),
50
+ pad('Due', 6),
51
+ pad('Next Due', 24),
52
+ pad('Last Status', 18),
53
+ pad('Last Run', 14),
54
+ ].join(' ');
55
+ console.log(chalk.bold(header));
56
+ console.log(chalk.dim('─'.repeat(header.length)));
57
+
58
+ for (const entry of entries) {
59
+ console.log([
60
+ pad(entry.id, 24),
61
+ pad(entry.enabled ? 'yes' : 'no', 8),
62
+ pad(`${entry.every_minutes}m`, 8),
63
+ pad(entry.due ? 'yes' : 'no', 6),
64
+ pad(entry.next_due_at || '—', 24),
65
+ pad(entry.last_status || entry.last_skip_reason || '—', 18),
66
+ pad((entry.last_run_id || '—').slice(0, 12), 14),
67
+ ].join(' '));
68
+ }
69
+ }
70
+
71
+ function pad(value, width) {
72
+ return String(value || '').padEnd(width);
73
+ }
74
+
75
+ function buildScheduleProvenance(entry) {
76
+ return {
77
+ trigger: 'schedule',
78
+ created_by: 'operator',
79
+ trigger_reason: entry.trigger_reason || `schedule:${entry.id}`,
80
+ };
81
+ }
82
+
83
+ async function runDueSchedules(context, opts = {}) {
84
+ const resolved = resolveScheduleEntries(context, opts.schedule, opts.at);
85
+ if (!resolved.ok) {
86
+ return { ok: false, exitCode: 1, error: resolved.error, results: [] };
87
+ }
88
+
89
+ const nowIso = opts.at || new Date().toISOString();
90
+ const results = [];
91
+
92
+ for (const entry of resolved.entries) {
93
+ if (!entry.enabled) {
94
+ results.push({ id: entry.id, action: 'disabled' });
95
+ continue;
96
+ }
97
+ if (!entry.due) {
98
+ results.push({ id: entry.id, action: 'not_due', next_due_at: entry.next_due_at });
99
+ continue;
100
+ }
101
+
102
+ const eligibility = evaluateScheduleLaunchEligibility(context.root, context.config);
103
+ if (!eligibility.ok) {
104
+ updateScheduleState(context.root, context.config, entry.id, (record) => ({
105
+ ...record,
106
+ last_skip_at: nowIso,
107
+ last_skip_reason: eligibility.reason,
108
+ }));
109
+ results.push({
110
+ id: entry.id,
111
+ action: 'skipped',
112
+ reason: eligibility.reason,
113
+ project_status: eligibility.status,
114
+ });
115
+ continue;
116
+ }
117
+
118
+ if (!opts.json) {
119
+ console.log(chalk.cyan(`Schedule due: ${entry.id}`));
120
+ }
121
+ const execution = await executeGovernedRun(context, {
122
+ provenance: buildScheduleProvenance(entry),
123
+ maxTurns: entry.max_turns,
124
+ autoApprove: entry.auto_approve,
125
+ role: entry.initial_role || undefined,
126
+ report: true,
127
+ allowBlockedRestart: false,
128
+ requireFreshStart: true,
129
+ allowedFreshStatuses: ['idle', 'completed'],
130
+ log: opts.json ? () => {} : console.log,
131
+ });
132
+
133
+ if (execution.skipped) {
134
+ updateScheduleState(context.root, context.config, entry.id, (record) => ({
135
+ ...record,
136
+ last_skip_at: nowIso,
137
+ last_skip_reason: execution.skipReason,
138
+ }));
139
+ results.push({
140
+ id: entry.id,
141
+ action: 'skipped',
142
+ reason: execution.skipReason,
143
+ });
144
+ continue;
145
+ }
146
+
147
+ const runId = execution.result?.state?.run_id || null;
148
+ const startedAt = execution.result?.state?.created_at || nowIso;
149
+ updateScheduleState(context.root, context.config, entry.id, (record) => ({
150
+ ...record,
151
+ last_started_at: startedAt,
152
+ last_finished_at: new Date().toISOString(),
153
+ last_run_id: runId,
154
+ last_status: execution.result?.stop_reason || (execution.exitCode === 0 ? 'completed' : 'launch_failed'),
155
+ last_skip_at: null,
156
+ last_skip_reason: null,
157
+ }));
158
+ results.push({
159
+ id: entry.id,
160
+ action: 'ran',
161
+ run_id: runId,
162
+ stop_reason: execution.result?.stop_reason || null,
163
+ exit_code: execution.exitCode,
164
+ });
165
+
166
+ if (execution.exitCode !== 0) {
167
+ return { ok: false, exitCode: execution.exitCode, results };
168
+ }
169
+ }
170
+
171
+ return { ok: true, exitCode: 0, results };
172
+ }
173
+
174
+ export async function scheduleListCommand(opts) {
175
+ const context = loadScheduleContext();
176
+ if (!context) return;
177
+
178
+ const resolved = resolveScheduleEntries(context, opts.schedule, opts.at);
179
+ if (!resolved.ok) {
180
+ console.error(chalk.red(resolved.error));
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+
185
+ if (opts.json) {
186
+ console.log(JSON.stringify({
187
+ schedules: resolved.entries,
188
+ state_file: SCHEDULE_STATE_PATH,
189
+ }, null, 2));
190
+ return;
191
+ }
192
+
193
+ printScheduleTable(resolved.entries);
194
+ }
195
+
196
+ export async function scheduleRunDueCommand(opts) {
197
+ const context = loadScheduleContext();
198
+ if (!context) return;
199
+
200
+ const result = await runDueSchedules(context, opts);
201
+ if (opts.json) {
202
+ console.log(JSON.stringify(result, null, 2));
203
+ } else if (!result.ok) {
204
+ console.error(chalk.red(result.error || 'Scheduled run failed'));
205
+ } else if (result.results.length === 0) {
206
+ console.log(chalk.dim('No schedules configured.'));
207
+ } else {
208
+ for (const entry of result.results) {
209
+ if (entry.action === 'ran') {
210
+ console.log(chalk.green(`Schedule ran: ${entry.id} (${entry.run_id || 'no run id'})`));
211
+ } else if (entry.action === 'skipped') {
212
+ console.log(chalk.yellow(`Schedule skipped: ${entry.id} (${entry.reason})`));
213
+ } else if (entry.action === 'not_due') {
214
+ console.log(chalk.dim(`Schedule not due: ${entry.id}`));
215
+ } else if (entry.action === 'disabled') {
216
+ console.log(chalk.dim(`Schedule disabled: ${entry.id}`));
217
+ }
218
+ }
219
+ }
220
+
221
+ process.exitCode = result.exitCode;
222
+ }
223
+
224
+ export async function scheduleDaemonCommand(opts) {
225
+ const context = loadScheduleContext();
226
+ if (!context) return;
227
+
228
+ const pollSeconds = Number.parseInt(opts.pollSeconds ?? '60', 10);
229
+ const maxCycles = opts.maxCycles != null ? Number.parseInt(opts.maxCycles, 10) : null;
230
+ if (!Number.isInteger(pollSeconds) || pollSeconds < 1) {
231
+ console.error(chalk.red('--poll-seconds must be an integer >= 1'));
232
+ process.exitCode = 1;
233
+ return;
234
+ }
235
+ if (maxCycles !== null && (!Number.isInteger(maxCycles) || maxCycles < 1)) {
236
+ console.error(chalk.red('--max-cycles must be an integer >= 1'));
237
+ process.exitCode = 1;
238
+ return;
239
+ }
240
+
241
+ let cycle = 0;
242
+ if (!opts.json) {
243
+ console.log(chalk.bold('AgentXchain Schedule Daemon'));
244
+ console.log(chalk.dim(` Poll: ${pollSeconds}s`));
245
+ console.log(chalk.dim(` State: ${SCHEDULE_STATE_PATH}`));
246
+ console.log('');
247
+ }
248
+
249
+ while (true) {
250
+ cycle += 1;
251
+ const result = await runDueSchedules(context, opts);
252
+ if (opts.json) {
253
+ console.log(JSON.stringify({ cycle, ...result }));
254
+ }
255
+ if (!result.ok) {
256
+ process.exitCode = result.exitCode;
257
+ return;
258
+ }
259
+ if (maxCycles !== null && cycle >= maxCycles) {
260
+ process.exitCode = 0;
261
+ return;
262
+ }
263
+ await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000));
264
+ }
265
+ }
package/src/lib/export.js CHANGED
@@ -31,6 +31,8 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
31
31
  '.agentxchain/hook-annotations.jsonl',
32
32
  '.agentxchain/notification-audit.jsonl',
33
33
  '.agentxchain/run-history.jsonl',
34
+ '.agentxchain/events.jsonl',
35
+ '.agentxchain/schedule-state.json',
34
36
  '.agentxchain/dispatch',
35
37
  '.agentxchain/staging',
36
38
  '.agentxchain/transactions/accept',
@@ -53,6 +55,8 @@ export const RUN_RESTORE_ROOTS = [
53
55
  '.agentxchain/hook-annotations.jsonl',
54
56
  '.agentxchain/notification-audit.jsonl',
55
57
  '.agentxchain/run-history.jsonl',
58
+ '.agentxchain/events.jsonl',
59
+ '.agentxchain/schedule-state.json',
56
60
  '.agentxchain/dispatch',
57
61
  '.agentxchain/staging',
58
62
  '.agentxchain/transactions/accept',
@@ -40,6 +40,7 @@ import { getMaxConcurrentTurns } from './normalized-config.js';
40
40
  import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
41
41
  import { runHooks } from './hook-runner.js';
42
42
  import { emitNotifications } from './notification-runner.js';
43
+ import { emitRunEvent } from './run-events.js';
43
44
  import { writeSessionCheckpoint } from './session-checkpoint.js';
44
45
  import { recordRunHistory } from './run-history.js';
45
46
  import { buildDefaultRunProvenance } from './run-provenance.js';
@@ -1751,6 +1752,12 @@ export function raiseOperatorEscalation(root, config, details) {
1751
1752
  detail,
1752
1753
  recovery_action: recoveryAction,
1753
1754
  }, targetTurn);
1755
+ emitRunEvent(root, 'escalation_raised', {
1756
+ run_id: blocked.state.run_id,
1757
+ phase: blocked.state.phase,
1758
+ status: 'blocked',
1759
+ payload: { source: 'operator', reason },
1760
+ });
1754
1761
 
1755
1762
  return {
1756
1763
  ok: true,
@@ -1794,6 +1801,12 @@ export function reactivateGovernedRun(root, state, details = {}) {
1794
1801
  resolved_via: details.via || 'unknown',
1795
1802
  previous_escalation: state.escalation || null,
1796
1803
  }, state.escalation?.from_turn_id ? getActiveTurns(state)[state.escalation.from_turn_id] || getActiveTurn(state) : getActiveTurn(state));
1804
+ emitRunEvent(details.root || root, 'escalation_resolved', {
1805
+ run_id: nextState.run_id,
1806
+ phase: nextState.phase,
1807
+ status: nextState.status,
1808
+ payload: { resolved_via: details.via || 'unknown' },
1809
+ });
1797
1810
  }
1798
1811
 
1799
1812
  return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
@@ -1846,6 +1859,12 @@ export function initializeGovernedRun(root, config, options = {}) {
1846
1859
  };
1847
1860
 
1848
1861
  writeState(root, updatedState);
1862
+ emitRunEvent(root, 'run_started', {
1863
+ run_id: runId,
1864
+ phase: updatedState.phase,
1865
+ status: 'active',
1866
+ payload: { provenance: provenance || {} },
1867
+ });
1849
1868
  return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
1850
1869
  }
1851
1870
 
@@ -2051,6 +2070,13 @@ export function assignGovernedTurn(root, config, roleId) {
2051
2070
 
2052
2071
  writeState(root, updatedState);
2053
2072
 
2073
+ emitRunEvent(root, 'turn_dispatched', {
2074
+ run_id: updatedState.run_id,
2075
+ phase: updatedState.phase,
2076
+ status: updatedState.status,
2077
+ turn: { turn_id: turnId, role_id: roleId },
2078
+ });
2079
+
2054
2080
  // Session checkpoint — non-fatal, written after every successful turn assignment
2055
2081
  writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
2056
2082
  role: roleId,
@@ -3027,6 +3053,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3027
3053
  }
3028
3054
  }
3029
3055
 
3056
+ // Emit turn_accepted event to local log.
3057
+ emitRunEvent(root, 'turn_accepted', {
3058
+ run_id: updatedState.run_id,
3059
+ phase: updatedState.phase,
3060
+ status: updatedState.status,
3061
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3062
+ });
3063
+
3030
3064
  if (updatedState.status === 'blocked') {
3031
3065
  // DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
3032
3066
  // Covers needs_human, budget:exhausted, and any other non-hook blocked states
@@ -3037,6 +3071,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3037
3071
  blockedOn: updatedState.blocked_on,
3038
3072
  recovery: updatedState.blocked_reason?.recovery || null,
3039
3073
  }, currentTurn);
3074
+ emitRunEvent(root, 'run_blocked', {
3075
+ run_id: updatedState.run_id,
3076
+ phase: updatedState.phase,
3077
+ status: 'blocked',
3078
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3079
+ payload: { category: updatedState.blocked_reason?.category || 'needs_human' },
3080
+ });
3040
3081
  }
3041
3082
 
3042
3083
  if (updatedState.pending_phase_transition) {
@@ -3046,6 +3087,16 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3046
3087
  gate: updatedState.pending_phase_transition.gate,
3047
3088
  requested_by_turn: updatedState.pending_phase_transition.requested_by_turn,
3048
3089
  }, currentTurn);
3090
+ emitRunEvent(root, 'gate_pending', {
3091
+ run_id: updatedState.run_id,
3092
+ phase: updatedState.phase,
3093
+ status: updatedState.status,
3094
+ payload: {
3095
+ gate_type: 'phase_transition',
3096
+ from: updatedState.pending_phase_transition.from,
3097
+ to: updatedState.pending_phase_transition.to,
3098
+ },
3099
+ });
3049
3100
  }
3050
3101
 
3051
3102
  if (updatedState.pending_run_completion) {
@@ -3054,6 +3105,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3054
3105
  requested_by_turn: updatedState.pending_run_completion.requested_by_turn,
3055
3106
  requested_at: updatedState.pending_run_completion.requested_at,
3056
3107
  }, currentTurn);
3108
+ emitRunEvent(root, 'gate_pending', {
3109
+ run_id: updatedState.run_id,
3110
+ phase: updatedState.phase,
3111
+ status: updatedState.status,
3112
+ payload: { gate_type: 'run_completion' },
3113
+ });
3057
3114
  }
3058
3115
 
3059
3116
  if (updatedState.status === 'completed') {
@@ -3062,6 +3119,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3062
3119
  completed_via: completionResult?.action === 'complete' ? 'accept_turn' : 'accept_turn_direct',
3063
3120
  requested_by_turn: completionResult?.requested_by_turn || turnResult.turn_id,
3064
3121
  }, currentTurn);
3122
+ emitRunEvent(root, 'run_completed', {
3123
+ run_id: updatedState.run_id,
3124
+ phase: updatedState.phase,
3125
+ status: 'completed',
3126
+ payload: { completed_at: updatedState.completed_at || now },
3127
+ });
3065
3128
  }
3066
3129
 
3067
3130
  // Session checkpoint — non-fatal, written after every successful acceptance
@@ -3211,6 +3274,13 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3211
3274
  };
3212
3275
 
3213
3276
  writeState(root, updatedState);
3277
+ emitRunEvent(root, 'turn_rejected', {
3278
+ run_id: updatedState.run_id,
3279
+ phase: updatedState.phase,
3280
+ status: updatedState.status,
3281
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3282
+ payload: { attempt: currentAttempt, retrying: true },
3283
+ });
3214
3284
  return {
3215
3285
  ok: true,
3216
3286
  state: attachLegacyCurrentTurnAlias(updatedState),
@@ -3271,6 +3341,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3271
3341
  blockedOn: updatedState.blocked_on,
3272
3342
  recovery: updatedState.blocked_reason?.recovery || null,
3273
3343
  }, updatedState.active_turns[currentTurn.turn_id]);
3344
+ emitRunEvent(root, 'turn_rejected', {
3345
+ run_id: updatedState.run_id,
3346
+ phase: updatedState.phase,
3347
+ status: 'blocked',
3348
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3349
+ payload: { attempt: currentAttempt, retrying: false, escalated: true },
3350
+ });
3351
+ emitRunEvent(root, 'run_blocked', {
3352
+ run_id: updatedState.run_id,
3353
+ phase: updatedState.phase,
3354
+ status: 'blocked',
3355
+ payload: { category: 'retries_exhausted' },
3356
+ });
3274
3357
 
3275
3358
  // Fire on_escalation hooks (advisory-only) after blocked state is persisted.
3276
3359
  const hooksConfig = config?.hooks || {};
@@ -3382,6 +3465,18 @@ export function approvePhaseTransition(root, config) {
3382
3465
  };
3383
3466
 
3384
3467
  writeState(root, updatedState);
3468
+ emitRunEvent(root, 'gate_approved', {
3469
+ run_id: updatedState.run_id,
3470
+ phase: updatedState.phase,
3471
+ status: 'active',
3472
+ payload: { gate_type: 'phase_transition', from: transition.from, to: transition.to },
3473
+ });
3474
+ emitRunEvent(root, 'phase_entered', {
3475
+ run_id: updatedState.run_id,
3476
+ phase: updatedState.phase,
3477
+ status: 'active',
3478
+ payload: { from: transition.from },
3479
+ });
3385
3480
 
3386
3481
  // Session checkpoint — non-fatal
3387
3482
  writeSessionCheckpoint(root, updatedState, 'phase_approved');
@@ -3484,6 +3579,18 @@ export function approveRunCompletion(root, config) {
3484
3579
  gate: completion.gate,
3485
3580
  requested_by_turn: completion.requested_by_turn || null,
3486
3581
  }, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
3582
+ emitRunEvent(root, 'gate_approved', {
3583
+ run_id: updatedState.run_id,
3584
+ phase: updatedState.phase,
3585
+ status: 'completed',
3586
+ payload: { gate_type: 'run_completion' },
3587
+ });
3588
+ emitRunEvent(root, 'run_completed', {
3589
+ run_id: updatedState.run_id,
3590
+ phase: updatedState.phase,
3591
+ status: 'completed',
3592
+ payload: { completed_at: updatedState.completed_at },
3593
+ });
3487
3594
 
3488
3595
  // Session checkpoint — non-fatal
3489
3596
  writeSessionCheckpoint(root, updatedState, 'run_completed');
@@ -34,6 +34,7 @@ const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
34
34
  export { DEFAULT_PHASES };
35
35
  const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
36
36
  const VALID_SEMANTIC_IDS = ['pm_signoff', 'system_spec', 'implementation_notes', 'acceptance_matrix', 'ship_verdict', 'release_notes', 'section_check'];
37
+ const VALID_SCHEDULE_ID = /^[a-z0-9_-]+$/;
37
38
 
38
39
  const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
39
40
  const VALID_API_PROXY_RETRY_CLASSES = [
@@ -508,6 +509,12 @@ export function validateV4Config(data, projectRoot) {
508
509
  errors.push(...notificationValidation.errors);
509
510
  }
510
511
 
512
+ // Schedules (optional but validated if present)
513
+ if (data.schedules !== undefined) {
514
+ const scheduleValidation = validateSchedulesConfig(data.schedules, data.roles);
515
+ errors.push(...scheduleValidation.errors);
516
+ }
517
+
511
518
  // Workflow Kit (optional but validated if present)
512
519
  if (data.workflow_kit !== undefined) {
513
520
  const wkValidation = validateWorkflowKitConfig(data.workflow_kit, data.routing, data.roles);
@@ -534,6 +541,57 @@ export function validateV4Config(data, projectRoot) {
534
541
  return { ok: errors.length === 0, errors };
535
542
  }
536
543
 
544
+ export function validateSchedulesConfig(schedules, roles) {
545
+ const errors = [];
546
+
547
+ if (!schedules || typeof schedules !== 'object' || Array.isArray(schedules)) {
548
+ errors.push('schedules must be an object');
549
+ return { ok: false, errors };
550
+ }
551
+
552
+ for (const [scheduleId, schedule] of Object.entries(schedules)) {
553
+ if (!VALID_SCHEDULE_ID.test(scheduleId)) {
554
+ errors.push(`Schedule "${scheduleId}" must use lowercase alphanumeric, underscore, or hyphen characters only`);
555
+ continue;
556
+ }
557
+
558
+ if (!schedule || typeof schedule !== 'object' || Array.isArray(schedule)) {
559
+ errors.push(`Schedule "${scheduleId}" must be an object`);
560
+ continue;
561
+ }
562
+
563
+ if (!Number.isInteger(schedule.every_minutes) || schedule.every_minutes < 1) {
564
+ errors.push(`Schedule "${scheduleId}": every_minutes must be an integer >= 1`);
565
+ }
566
+
567
+ if ('enabled' in schedule && typeof schedule.enabled !== 'boolean') {
568
+ errors.push(`Schedule "${scheduleId}": enabled must be a boolean`);
569
+ }
570
+
571
+ if ('auto_approve' in schedule && typeof schedule.auto_approve !== 'boolean') {
572
+ errors.push(`Schedule "${scheduleId}": auto_approve must be a boolean`);
573
+ }
574
+
575
+ if ('max_turns' in schedule && (!Number.isInteger(schedule.max_turns) || schedule.max_turns < 1)) {
576
+ errors.push(`Schedule "${scheduleId}": max_turns must be an integer >= 1`);
577
+ }
578
+
579
+ if ('trigger_reason' in schedule && (typeof schedule.trigger_reason !== 'string' || !schedule.trigger_reason.trim())) {
580
+ errors.push(`Schedule "${scheduleId}": trigger_reason must be a non-empty string when provided`);
581
+ }
582
+
583
+ if ('initial_role' in schedule) {
584
+ if (typeof schedule.initial_role !== 'string' || !schedule.initial_role.trim()) {
585
+ errors.push(`Schedule "${scheduleId}": initial_role must be a non-empty string when provided`);
586
+ } else if (roles && !roles[schedule.initial_role]) {
587
+ errors.push(`Schedule "${scheduleId}": initial_role "${schedule.initial_role}" is not a defined role`);
588
+ }
589
+ }
590
+ }
591
+
592
+ return { ok: errors.length === 0, errors };
593
+ }
594
+
537
595
  /**
538
596
  * Validate the workflow_kit config section.
539
597
  * Returns { ok, errors, warnings }.
@@ -850,6 +908,7 @@ export function normalizeV3(raw) {
850
908
  gates: {},
851
909
  hooks: {},
852
910
  notifications: {},
911
+ schedules: {},
853
912
  budget: null,
854
913
  policies: [],
855
914
  approval_policy: null,
@@ -917,6 +976,7 @@ export function normalizeV4(raw) {
917
976
  gates: raw.gates || {},
918
977
  hooks: raw.hooks || {},
919
978
  notifications: raw.notifications || {},
979
+ schedules: normalizeSchedules(raw.schedules),
920
980
  budget: raw.budget || null,
921
981
  policies: normalizePolicies(raw.policies),
922
982
  approval_policy: raw.approval_policy || null,
@@ -949,6 +1009,26 @@ export function normalizeV4(raw) {
949
1009
  };
950
1010
  }
951
1011
 
1012
+ function normalizeSchedules(rawSchedules) {
1013
+ if (!rawSchedules || typeof rawSchedules !== 'object' || Array.isArray(rawSchedules)) {
1014
+ return {};
1015
+ }
1016
+
1017
+ return Object.fromEntries(
1018
+ Object.entries(rawSchedules).map(([scheduleId, schedule]) => [
1019
+ scheduleId,
1020
+ {
1021
+ enabled: schedule?.enabled !== false,
1022
+ every_minutes: schedule?.every_minutes,
1023
+ auto_approve: schedule?.auto_approve !== false,
1024
+ max_turns: schedule?.max_turns ?? 50,
1025
+ initial_role: schedule?.initial_role || null,
1026
+ trigger_reason: schedule?.trigger_reason?.trim() || `schedule:${scheduleId}`,
1027
+ },
1028
+ ]),
1029
+ );
1030
+ }
1031
+
952
1032
  /**
953
1033
  * Load and normalize a config from raw JSON.
954
1034
  * Returns { ok, normalized, errors, version }.
@@ -41,6 +41,9 @@ const ORCHESTRATOR_STATE_FILES = [
41
41
  '.agentxchain/hook-audit.jsonl',
42
42
  '.agentxchain/hook-annotations.jsonl',
43
43
  '.agentxchain/run-history.jsonl',
44
+ '.agentxchain/events.jsonl',
45
+ '.agentxchain/notification-audit.jsonl',
46
+ '.agentxchain/schedule-state.json',
44
47
  'TALK.md',
45
48
  ];
46
49