agentxchain 2.48.0 → 2.50.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.
@@ -107,6 +107,7 @@ import { intakeStatusCommand } from '../src/commands/intake-status.js';
107
107
  import { demoCommand } from '../src/commands/demo.js';
108
108
  import { historyCommand } from '../src/commands/history.js';
109
109
  import { eventsCommand } from '../src/commands/events.js';
110
+ import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
110
111
 
111
112
  const __dirname = dirname(fileURLToPath(import.meta.url));
112
113
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -258,6 +259,41 @@ program
258
259
  .option('-v, --verbose', 'Show stack traces on failure')
259
260
  .action(demoCommand);
260
261
 
262
+ const scheduleCmd = program
263
+ .command('schedule')
264
+ .description('Run governed schedules for repo-local lights-out execution');
265
+
266
+ scheduleCmd
267
+ .command('list')
268
+ .description('List configured governed schedules and due status')
269
+ .option('--schedule <id>', 'Show a single schedule')
270
+ .option('--at <iso8601>', 'Evaluate due status at a fixed time')
271
+ .option('-j, --json', 'Output as JSON')
272
+ .action(scheduleListCommand);
273
+
274
+ scheduleCmd
275
+ .command('run-due')
276
+ .description('Execute every due governed schedule once')
277
+ .option('--schedule <id>', 'Run one configured schedule only')
278
+ .option('--at <iso8601>', 'Evaluate due status at a fixed time')
279
+ .option('-j, --json', 'Output as JSON')
280
+ .action(scheduleRunDueCommand);
281
+
282
+ scheduleCmd
283
+ .command('daemon')
284
+ .description('Poll for due governed schedules and run them locally')
285
+ .option('--schedule <id>', 'Run one configured schedule only')
286
+ .option('--poll-seconds <n>', 'Polling interval in seconds', '60')
287
+ .option('--max-cycles <n>', 'Stop after N cycles (test helper)')
288
+ .option('-j, --json', 'Output as JSON')
289
+ .action(scheduleDaemonCommand);
290
+
291
+ scheduleCmd
292
+ .command('status')
293
+ .description('Show daemon health: running, stale, not_running, or never_started')
294
+ .option('-j, --json', 'Output as JSON')
295
+ .action(scheduleStatusCommand);
296
+
261
297
  program
262
298
  .command('history')
263
299
  .description('Show cross-run history of governed runs in this project')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.48.0",
3
+ "version": "2.50.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,18 @@ formula_sha() {
27
27
  grep -E '^\s*sha256\s+"' "$formula_path" | sed 's/.*sha256 *"\([a-f0-9]*\)".*/\1/' || true
28
28
  }
29
29
 
30
+ canonical_tap_matches_target() {
31
+ local formula_path="$1"
32
+ local expected_url="$2"
33
+ local expected_sha="$3"
34
+ [[ -f "$formula_path" ]] || return 1
35
+ local remote_url
36
+ local remote_sha
37
+ remote_url="$(formula_url "$formula_path")"
38
+ remote_sha="$(formula_sha "$formula_path")"
39
+ [[ "$remote_url" == "$expected_url" && "$remote_sha" == "$expected_sha" ]]
40
+ }
41
+
30
42
  usage() {
31
43
  echo "Usage: bash scripts/sync-homebrew.sh --target-version <semver> [--push-tap] [--dry-run]" >&2
32
44
  }
@@ -207,10 +219,21 @@ if $PUSH_TAP; then
207
219
  git add Formula/agentxchain.rb
208
220
  git commit -m "agentxchain ${TARGET_VERSION}"
209
221
  if ! git push origin HEAD:main; then
210
- echo "FAIL: could not push to ${CANONICAL_TAP_REPO}" >&2
211
- exit 1
222
+ echo " Push rejected by ${CANONICAL_TAP_REPO}; verifying remote state..."
223
+ git fetch origin main >/dev/null 2>&1 || true
224
+ REMOTE_FORMULA="$(mktemp "${TMPDIR:-/tmp}/homebrew-tap-remote-formula.XXXXXX")"
225
+ if git show origin/main:Formula/agentxchain.rb >"$REMOTE_FORMULA" 2>/dev/null \
226
+ && canonical_tap_matches_target "$REMOTE_FORMULA" "$TARBALL_URL" "$TARBALL_SHA"; then
227
+ rm -f "$REMOTE_FORMULA"
228
+ echo " Canonical tap already matches target after push rejection — treating sync as complete."
229
+ else
230
+ rm -f "$REMOTE_FORMULA"
231
+ echo "FAIL: could not push to ${CANONICAL_TAP_REPO} and remote tap does not match target artifact" >&2
232
+ exit 1
233
+ fi
234
+ else
235
+ echo " Pushed to ${CANONICAL_TAP_REPO}"
212
236
  fi
213
- echo " Pushed to ${CANONICAL_TAP_REPO}"
214
237
  fi
215
238
  )
216
239
 
@@ -65,6 +65,20 @@ function appendAcceptanceHints(baseMatrix, acceptanceHints) {
65
65
  return `${baseMatrix}\n\n## Template Guidance\n${hintLines}\n`;
66
66
  }
67
67
 
68
+ function findGitRoot(startDir) {
69
+ let current = resolve(startDir);
70
+ while (true) {
71
+ if (existsSync(join(current, '.git'))) {
72
+ return current;
73
+ }
74
+ const parent = dirname(current);
75
+ if (parent === current) {
76
+ return null;
77
+ }
78
+ current = parent;
79
+ }
80
+ }
81
+
68
82
  // ── Governed init ───────────────────────────────────────────────────────────
69
83
 
70
84
  const GOVERNED_ROLES = {
@@ -950,6 +964,12 @@ async function initGoverned(opts) {
950
964
  if (dir !== process.cwd()) {
951
965
  console.log(` ${chalk.bold(`cd ${targetLabel}`)}`);
952
966
  }
967
+ if (!findGitRoot(dir)) {
968
+ console.log(` ${chalk.bold('git init')} ${chalk.dim('# initialize the governed repo')}`);
969
+ }
970
+ console.log(` ${chalk.bold('agentxchain template validate')} ${chalk.dim('# prove the scaffold contract before the first turn')}`);
971
+ console.log(` ${chalk.bold('git add -A')} ${chalk.dim('# stage the governed scaffold')}`);
972
+ console.log(` ${chalk.bold('git commit -m "initial governed scaffold"')} ${chalk.dim('# checkpoint the starting state')}`);
953
973
  console.log(` ${chalk.bold('agentxchain step')} ${chalk.dim('# run the first governed turn')}`);
954
974
  console.log(` ${chalk.bold('agentxchain status')} ${chalk.dim('# inspect phase, gate, and turn state')}`);
955
975
  console.log('');
@@ -49,12 +49,18 @@ export async function runCommand(opts) {
49
49
  process.exit(1);
50
50
  }
51
51
 
52
+ const execution = await executeGovernedRun(context, opts);
53
+ process.exit(execution.exitCode);
54
+ }
55
+
56
+ export async function executeGovernedRun(context, opts = {}) {
52
57
  const { root, config, rawConfig } = context;
58
+ const log = opts.log || console.log;
53
59
 
54
60
  if (config.protocol_mode !== 'governed') {
55
- console.log(chalk.red('The run command is only available for governed projects.'));
56
- console.log(chalk.dim('Legacy projects use: agentxchain start'));
57
- process.exit(1);
61
+ log(chalk.red('The run command is only available for governed projects.'));
62
+ log(chalk.dim('Legacy projects use: agentxchain start'));
63
+ return { exitCode: 1, result: null };
58
64
  }
59
65
 
60
66
  // ── Provenance flag validation ──────────────────────────────────────────
@@ -62,17 +68,21 @@ export async function runCommand(opts) {
62
68
  const recoverFrom = opts.recoverFrom;
63
69
 
64
70
  if (continueFrom && recoverFrom) {
65
- console.log(chalk.red('Cannot specify both --continue-from and --recover-from'));
66
- process.exit(1);
71
+ log(chalk.red('Cannot specify both --continue-from and --recover-from'));
72
+ return { exitCode: 1, result: null };
67
73
  }
68
74
 
69
- let provenance = undefined;
75
+ let provenance = opts.provenance;
70
76
  if (continueFrom || recoverFrom) {
77
+ if (provenance) {
78
+ log(chalk.red('Cannot combine internal provenance overrides with --continue-from or --recover-from'));
79
+ return { exitCode: 1, result: null };
80
+ }
71
81
  const parentId = continueFrom || recoverFrom;
72
82
  const validation = validateParentRun(root, parentId);
73
83
  if (!validation.ok) {
74
- console.log(chalk.red(validation.error));
75
- process.exit(1);
84
+ log(chalk.red(validation.error));
85
+ return { exitCode: 1, result: null };
76
86
  }
77
87
  provenance = {
78
88
  trigger: continueFrom ? 'continuation' : 'recovery',
@@ -89,21 +99,36 @@ export async function runCommand(opts) {
89
99
  : null;
90
100
 
91
101
  if (overrideResolution?.error) {
92
- console.log(chalk.red(overrideResolution.error));
102
+ log(chalk.red(overrideResolution.error));
93
103
  if (overrideResolution.availableRoles.length) {
94
- console.log(chalk.dim(`Available roles: ${overrideResolution.availableRoles.join(', ')}`));
104
+ log(chalk.dim(`Available roles: ${overrideResolution.availableRoles.join(', ')}`));
105
+ }
106
+ return { exitCode: 1, result: null };
107
+ }
108
+
109
+ if (opts.requireFreshStart) {
110
+ const state = loadProjectState(root, config);
111
+ const allowedStatuses = new Set(opts.allowedFreshStatuses || ['idle', 'completed']);
112
+ const currentStatus = state?.status || 'missing';
113
+ if (currentStatus !== 'missing' && !allowedStatuses.has(currentStatus)) {
114
+ return {
115
+ exitCode: 0,
116
+ skipped: true,
117
+ skipReason: `state_${currentStatus}`,
118
+ state,
119
+ result: null,
120
+ };
95
121
  }
96
- process.exit(1);
97
122
  }
98
123
 
99
124
  // ── Dry run ───────────────────────────────────────────────────────────────
100
125
  if (opts.dryRun) {
101
126
  const dryRunState = loadProjectState(root, config);
102
127
  const roleId = overrideResolution?.roleId || resolveRole(null, dryRunState, config);
103
- console.log(chalk.cyan('Dry run — no execution'));
104
- console.log(` First role: ${roleId || chalk.dim('(unresolved)')}`);
105
- console.log(` Max turns: ${maxTurns}`);
106
- console.log(` Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`);
128
+ log(chalk.cyan('Dry run — no execution'));
129
+ log(` First role: ${roleId || chalk.dim('(unresolved)')}`);
130
+ log(` Max turns: ${maxTurns}`);
131
+ log(` Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`);
107
132
  const roleIds = Object.keys(config.roles || {});
108
133
  for (const rid of roleIds) {
109
134
  const role = config.roles[rid];
@@ -111,9 +136,9 @@ export async function runCommand(opts) {
111
136
  const rt = config.runtimes?.[rtId];
112
137
  const rtType = rt?.type || role.runtime_class || 'manual';
113
138
  const supported = rtType !== 'manual';
114
- console.log(` ${supported ? chalk.green('✓') : chalk.red('✗')} ${rid} → ${rtType}${supported ? '' : ' (not supported in run mode)'}`);
139
+ log(` ${supported ? chalk.green('✓') : chalk.red('✗')} ${rid} → ${rtType}${supported ? '' : ' (not supported in run mode)'}`);
115
140
  }
116
- process.exit(0);
141
+ return { exitCode: 0, result: null };
117
142
  }
118
143
 
119
144
  // ── SIGINT handling ─────────────────────────────────────────────────────
@@ -128,13 +153,13 @@ export async function runCommand(opts) {
128
153
  }
129
154
  aborted = true;
130
155
  controller.abort();
131
- console.log(chalk.yellow('\nSIGINT received — finishing current turn, then stopping.'));
156
+ log(chalk.yellow('\nSIGINT received — finishing current turn, then stopping.'));
132
157
  });
133
158
 
134
159
  // ── Run header ──────────────────────────────────────────────────────────
135
- console.log(chalk.cyan.bold('agentxchain run'));
136
- console.log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
137
- console.log('');
160
+ log(chalk.cyan.bold('agentxchain run'));
161
+ log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
162
+ log('');
138
163
 
139
164
  // ── Track first-call for --role override ────────────────────────────────
140
165
  let firstSelectRole = true;
@@ -165,7 +190,7 @@ export async function runCommand(opts) {
165
190
 
166
191
  // Manual adapter is not supported in run mode
167
192
  if (runtimeType === 'manual') {
168
- console.log(chalk.yellow(`Skipping manual role "${roleId}" — use agentxchain step for manual dispatch.`));
193
+ log(chalk.yellow(`Skipping manual role "${roleId}" — use agentxchain step for manual dispatch.`));
169
194
  return { accept: false, reason: 'manual adapter is not supported in run mode — use agentxchain step' };
170
195
  }
171
196
 
@@ -204,7 +229,7 @@ export async function runCommand(opts) {
204
229
  // ── Route to adapter ──────────────────────────────────────────────
205
230
  const adapterOpts = {
206
231
  signal: controller.signal,
207
- onStatus: (msg) => console.log(chalk.dim(` ${msg}`)),
232
+ onStatus: (msg) => log(chalk.dim(` ${msg}`)),
208
233
  verifyManifest: true,
209
234
  };
210
235
 
@@ -216,18 +241,18 @@ export async function runCommand(opts) {
216
241
  let adapterResult;
217
242
 
218
243
  if (runtimeType === 'api_proxy') {
219
- console.log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
244
+ log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
220
245
  adapterResult = await dispatchApiProxy(projectRoot, state, cfg, adapterOpts);
221
246
  } else if (runtimeType === 'mcp') {
222
247
  const transport = resolveMcpTransport(runtime);
223
- console.log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
248
+ log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
224
249
  adapterResult = await dispatchMcp(projectRoot, state, cfg, adapterOpts);
225
250
  } else if (runtimeType === 'local_cli') {
226
251
  const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
227
- console.log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
252
+ log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
228
253
  adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
229
254
  } else if (runtimeType === 'remote_agent') {
230
- console.log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
255
+ log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
231
256
  adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
232
257
  } else {
233
258
  return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
@@ -286,14 +311,14 @@ export async function runCommand(opts) {
286
311
 
287
312
  async approveGate(gateType, state) {
288
313
  if (autoApprove) {
289
- console.log(chalk.yellow(` Auto-approved ${gateType} gate`));
314
+ log(chalk.yellow(` Auto-approved ${gateType} gate`));
290
315
  return true;
291
316
  }
292
317
 
293
318
  // Non-TTY → fail-closed
294
319
  if (!process.stdin.isTTY) {
295
- console.log(chalk.yellow(` Gate pause: ${gateType} — stdin is not a TTY, failing closed.`));
296
- console.log(chalk.dim(' Use --auto-approve for non-interactive mode.'));
320
+ log(chalk.yellow(` Gate pause: ${gateType} — stdin is not a TTY, failing closed.`));
321
+ log(chalk.dim(' Use --auto-approve for non-interactive mode.'));
297
322
  return false;
298
323
  }
299
324
 
@@ -301,9 +326,9 @@ export async function runCommand(opts) {
301
326
  ? state.pending_phase_transition?.target || '(next phase)'
302
327
  : 'run completion';
303
328
 
304
- console.log('');
305
- console.log(chalk.yellow.bold(`Gate pause: ${gateType}`));
306
- console.log(chalk.dim(` Phase: ${state.phase} → ${target}`));
329
+ log('');
330
+ log(chalk.yellow.bold(`Gate pause: ${gateType}`));
331
+ log(chalk.dim(` Phase: ${state.phase} → ${target}`));
307
332
 
308
333
  const answer = await promptUser(` Approve? [y/N] `);
309
334
  const approved = /^y(es)?$/i.test(answer.trim());
@@ -313,31 +338,31 @@ export async function runCommand(opts) {
313
338
  onEvent(event) {
314
339
  switch (event.type) {
315
340
  case 'turn_assigned':
316
- console.log(chalk.cyan(`Turn assigned: ${event.turn?.turn_id} → ${event.role}`));
341
+ log(chalk.cyan(`Turn assigned: ${event.turn?.turn_id} → ${event.role}`));
317
342
  break;
318
343
  case 'turn_accepted':
319
- console.log(chalk.green(`Turn accepted: ${event.turn?.turn_id}`));
344
+ log(chalk.green(`Turn accepted: ${event.turn?.turn_id}`));
320
345
  break;
321
346
  case 'turn_rejected':
322
- console.log(chalk.yellow(`Turn rejected: ${event.turn?.turn_id} — ${event.reason || 'no reason'}`));
347
+ log(chalk.yellow(`Turn rejected: ${event.turn?.turn_id} — ${event.reason || 'no reason'}`));
323
348
  break;
324
349
  case 'gate_paused':
325
- console.log(chalk.yellow(`Gate paused: ${event.gateType}`));
350
+ log(chalk.yellow(`Gate paused: ${event.gateType}`));
326
351
  break;
327
352
  case 'gate_approved':
328
- console.log(chalk.green(`Gate approved: ${event.gateType}`));
353
+ log(chalk.green(`Gate approved: ${event.gateType}`));
329
354
  break;
330
355
  case 'gate_held':
331
- console.log(chalk.yellow(`Gate held: ${event.gateType} — run paused`));
356
+ log(chalk.yellow(`Gate held: ${event.gateType} — run paused`));
332
357
  break;
333
358
  case 'blocked':
334
- console.log(chalk.red(`Run blocked`));
359
+ log(chalk.red(`Run blocked`));
335
360
  break;
336
361
  case 'completed':
337
- console.log(chalk.green.bold('Run completed'));
362
+ log(chalk.green.bold('Run completed'));
338
363
  break;
339
364
  case 'caller_stopped':
340
- console.log(chalk.yellow('Run stopped by caller'));
365
+ log(chalk.yellow('Run stopped by caller'));
341
366
  break;
342
367
  }
343
368
  },
@@ -347,38 +372,38 @@ export async function runCommand(opts) {
347
372
  const runLoopOpts = {
348
373
  maxTurns,
349
374
  startNewRunFromCompleted: true,
350
- startNewRunFromBlocked: Boolean(provenance),
375
+ startNewRunFromBlocked: opts.allowBlockedRestart ?? Boolean(provenance),
351
376
  };
352
377
  if (provenance) runLoopOpts.provenance = provenance;
353
378
  const result = await runLoop(root, config, callbacks, runLoopOpts);
354
379
 
355
380
  // ── Summary ─────────────────────────────────────────────────────────────
356
- console.log('');
357
- console.log(chalk.dim('─── Run Summary ───'));
358
- console.log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
359
- console.log(` Turns: ${result.turns_executed}`);
360
- console.log(` Gates: ${result.gates_approved} approved`);
361
- console.log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
381
+ log('');
382
+ log(chalk.dim('─── Run Summary ───'));
383
+ log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
384
+ log(` Turns: ${result.turns_executed}`);
385
+ log(` Gates: ${result.gates_approved} approved`);
386
+ log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
362
387
 
363
388
  if (result.errors.length) {
364
389
  for (const err of result.errors) {
365
- console.log(chalk.red(` ${err}`));
390
+ log(chalk.red(` ${err}`));
366
391
  }
367
392
  }
368
393
 
369
394
  if (qaMissingCredentialsFallback) {
370
- printManualQaFallback();
395
+ printManualQaFallback(log);
371
396
  }
372
397
 
373
398
  // Recovery guidance for blocked/rejected states
374
399
  if (result.state && (result.stop_reason === 'blocked' || result.stop_reason === 'reject_exhausted' || result.stop_reason === 'dispatch_error')) {
375
400
  const recovery = deriveRecoveryDescriptor(result.state);
376
401
  if (recovery) {
377
- console.log('');
378
- console.log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
379
- console.log(chalk.dim(` Action: ${recovery.recovery_action}`));
402
+ log('');
403
+ log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
404
+ log(chalk.dim(` Action: ${recovery.recovery_action}`));
380
405
  if (recovery.detail) {
381
- console.log(chalk.dim(` Detail: ${recovery.detail}`));
406
+ log(chalk.dim(` Detail: ${recovery.detail}`));
382
407
  }
383
408
  }
384
409
  }
@@ -399,22 +424,25 @@ export async function runCommand(opts) {
399
424
  const reportPath = join(reportsDir, `report-${runId}.md`);
400
425
  writeFileSync(reportPath, formatGovernanceReportMarkdown(reportResult.report));
401
426
 
402
- console.log('');
403
- console.log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
427
+ log('');
428
+ log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
404
429
  } else {
405
- console.log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
430
+ log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
406
431
  }
407
432
  } catch (err) {
408
- console.log(chalk.dim(` Governance report failed: ${err.message}`));
433
+ log(chalk.dim(` Governance report failed: ${err.message}`));
409
434
  }
410
435
  }
411
436
 
412
437
  // ── Exit code ───────────────────────────────────────────────────────────
413
438
  const successReasons = new Set(['completed', 'gate_held', 'caller_stopped', 'max_turns_reached']);
414
- if (result.ok || successReasons.has(result.stop_reason)) {
415
- process.exit(0);
416
- }
417
- process.exit(1);
439
+ return {
440
+ exitCode: result.ok || successReasons.has(result.stop_reason) ? 0 : 1,
441
+ result,
442
+ skipped: false,
443
+ skipReason: null,
444
+ provenance: provenance || null,
445
+ };
418
446
  }
419
447
 
420
448
  // ── Helpers ───────────────────────────────────────────────────────────────
@@ -452,10 +480,10 @@ function shouldPrintManualQaFallback({ roleId, runtimeId, classified, rawConfig
452
480
  && rawConfig?.runtimes?.['manual-qa']?.type === 'manual';
453
481
  }
454
482
 
455
- function printManualQaFallback() {
456
- console.log('');
457
- console.log(chalk.dim(' No-key QA fallback:'));
458
- console.log(chalk.dim(' - Edit agentxchain.json and change roles.qa.runtime from "api-qa" to "manual-qa"'));
459
- console.log(chalk.dim(' - Then recover the retained QA turn with: agentxchain step --resume'));
460
- console.log(chalk.dim(' - Guide: https://agentxchain.dev/docs/getting-started'));
483
+ function printManualQaFallback(log = console.log) {
484
+ log('');
485
+ log(chalk.dim(' No-key QA fallback:'));
486
+ log(chalk.dim(' - Edit agentxchain.json and change roles.qa.runtime from "api-qa" to "manual-qa"'));
487
+ log(chalk.dim(' - Then recover the retained QA turn with: agentxchain step --resume'));
488
+ log(chalk.dim(' - Guide: https://agentxchain.dev/docs/getting-started'));
461
489
  }
@@ -0,0 +1,358 @@
1
+ import chalk from 'chalk';
2
+ import { loadProjectContext } from '../lib/config.js';
3
+ import {
4
+ SCHEDULE_STATE_PATH,
5
+ DAEMON_STATE_PATH,
6
+ listSchedules,
7
+ updateScheduleState,
8
+ evaluateScheduleLaunchEligibility,
9
+ readDaemonState,
10
+ writeDaemonState,
11
+ updateDaemonHeartbeat,
12
+ createDaemonState,
13
+ evaluateDaemonStatus,
14
+ } from '../lib/run-schedule.js';
15
+ import { executeGovernedRun } from './run.js';
16
+
17
+ function loadScheduleContext() {
18
+ const context = loadProjectContext();
19
+ if (!context) {
20
+ console.error(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
21
+ process.exitCode = 1;
22
+ return null;
23
+ }
24
+ if (context.config.protocol_mode !== 'governed') {
25
+ console.error(chalk.red('The schedule command is only available for governed projects.'));
26
+ process.exitCode = 1;
27
+ return null;
28
+ }
29
+ return context;
30
+ }
31
+
32
+ function resolveScheduleEntries(context, scheduleId, at) {
33
+ const entries = listSchedules(context.root, context.config, { at });
34
+ if (!scheduleId) {
35
+ return { ok: true, entries };
36
+ }
37
+
38
+ const matched = entries.find((entry) => entry.id === scheduleId);
39
+ if (!matched) {
40
+ return { ok: false, error: `Unknown schedule: ${scheduleId}` };
41
+ }
42
+
43
+ return { ok: true, entries: [matched] };
44
+ }
45
+
46
+ function printScheduleTable(entries) {
47
+ if (entries.length === 0) {
48
+ console.log(chalk.dim('No schedules configured.'));
49
+ return;
50
+ }
51
+
52
+ const header = [
53
+ pad('Schedule', 24),
54
+ pad('Enabled', 8),
55
+ pad('Every', 8),
56
+ pad('Due', 6),
57
+ pad('Next Due', 24),
58
+ pad('Last Status', 18),
59
+ pad('Last Run', 14),
60
+ ].join(' ');
61
+ console.log(chalk.bold(header));
62
+ console.log(chalk.dim('─'.repeat(header.length)));
63
+
64
+ for (const entry of entries) {
65
+ console.log([
66
+ pad(entry.id, 24),
67
+ pad(entry.enabled ? 'yes' : 'no', 8),
68
+ pad(`${entry.every_minutes}m`, 8),
69
+ pad(entry.due ? 'yes' : 'no', 6),
70
+ pad(entry.next_due_at || '—', 24),
71
+ pad(entry.last_status || entry.last_skip_reason || '—', 18),
72
+ pad((entry.last_run_id || '—').slice(0, 12), 14),
73
+ ].join(' '));
74
+ }
75
+ }
76
+
77
+ function pad(value, width) {
78
+ return String(value || '').padEnd(width);
79
+ }
80
+
81
+ function buildScheduleProvenance(entry) {
82
+ return {
83
+ trigger: 'schedule',
84
+ created_by: 'operator',
85
+ trigger_reason: entry.trigger_reason || `schedule:${entry.id}`,
86
+ };
87
+ }
88
+
89
+ async function runDueSchedules(context, opts = {}) {
90
+ const resolved = resolveScheduleEntries(context, opts.schedule, opts.at);
91
+ if (!resolved.ok) {
92
+ return { ok: false, exitCode: 1, error: resolved.error, results: [] };
93
+ }
94
+
95
+ const nowIso = opts.at || new Date().toISOString();
96
+ const results = [];
97
+
98
+ for (const entry of resolved.entries) {
99
+ if (!entry.enabled) {
100
+ results.push({ id: entry.id, action: 'disabled' });
101
+ continue;
102
+ }
103
+ if (!entry.due) {
104
+ results.push({ id: entry.id, action: 'not_due', next_due_at: entry.next_due_at });
105
+ continue;
106
+ }
107
+
108
+ const eligibility = evaluateScheduleLaunchEligibility(context.root, context.config);
109
+ if (!eligibility.ok) {
110
+ updateScheduleState(context.root, context.config, entry.id, (record) => ({
111
+ ...record,
112
+ last_skip_at: nowIso,
113
+ last_skip_reason: eligibility.reason,
114
+ }));
115
+ results.push({
116
+ id: entry.id,
117
+ action: 'skipped',
118
+ reason: eligibility.reason,
119
+ project_status: eligibility.status,
120
+ });
121
+ continue;
122
+ }
123
+
124
+ if (!opts.json) {
125
+ console.log(chalk.cyan(`Schedule due: ${entry.id}`));
126
+ }
127
+ const execution = await executeGovernedRun(context, {
128
+ provenance: buildScheduleProvenance(entry),
129
+ maxTurns: entry.max_turns,
130
+ autoApprove: entry.auto_approve,
131
+ role: entry.initial_role || undefined,
132
+ report: true,
133
+ allowBlockedRestart: false,
134
+ requireFreshStart: true,
135
+ allowedFreshStatuses: ['idle', 'completed'],
136
+ log: opts.json ? () => {} : console.log,
137
+ });
138
+
139
+ if (execution.skipped) {
140
+ updateScheduleState(context.root, context.config, entry.id, (record) => ({
141
+ ...record,
142
+ last_skip_at: nowIso,
143
+ last_skip_reason: execution.skipReason,
144
+ }));
145
+ results.push({
146
+ id: entry.id,
147
+ action: 'skipped',
148
+ reason: execution.skipReason,
149
+ });
150
+ continue;
151
+ }
152
+
153
+ const runId = execution.result?.state?.run_id || null;
154
+ const startedAt = execution.result?.state?.created_at || nowIso;
155
+ updateScheduleState(context.root, context.config, entry.id, (record) => ({
156
+ ...record,
157
+ last_started_at: startedAt,
158
+ last_finished_at: new Date().toISOString(),
159
+ last_run_id: runId,
160
+ last_status: execution.result?.stop_reason || (execution.exitCode === 0 ? 'completed' : 'launch_failed'),
161
+ last_skip_at: null,
162
+ last_skip_reason: null,
163
+ }));
164
+ results.push({
165
+ id: entry.id,
166
+ action: 'ran',
167
+ run_id: runId,
168
+ stop_reason: execution.result?.stop_reason || null,
169
+ exit_code: execution.exitCode,
170
+ });
171
+
172
+ if (execution.exitCode !== 0) {
173
+ return { ok: false, exitCode: execution.exitCode, results };
174
+ }
175
+ }
176
+
177
+ return { ok: true, exitCode: 0, results };
178
+ }
179
+
180
+ export async function scheduleListCommand(opts) {
181
+ const context = loadScheduleContext();
182
+ if (!context) return;
183
+
184
+ const resolved = resolveScheduleEntries(context, opts.schedule, opts.at);
185
+ if (!resolved.ok) {
186
+ console.error(chalk.red(resolved.error));
187
+ process.exitCode = 1;
188
+ return;
189
+ }
190
+
191
+ if (opts.json) {
192
+ console.log(JSON.stringify({
193
+ schedules: resolved.entries,
194
+ state_file: SCHEDULE_STATE_PATH,
195
+ }, null, 2));
196
+ return;
197
+ }
198
+
199
+ printScheduleTable(resolved.entries);
200
+ }
201
+
202
+ export async function scheduleRunDueCommand(opts) {
203
+ const context = loadScheduleContext();
204
+ if (!context) return;
205
+
206
+ const result = await runDueSchedules(context, opts);
207
+ if (opts.json) {
208
+ console.log(JSON.stringify(result, null, 2));
209
+ } else if (!result.ok) {
210
+ console.error(chalk.red(result.error || 'Scheduled run failed'));
211
+ } else if (result.results.length === 0) {
212
+ console.log(chalk.dim('No schedules configured.'));
213
+ } else {
214
+ for (const entry of result.results) {
215
+ if (entry.action === 'ran') {
216
+ console.log(chalk.green(`Schedule ran: ${entry.id} (${entry.run_id || 'no run id'})`));
217
+ } else if (entry.action === 'skipped') {
218
+ console.log(chalk.yellow(`Schedule skipped: ${entry.id} (${entry.reason})`));
219
+ } else if (entry.action === 'not_due') {
220
+ console.log(chalk.dim(`Schedule not due: ${entry.id}`));
221
+ } else if (entry.action === 'disabled') {
222
+ console.log(chalk.dim(`Schedule disabled: ${entry.id}`));
223
+ }
224
+ }
225
+ }
226
+
227
+ process.exitCode = result.exitCode;
228
+ }
229
+
230
+ export async function scheduleStatusCommand(opts) {
231
+ const context = loadScheduleContext();
232
+ if (!context) return;
233
+
234
+ const raw = readDaemonState(context.root);
235
+ const evaluation = evaluateDaemonStatus(raw);
236
+
237
+ if (opts.json) {
238
+ const output = {
239
+ ok: evaluation.status === 'running' || evaluation.status === 'never_started',
240
+ state_file: DAEMON_STATE_PATH,
241
+ daemon: {
242
+ status: evaluation.status,
243
+ pid: raw?.pid ?? null,
244
+ started_at: raw?.started_at ?? null,
245
+ last_heartbeat_at: raw?.last_heartbeat_at ?? null,
246
+ last_cycle_result: raw?.last_cycle_result ?? null,
247
+ poll_seconds: raw?.poll_seconds ?? null,
248
+ stale_after_seconds: evaluation.stale_after_seconds ?? null,
249
+ last_error: raw?.last_error ?? null,
250
+ },
251
+ };
252
+ if (evaluation.warning) output.daemon.warning = evaluation.warning;
253
+ console.log(JSON.stringify(output, null, 2));
254
+ return;
255
+ }
256
+
257
+ // Human-readable output
258
+ const statusColors = {
259
+ running: chalk.green,
260
+ stale: chalk.yellow,
261
+ not_running: chalk.red,
262
+ never_started: chalk.dim,
263
+ };
264
+ const colorFn = statusColors[evaluation.status] || chalk.white;
265
+
266
+ console.log(chalk.bold('Schedule Daemon Status'));
267
+ console.log(` State: ${colorFn(evaluation.status)}`);
268
+
269
+ if (evaluation.status === 'never_started') {
270
+ console.log(chalk.dim(' No daemon state file found. Run `agentxchain schedule daemon` to start.'));
271
+ return;
272
+ }
273
+
274
+ if (evaluation.warning) {
275
+ console.log(chalk.yellow(` Warning: ${evaluation.warning}`));
276
+ }
277
+
278
+ if (raw?.pid != null) {
279
+ console.log(` PID: ${raw.pid}`);
280
+ }
281
+ if (raw?.started_at) {
282
+ console.log(` Started: ${raw.started_at}`);
283
+ }
284
+ if (raw?.last_heartbeat_at) {
285
+ console.log(` Heartbeat: ${raw.last_heartbeat_at}`);
286
+ }
287
+ if (raw?.last_cycle_result) {
288
+ const resultColor = raw.last_cycle_result === 'ok' ? chalk.green : chalk.red;
289
+ console.log(` Last cycle: ${resultColor(raw.last_cycle_result)}`);
290
+ }
291
+ if (raw?.poll_seconds != null) {
292
+ console.log(` Poll: ${raw.poll_seconds}s`);
293
+ }
294
+ if (evaluation.status === 'stale') {
295
+ console.log(chalk.yellow(` ⚠ Heartbeat is ${evaluation.heartbeat_age_seconds}s old (stale after ${evaluation.stale_after_seconds}s)`));
296
+ }
297
+ if (raw?.last_error) {
298
+ console.log(chalk.red(` Last error: ${raw.last_error}`));
299
+ }
300
+ }
301
+
302
+ export async function scheduleDaemonCommand(opts) {
303
+ const context = loadScheduleContext();
304
+ if (!context) return;
305
+
306
+ const pollSeconds = Number.parseInt(opts.pollSeconds ?? '60', 10);
307
+ const maxCycles = opts.maxCycles != null ? Number.parseInt(opts.maxCycles, 10) : null;
308
+ if (!Number.isInteger(pollSeconds) || pollSeconds < 1) {
309
+ console.error(chalk.red('--poll-seconds must be an integer >= 1'));
310
+ process.exitCode = 1;
311
+ return;
312
+ }
313
+ if (maxCycles !== null && (!Number.isInteger(maxCycles) || maxCycles < 1)) {
314
+ console.error(chalk.red('--max-cycles must be an integer >= 1'));
315
+ process.exitCode = 1;
316
+ return;
317
+ }
318
+
319
+ let cycle = 0;
320
+ const daemonState = createDaemonState(process.pid, pollSeconds, opts.schedule || null, maxCycles);
321
+
322
+ try {
323
+ writeDaemonState(context.root, daemonState);
324
+ } catch (err) {
325
+ console.error(chalk.red(`Cannot write daemon state: ${err.message}`));
326
+ process.exitCode = 1;
327
+ return;
328
+ }
329
+
330
+ if (!opts.json) {
331
+ console.log(chalk.bold('AgentXchain Schedule Daemon'));
332
+ console.log(chalk.dim(` Poll: ${pollSeconds}s`));
333
+ console.log(chalk.dim(` State: ${SCHEDULE_STATE_PATH}`));
334
+ console.log(chalk.dim(` Health: ${DAEMON_STATE_PATH}`));
335
+ console.log('');
336
+ }
337
+
338
+ while (true) {
339
+ cycle += 1;
340
+ daemonState.last_cycle_started_at = new Date().toISOString();
341
+ const result = await runDueSchedules(context, opts);
342
+
343
+ updateDaemonHeartbeat(context.root, daemonState, result);
344
+
345
+ if (opts.json) {
346
+ console.log(JSON.stringify({ cycle, ...result }));
347
+ }
348
+ if (!result.ok) {
349
+ process.exitCode = result.exitCode;
350
+ return;
351
+ }
352
+ if (maxCycles !== null && cycle >= maxCycles) {
353
+ process.exitCode = 0;
354
+ return;
355
+ }
356
+ await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000));
357
+ }
358
+ }
package/src/lib/export.js CHANGED
@@ -32,6 +32,8 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
32
32
  '.agentxchain/notification-audit.jsonl',
33
33
  '.agentxchain/run-history.jsonl',
34
34
  '.agentxchain/events.jsonl',
35
+ '.agentxchain/schedule-state.json',
36
+ '.agentxchain/schedule-daemon.json',
35
37
  '.agentxchain/dispatch',
36
38
  '.agentxchain/staging',
37
39
  '.agentxchain/transactions/accept',
@@ -55,6 +57,8 @@ export const RUN_RESTORE_ROOTS = [
55
57
  '.agentxchain/notification-audit.jsonl',
56
58
  '.agentxchain/run-history.jsonl',
57
59
  '.agentxchain/events.jsonl',
60
+ '.agentxchain/schedule-state.json',
61
+ '.agentxchain/schedule-daemon.json',
58
62
  '.agentxchain/dispatch',
59
63
  '.agentxchain/staging',
60
64
  '.agentxchain/transactions/accept',
@@ -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 }.
@@ -43,6 +43,8 @@ const ORCHESTRATOR_STATE_FILES = [
43
43
  '.agentxchain/run-history.jsonl',
44
44
  '.agentxchain/events.jsonl',
45
45
  '.agentxchain/notification-audit.jsonl',
46
+ '.agentxchain/schedule-state.json',
47
+ '.agentxchain/schedule-daemon.json',
46
48
  'TALK.md',
47
49
  ];
48
50
 
@@ -0,0 +1,227 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { safeWriteJson } from './safe-write.js';
4
+ import { loadProjectState } from './config.js';
5
+
6
+ export const SCHEDULE_STATE_PATH = '.agentxchain/schedule-state.json';
7
+ export const DAEMON_STATE_PATH = '.agentxchain/schedule-daemon.json';
8
+ const SCHEDULE_STATE_SCHEMA_VERSION = '0.1';
9
+ const DAEMON_STATE_SCHEMA_VERSION = '0.1';
10
+
11
+ function parseIsoTime(value) {
12
+ if (typeof value !== 'string' || !value.trim()) return null;
13
+ const ts = Date.parse(value);
14
+ return Number.isFinite(ts) ? ts : null;
15
+ }
16
+
17
+ function toIso(value) {
18
+ return new Date(value).toISOString();
19
+ }
20
+
21
+ function normalizeScheduleStateRecord(value) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
23
+ return {
24
+ last_started_at: null,
25
+ last_finished_at: null,
26
+ last_run_id: null,
27
+ last_status: null,
28
+ last_skip_at: null,
29
+ last_skip_reason: null,
30
+ };
31
+ }
32
+
33
+ return {
34
+ last_started_at: typeof value.last_started_at === 'string' ? value.last_started_at : null,
35
+ last_finished_at: typeof value.last_finished_at === 'string' ? value.last_finished_at : null,
36
+ last_run_id: typeof value.last_run_id === 'string' ? value.last_run_id : null,
37
+ last_status: typeof value.last_status === 'string' ? value.last_status : null,
38
+ last_skip_at: typeof value.last_skip_at === 'string' ? value.last_skip_at : null,
39
+ last_skip_reason: typeof value.last_skip_reason === 'string' ? value.last_skip_reason : null,
40
+ };
41
+ }
42
+
43
+ function normalizeScheduleState(value, config) {
44
+ const schedules = {};
45
+ const configuredIds = Object.keys(config?.schedules || {});
46
+ for (const scheduleId of configuredIds) {
47
+ schedules[scheduleId] = normalizeScheduleStateRecord(value?.schedules?.[scheduleId]);
48
+ }
49
+
50
+ return {
51
+ schema_version: SCHEDULE_STATE_SCHEMA_VERSION,
52
+ schedules,
53
+ };
54
+ }
55
+
56
+ export function readScheduleState(root, config) {
57
+ const absPath = join(root, SCHEDULE_STATE_PATH);
58
+ if (!existsSync(absPath)) {
59
+ return normalizeScheduleState(null, config);
60
+ }
61
+
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(absPath, 'utf8'));
64
+ return normalizeScheduleState(parsed, config);
65
+ } catch {
66
+ return normalizeScheduleState(null, config);
67
+ }
68
+ }
69
+
70
+ export function writeScheduleState(root, state) {
71
+ const absPath = join(root, SCHEDULE_STATE_PATH);
72
+ mkdirSync(dirname(absPath), { recursive: true });
73
+ safeWriteJson(absPath, state);
74
+ }
75
+
76
+ export function updateScheduleState(root, config, scheduleId, updater) {
77
+ const state = readScheduleState(root, config);
78
+ const current = normalizeScheduleStateRecord(state.schedules[scheduleId]);
79
+ state.schedules[scheduleId] = normalizeScheduleStateRecord(updater(current));
80
+ writeScheduleState(root, state);
81
+ return state.schedules[scheduleId];
82
+ }
83
+
84
+ export function computeScheduleStatus(schedule, record, now = Date.now()) {
85
+ if (schedule.enabled === false) {
86
+ return {
87
+ due: false,
88
+ next_due_at: null,
89
+ due_reason: 'disabled',
90
+ };
91
+ }
92
+
93
+ const lastStartedAt = parseIsoTime(record.last_started_at);
94
+ if (lastStartedAt === null) {
95
+ return {
96
+ due: true,
97
+ next_due_at: toIso(now),
98
+ due_reason: 'never_started',
99
+ };
100
+ }
101
+
102
+ const nextDueTs = lastStartedAt + (schedule.every_minutes * 60 * 1000);
103
+ return {
104
+ due: now >= nextDueTs,
105
+ next_due_at: toIso(nextDueTs),
106
+ due_reason: now >= nextDueTs ? 'interval_elapsed' : 'waiting_interval',
107
+ };
108
+ }
109
+
110
+ export function listSchedules(root, config, { at } = {}) {
111
+ const now = at ? Date.parse(at) : Date.now();
112
+ const scheduleState = readScheduleState(root, config);
113
+ const projectState = loadProjectState(root, config);
114
+ const projectStatus = projectState?.status || 'missing';
115
+
116
+ return Object.entries(config?.schedules || {}).map(([scheduleId, schedule]) => {
117
+ const record = scheduleState.schedules[scheduleId] || normalizeScheduleStateRecord(null);
118
+ const status = computeScheduleStatus(schedule, record, now);
119
+ return {
120
+ id: scheduleId,
121
+ enabled: schedule.enabled !== false,
122
+ every_minutes: schedule.every_minutes,
123
+ auto_approve: schedule.auto_approve !== false,
124
+ max_turns: schedule.max_turns ?? 50,
125
+ initial_role: schedule.initial_role || null,
126
+ trigger_reason: schedule.trigger_reason || `schedule:${scheduleId}`,
127
+ due: status.due,
128
+ due_reason: status.due_reason,
129
+ next_due_at: status.next_due_at,
130
+ project_status: projectStatus,
131
+ last_started_at: record.last_started_at,
132
+ last_finished_at: record.last_finished_at,
133
+ last_run_id: record.last_run_id,
134
+ last_status: record.last_status,
135
+ last_skip_at: record.last_skip_at,
136
+ last_skip_reason: record.last_skip_reason,
137
+ };
138
+ });
139
+ }
140
+
141
+ export function evaluateScheduleLaunchEligibility(root, config) {
142
+ const projectState = loadProjectState(root, config);
143
+ const status = projectState?.status || 'missing';
144
+
145
+ if (status === 'missing' || status === 'idle' || status === 'completed') {
146
+ return { ok: true, status };
147
+ }
148
+
149
+ if (status === 'blocked') {
150
+ return { ok: false, status, reason: 'run_blocked' };
151
+ }
152
+
153
+ if (status === 'active') {
154
+ return { ok: false, status, reason: 'run_active' };
155
+ }
156
+
157
+ if (status === 'paused') {
158
+ return { ok: false, status, reason: 'run_paused' };
159
+ }
160
+
161
+ return { ok: false, status, reason: `run_${status}` };
162
+ }
163
+
164
+ // ── Daemon Health State ─────────────────────────────────────────────────────
165
+
166
+ export function readDaemonState(root) {
167
+ const absPath = join(root, DAEMON_STATE_PATH);
168
+ if (!existsSync(absPath)) return null;
169
+ try {
170
+ return JSON.parse(readFileSync(absPath, 'utf8'));
171
+ } catch {
172
+ return { _parse_error: true };
173
+ }
174
+ }
175
+
176
+ export function writeDaemonState(root, state) {
177
+ const absPath = join(root, DAEMON_STATE_PATH);
178
+ mkdirSync(dirname(absPath), { recursive: true });
179
+ safeWriteJson(absPath, { schema_version: DAEMON_STATE_SCHEMA_VERSION, ...state });
180
+ }
181
+
182
+ export function updateDaemonHeartbeat(root, daemonState, cycleResult) {
183
+ const now = new Date().toISOString();
184
+ const updated = {
185
+ ...daemonState,
186
+ last_heartbeat_at: now,
187
+ last_cycle_finished_at: now,
188
+ last_cycle_result: cycleResult.ok ? 'ok' : 'error',
189
+ last_error: cycleResult.ok ? null : (cycleResult.error || 'cycle failed'),
190
+ };
191
+ writeDaemonState(root, updated);
192
+ return updated;
193
+ }
194
+
195
+ export function createDaemonState(pid, pollSeconds, scheduleId, maxCycles) {
196
+ const now = new Date().toISOString();
197
+ return {
198
+ pid,
199
+ started_at: now,
200
+ last_heartbeat_at: now,
201
+ last_cycle_started_at: null,
202
+ last_cycle_finished_at: null,
203
+ last_cycle_result: null,
204
+ poll_seconds: pollSeconds,
205
+ schedule_id: scheduleId || null,
206
+ max_cycles: maxCycles,
207
+ last_error: null,
208
+ };
209
+ }
210
+
211
+ export function evaluateDaemonStatus(daemonState, now = Date.now()) {
212
+ if (!daemonState) return { status: 'never_started' };
213
+ if (daemonState._parse_error) return { status: 'not_running', warning: 'state file is malformed' };
214
+
215
+ const heartbeat = parseIsoTime(daemonState.last_heartbeat_at);
216
+ if (heartbeat === null) return { status: 'not_running', warning: 'no heartbeat recorded' };
217
+
218
+ const pollSeconds = typeof daemonState.poll_seconds === 'number' ? daemonState.poll_seconds : 60;
219
+ const staleAfterSeconds = Math.max(pollSeconds * 3, 30);
220
+ const ageSeconds = (now - heartbeat) / 1000;
221
+
222
+ if (ageSeconds > staleAfterSeconds) {
223
+ return { status: 'stale', stale_after_seconds: staleAfterSeconds, heartbeat_age_seconds: Math.round(ageSeconds) };
224
+ }
225
+
226
+ return { status: 'running', stale_after_seconds: staleAfterSeconds, heartbeat_age_seconds: Math.round(ageSeconds) };
227
+ }