agentxchain 2.33.1 → 2.35.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.
@@ -76,6 +76,7 @@ import { approveCompletionCommand } from '../src/commands/approve-completion.js'
76
76
  import { dashboardCommand } from '../src/commands/dashboard.js';
77
77
  import { exportCommand } from '../src/commands/export.js';
78
78
  import { restoreCommand } from '../src/commands/restore.js';
79
+ import { restartCommand } from '../src/commands/restart.js';
79
80
  import { reportCommand } from '../src/commands/report.js';
80
81
  import {
81
82
  pluginInstallCommand,
@@ -146,6 +147,12 @@ program
146
147
  .requiredOption('--input <path>', 'Path to a prior run export artifact')
147
148
  .action(restoreCommand);
148
149
 
150
+ program
151
+ .command('restart')
152
+ .description('Restart a governed run from the last checkpoint (cross-session recovery)')
153
+ .option('--role <role>', 'Override the next role assignment')
154
+ .action(restartCommand);
155
+
149
156
  program
150
157
  .command('report')
151
158
  .description('Render a human-readable governance summary from an export artifact')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.33.1",
3
+ "version": "2.35.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -81,7 +81,7 @@ stage_if_present() {
81
81
  }
82
82
 
83
83
  # 1. Assert only allowed release-surface dirt is present
84
- echo "[1/7] Checking release-prep tree state..."
84
+ echo "[1/8] Checking release-prep tree state..."
85
85
  DISALLOWED_DIRTY=()
86
86
  while IFS= read -r status_line; do
87
87
  [[ -z "$status_line" ]] && continue
@@ -99,7 +99,7 @@ fi
99
99
  echo " OK: tree contains only allowed release-prep changes"
100
100
 
101
101
  # 2. Assert not already at target version
102
- echo "[2/7] Checking current version..."
102
+ echo "[2/8] Checking current version..."
103
103
  CURRENT_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")
104
104
  if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
105
105
  echo "FAIL: package.json is already at ${TARGET_VERSION}. Cannot double-bump." >&2
@@ -108,20 +108,77 @@ fi
108
108
  echo " OK: current version is ${CURRENT_VERSION}, bumping to ${TARGET_VERSION}"
109
109
 
110
110
  # 3. Assert tag does not already exist
111
- echo "[3/7] Checking for existing tag..."
111
+ echo "[3/8] Checking for existing tag..."
112
112
  if git rev-parse "v${TARGET_VERSION}" >/dev/null 2>&1; then
113
113
  echo "FAIL: tag v${TARGET_VERSION} already exists. Delete it first or choose a different version." >&2
114
114
  exit 1
115
115
  fi
116
116
  echo " OK: tag v${TARGET_VERSION} does not exist"
117
117
 
118
- # 4. Update version files (no git operations)
119
- echo "[4/7] Updating version files..."
118
+ # 4. Pre-bump version-surface alignment guard
119
+ # Ensures all governed version surfaces already reference the target version
120
+ # BEFORE the bump commit is created. This catches stale drift that would
121
+ # otherwise only be discovered after minting local release identities.
122
+ echo "[4/8] Verifying version-surface alignment for ${TARGET_VERSION}..."
123
+ SURFACE_ERRORS=()
124
+
125
+ # 4a. CHANGELOG top heading
126
+ CHANGELOG_TOP=$(grep -m1 -E '^## [0-9]+\.[0-9]+\.[0-9]+$' "${REPO_ROOT}/cli/CHANGELOG.md" 2>/dev/null | sed 's/^## //' || true)
127
+ if [[ "$CHANGELOG_TOP" != "$TARGET_VERSION" ]]; then
128
+ SURFACE_ERRORS+=("CHANGELOG.md top heading is '${CHANGELOG_TOP:-missing}', expected '${TARGET_VERSION}'")
129
+ fi
130
+
131
+ # 4b. Release notes page exists
132
+ RELEASE_DOC_ID="v${TARGET_VERSION//./-}"
133
+ RELEASE_DOC_PATH="website-v2/docs/releases/${RELEASE_DOC_ID}.mdx"
134
+ if [[ ! -f "${REPO_ROOT}/${RELEASE_DOC_PATH}" ]]; then
135
+ SURFACE_ERRORS+=("release notes page missing: ${RELEASE_DOC_PATH}")
136
+ fi
137
+
138
+ # 4c. Docs sidebar links the release page
139
+ if ! grep -q "'releases/${RELEASE_DOC_ID}'" "${REPO_ROOT}/website-v2/sidebars.ts" 2>/dev/null; then
140
+ SURFACE_ERRORS+=("sidebars.ts does not link 'releases/${RELEASE_DOC_ID}'")
141
+ fi
142
+
143
+ # 4d. Homepage hero badge shows target version
144
+ if ! grep -q "v${TARGET_VERSION}" "${REPO_ROOT}/website-v2/src/pages/index.tsx" 2>/dev/null; then
145
+ SURFACE_ERRORS+=("homepage index.tsx does not contain 'v${TARGET_VERSION}'")
146
+ fi
147
+
148
+ # 4e. Conformance capabilities version
149
+ CAPS_VERSION=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync('${REPO_ROOT}/.agentxchain-conformance/capabilities.json','utf8')).version)}catch{console.log('missing')}" 2>/dev/null || echo "missing")
150
+ if [[ "$CAPS_VERSION" != "$TARGET_VERSION" ]]; then
151
+ SURFACE_ERRORS+=("capabilities.json version is '${CAPS_VERSION}', expected '${TARGET_VERSION}'")
152
+ fi
153
+
154
+ # 4f. Protocol implementor guide example
155
+ if ! grep -q "\"version\": \"${TARGET_VERSION}\"" "${REPO_ROOT}/website-v2/docs/protocol-implementor-guide.mdx" 2>/dev/null; then
156
+ SURFACE_ERRORS+=("protocol-implementor-guide.mdx does not contain '\"version\": \"${TARGET_VERSION}\"'")
157
+ fi
158
+
159
+ # 4g. Launch evidence report title
160
+ ESCAPED_VERSION="${TARGET_VERSION//./\\.}"
161
+ if ! grep -qE "^# Launch Evidence Report — AgentXchain v${ESCAPED_VERSION}" "${REPO_ROOT}/.planning/LAUNCH_EVIDENCE_REPORT.md" 2>/dev/null; then
162
+ SURFACE_ERRORS+=("LAUNCH_EVIDENCE_REPORT.md title does not carry v${TARGET_VERSION}")
163
+ fi
164
+
165
+ if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
166
+ echo "FAIL: ${#SURFACE_ERRORS[@]} version-surface(s) not aligned to ${TARGET_VERSION}:" >&2
167
+ printf ' - %s\n' "${SURFACE_ERRORS[@]}" >&2
168
+ echo "" >&2
169
+ echo "Fix these surfaces before running release-bump. The bump script refuses to" >&2
170
+ echo "create release identity when governed surfaces are stale." >&2
171
+ exit 1
172
+ fi
173
+ echo " OK: all 7 governed version surfaces reference ${TARGET_VERSION}"
174
+
175
+ # 5. Update version files (no git operations)
176
+ echo "[5/8] Updating version files..."
120
177
  npm version "$TARGET_VERSION" --no-git-tag-version
121
178
  echo " OK: package.json updated to ${TARGET_VERSION}"
122
179
 
123
- # 5. Stage version files
124
- echo "[5/7] Staging version files..."
180
+ # 6. Stage version files
181
+ echo "[6/8] Staging version files..."
125
182
  git add -- package.json
126
183
  if [[ -f package-lock.json ]]; then
127
184
  git add -- package-lock.json
@@ -131,8 +188,8 @@ for rel_path in "${ALLOWED_RELEASE_PATHS[@]}"; do
131
188
  done
132
189
  echo " OK: version files and allowed release surfaces staged"
133
190
 
134
- # 6. Create release commit
135
- echo "[6/7] Creating release commit..."
191
+ # 7. Create release commit
192
+ echo "[7/8] Creating release commit..."
136
193
  git commit -m "${TARGET_VERSION}"
137
194
  RELEASE_SHA=$(git rev-parse HEAD)
138
195
  COMMIT_MSG=$(git log -1 --format=%s)
@@ -142,8 +199,8 @@ if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
142
199
  fi
143
200
  echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
144
201
 
145
- # 7. Create annotated tag
146
- echo "[7/7] Creating annotated tag..."
202
+ # 8. Create annotated tag
203
+ echo "[8/8] Creating annotated tag..."
147
204
  git tag -a "v${TARGET_VERSION}" -m "v${TARGET_VERSION}"
148
205
  TAG_SHA=$(git rev-parse "v${TARGET_VERSION}")
149
206
  if [[ -z "$TAG_SHA" ]]; then
@@ -0,0 +1,222 @@
1
+ /**
2
+ * agentxchain restart — governed-only command.
3
+ *
4
+ * Reconstructs dispatch context from durable checkpoint state and assigns the
5
+ * next turn. Unlike `resume` (which assumes the caller has session context),
6
+ * `restart` assumes the caller has NO session context and rebuilds everything
7
+ * from .agentxchain/session.json + .agentxchain/state.json.
8
+ *
9
+ * Use case: terminal died mid-run, machine restarted, new agent session
10
+ * picking up a long-horizon governed run.
11
+ */
12
+
13
+ import chalk from 'chalk';
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
15
+ import { join, dirname } from 'path';
16
+ import { loadProjectContext, loadProjectState } from '../lib/config.js';
17
+ import {
18
+ assignGovernedTurn,
19
+ getActiveTurns,
20
+ getActiveTurnCount,
21
+ reactivateGovernedRun,
22
+ STATE_PATH,
23
+ HISTORY_PATH,
24
+ LEDGER_PATH,
25
+ } from '../lib/governed-state.js';
26
+ import { readSessionCheckpoint, SESSION_PATH } from '../lib/session-checkpoint.js';
27
+
28
+ /**
29
+ * Generate a session recovery report summarizing the run state
30
+ * so a new agent session can orient quickly.
31
+ */
32
+ function generateRecoveryReport(root, state, checkpoint) {
33
+ const lines = [
34
+ '# Session Recovery Report',
35
+ '',
36
+ `> Auto-generated by \`agentxchain restart\` at ${new Date().toISOString()}`,
37
+ '',
38
+ '## Run Identity',
39
+ '',
40
+ `- **Run ID**: \`${state.run_id}\``,
41
+ `- **Project**: \`${state.project_id || 'unknown'}\``,
42
+ `- **Status**: ${state.status}`,
43
+ `- **Phase**: ${state.phase || state.current_phase || 'unknown'}`,
44
+ '',
45
+ ];
46
+
47
+ if (checkpoint) {
48
+ lines.push(
49
+ '## Last Checkpoint',
50
+ '',
51
+ `- **Session**: \`${checkpoint.session_id}\``,
52
+ `- **Last turn**: \`${checkpoint.last_turn_id || 'none'}\``,
53
+ `- **Last role**: ${checkpoint.last_role || 'unknown'}`,
54
+ `- **Reason**: ${checkpoint.checkpoint_reason}`,
55
+ `- **Time**: ${checkpoint.last_checkpoint_at}`,
56
+ '',
57
+ );
58
+ }
59
+
60
+ // Recent decisions from ledger
61
+ const ledgerPath = join(root, LEDGER_PATH);
62
+ if (existsSync(ledgerPath)) {
63
+ try {
64
+ const entries = readFileSync(ledgerPath, 'utf8')
65
+ .trim()
66
+ .split('\n')
67
+ .filter(Boolean)
68
+ .map(l => JSON.parse(l));
69
+ const recent = entries.slice(-5);
70
+ if (recent.length > 0) {
71
+ lines.push('## Recent Decisions', '');
72
+ for (const entry of recent) {
73
+ lines.push(`- \`${entry.decision_id || entry.id || 'unknown'}\`: ${entry.summary || entry.description || JSON.stringify(entry).slice(0, 100)}`);
74
+ }
75
+ lines.push('');
76
+ }
77
+ } catch {
78
+ // Non-fatal
79
+ }
80
+ }
81
+
82
+ // Turn history summary
83
+ const historyPath = join(root, HISTORY_PATH);
84
+ if (existsSync(historyPath)) {
85
+ try {
86
+ const entries = readFileSync(historyPath, 'utf8')
87
+ .trim()
88
+ .split('\n')
89
+ .filter(Boolean)
90
+ .map(l => JSON.parse(l));
91
+ lines.push(`## Turn History`, '', `Total turns completed: ${entries.length}`, '');
92
+ const recent = entries.slice(-3);
93
+ if (recent.length > 0) {
94
+ lines.push('### Last 3 Turns', '');
95
+ for (const entry of recent) {
96
+ lines.push(`- **${entry.turn_id}** (${entry.role}, phase: ${entry.phase}): ${entry.status}`);
97
+ }
98
+ lines.push('');
99
+ }
100
+ } catch {
101
+ // Non-fatal
102
+ }
103
+ }
104
+
105
+ lines.push('## Next Steps', '', 'The next turn has been assigned. Check the dispatch bundle for context.', '');
106
+
107
+ return lines.join('\n');
108
+ }
109
+
110
+ export async function restartCommand(opts) {
111
+ const context = loadProjectContext();
112
+ if (!context) {
113
+ console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
114
+ process.exit(1);
115
+ }
116
+
117
+ const { root, config } = context;
118
+
119
+ if (config.protocol_mode !== 'governed') {
120
+ console.log(chalk.red('The restart command is only available for governed projects.'));
121
+ process.exit(1);
122
+ }
123
+
124
+ // Load state
125
+ const statePath = join(root, STATE_PATH);
126
+ if (!existsSync(statePath)) {
127
+ console.log(chalk.red('No governed run found. Use `agentxchain resume` or `agentxchain run` to start.'));
128
+ process.exit(1);
129
+ }
130
+
131
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
132
+
133
+ // Load checkpoint (optional — restart can work without it, just with less context)
134
+ const checkpoint = readSessionCheckpoint(root);
135
+
136
+ // Validate run_id agreement if checkpoint exists
137
+ if (checkpoint && checkpoint.run_id !== state.run_id) {
138
+ console.log(chalk.red(`Checkpoint run_id mismatch: session.json has ${checkpoint.run_id}, state.json has ${state.run_id}`));
139
+ console.log(chalk.dim('The checkpoint is stale. Proceeding with state.json as ground truth.'));
140
+ }
141
+
142
+ // Check terminal states
143
+ if (state.status === 'completed') {
144
+ console.log(chalk.red('Run is in terminal state: completed.'));
145
+ console.log(chalk.dim(`Run ${state.run_id} completed at ${state.completed_at || 'unknown'}.`));
146
+ process.exit(1);
147
+ }
148
+
149
+ if (state.status === 'failed') {
150
+ console.log(chalk.red('Run is in terminal state: failed.'));
151
+ process.exit(1);
152
+ }
153
+
154
+ if (state.status === 'blocked') {
155
+ console.log(chalk.red('Run is blocked. Resolve the blocker first.'));
156
+ if (state.blocked_reason) {
157
+ console.log(chalk.dim(`Reason: ${typeof state.blocked_reason === 'string' ? state.blocked_reason : JSON.stringify(state.blocked_reason)}`));
158
+ }
159
+ console.log(chalk.dim('Use `agentxchain step --resume` or resolve the blocker, then try again.'));
160
+ process.exit(1);
161
+ }
162
+
163
+ // Handle abandoned active turns (assigned but never completed)
164
+ const activeTurns = getActiveTurns(state);
165
+ const activeTurnCount = getActiveTurnCount(state);
166
+ if (activeTurnCount > 0 && state.status === 'active') {
167
+ const turnIds = Object.keys(activeTurns);
168
+ console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
169
+ console.log(chalk.dim('These turns will be available for the next agent to complete.'));
170
+ }
171
+
172
+ // If paused, reactivate
173
+ if (state.status === 'paused' || state.status === 'idle') {
174
+ const reactivated = reactivateGovernedRun(root, state, {
175
+ reason: 'session_restart',
176
+ });
177
+ if (!reactivated.ok) {
178
+ console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
179
+ process.exit(1);
180
+ }
181
+ }
182
+
183
+ // Determine role from option or routing
184
+ const phase = state.phase || state.current_phase;
185
+ const routing = config.routing?.[phase];
186
+ const roleId = opts.role || routing?.entry_role || Object.keys(config.roles || {})[0] || null;
187
+
188
+ if (!roleId) {
189
+ console.log(chalk.red('Cannot determine which role to assign. Use --role to specify.'));
190
+ process.exit(1);
191
+ }
192
+
193
+ // Assign next turn if no active turn exists
194
+ if (activeTurnCount === 0) {
195
+ const assignment = assignGovernedTurn(root, config, roleId);
196
+ if (!assignment.ok) {
197
+ console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
198
+ process.exit(1);
199
+ }
200
+
201
+ console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
202
+ console.log(chalk.dim(` Phase: ${phase}`));
203
+ console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
204
+ console.log(chalk.dim(` Role: ${assignment.turn?.role || roleId || 'routing default'}`));
205
+ if (checkpoint) {
206
+ console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
207
+ }
208
+ } else {
209
+ console.log(chalk.green(`✓ Reconnected to run ${state.run_id}`));
210
+ console.log(chalk.dim(` Phase: ${phase}`));
211
+ console.log(chalk.dim(` Active turns: ${Object.keys(activeTurns).join(', ')}`));
212
+ console.log(chalk.dim(' Complete the active turn(s) with `agentxchain accept-turn` or `agentxchain step`.'));
213
+ }
214
+
215
+ // Write session recovery report
216
+ const recoveryReport = generateRecoveryReport(root, state, checkpoint);
217
+ const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
218
+ const recoveryDir = dirname(recoveryPath);
219
+ if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
220
+ writeFileSync(recoveryPath, recoveryReport);
221
+ console.log(chalk.dim(` Recovery report: .agentxchain/SESSION_RECOVERY.md`));
222
+ }
@@ -1,7 +1,12 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
1
3
  import chalk from 'chalk';
2
4
  import { loadConfig, loadLock, loadProjectContext, loadProjectState, loadState } from '../lib/config.js';
3
5
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
4
6
  import { getActiveTurn, getActiveTurnCount, getActiveTurns } from '../lib/governed-state.js';
7
+ import { readSessionCheckpoint } from '../lib/session-checkpoint.js';
8
+
9
+ const SESSION_RECOVERY_PATH = '.agentxchain/SESSION_RECOVERY.md';
5
10
 
6
11
  export async function statusCommand(opts) {
7
12
  const context = loadProjectContext();
@@ -74,6 +79,7 @@ export async function statusCommand(opts) {
74
79
  function renderGovernedStatus(context, opts) {
75
80
  const { root, config, version } = context;
76
81
  const state = loadProjectState(root, config);
82
+ const continuity = getContinuityStatus(root, state);
77
83
 
78
84
  if (opts.json) {
79
85
  console.log(JSON.stringify({
@@ -82,6 +88,7 @@ function renderGovernedStatus(context, opts) {
82
88
  template: config.template || 'generic',
83
89
  config,
84
90
  state,
91
+ continuity,
85
92
  }, null, 2));
86
93
  return;
87
94
  }
@@ -101,6 +108,8 @@ function renderGovernedStatus(context, opts) {
101
108
  }
102
109
  console.log('');
103
110
 
111
+ renderContinuityStatus(continuity, state);
112
+
104
113
  const activeTurnCount = getActiveTurnCount(state);
105
114
  const activeTurns = getActiveTurns(state);
106
115
  const singleActiveTurn = getActiveTurn(state);
@@ -238,6 +247,64 @@ function renderGovernedStatus(context, opts) {
238
247
  console.log('');
239
248
  }
240
249
 
250
+ function getContinuityStatus(root, state) {
251
+ const checkpoint = readSessionCheckpoint(root);
252
+ const recoveryReportPath = existsSync(join(root, SESSION_RECOVERY_PATH))
253
+ ? SESSION_RECOVERY_PATH
254
+ : null;
255
+
256
+ if (!checkpoint && !recoveryReportPath) return null;
257
+
258
+ const staleCheckpoint = !!(
259
+ checkpoint?.run_id
260
+ && state?.run_id
261
+ && checkpoint.run_id !== state.run_id
262
+ );
263
+
264
+ return {
265
+ checkpoint,
266
+ stale_checkpoint: staleCheckpoint,
267
+ recovery_report_path: recoveryReportPath,
268
+ restart_recommended: !!state && !['blocked', 'completed', 'failed'].includes(state.status),
269
+ };
270
+ }
271
+
272
+ function renderContinuityStatus(continuity, state) {
273
+ if (!continuity) return;
274
+
275
+ console.log(` ${chalk.dim('Continuity:')}`);
276
+
277
+ if (continuity.checkpoint) {
278
+ const checkpoint = continuity.checkpoint;
279
+ const checkpointSummary = checkpoint.last_checkpoint_at
280
+ ? `${checkpoint.checkpoint_reason || 'unknown'} at ${checkpoint.last_checkpoint_at}`
281
+ : (checkpoint.checkpoint_reason || 'unknown');
282
+ console.log(` ${chalk.dim('Session:')} ${checkpoint.session_id || chalk.dim('unknown')}`);
283
+ console.log(` ${chalk.dim('Checkpoint:')} ${checkpointSummary}`);
284
+ console.log(` ${chalk.dim('Last turn:')} ${checkpoint.last_turn_id || chalk.dim('none')}`);
285
+ console.log(` ${chalk.dim('Last role:')} ${checkpoint.last_role || chalk.dim('unknown')}`);
286
+ if (continuity.stale_checkpoint) {
287
+ console.log(
288
+ ` ${chalk.dim('Warning:')} ${chalk.yellow(
289
+ `session checkpoint tracks ${checkpoint.run_id}, but state.json tracks ${state?.run_id || 'unknown'}; state.json remains source of truth`
290
+ )}`
291
+ );
292
+ }
293
+ } else {
294
+ console.log(` ${chalk.dim('Checkpoint:')} ${chalk.yellow('No session checkpoint recorded')}`);
295
+ }
296
+
297
+ if (continuity.restart_recommended) {
298
+ console.log(` ${chalk.dim('Restart:')} ${chalk.cyan('agentxchain restart')} (rebuild session context from disk)`);
299
+ }
300
+
301
+ if (continuity.recovery_report_path) {
302
+ console.log(` ${chalk.dim('Report:')} ${continuity.recovery_report_path}`);
303
+ }
304
+
305
+ console.log('');
306
+ }
307
+
241
308
  function formatPhase(phase) {
242
309
  const colors = { discovery: chalk.blue, build: chalk.green, qa: chalk.yellow, deploy: chalk.magenta, blocked: chalk.red };
243
310
  return (colors[phase] || chalk.white)(phase);
@@ -10,6 +10,7 @@ import { readFileSync, existsSync } from 'fs';
10
10
  import { join, normalize } from 'path';
11
11
 
12
12
  const STATE_FILE = 'state.json';
13
+ const SESSION_FILE = 'session.json';
13
14
  const HISTORY_FILE = 'history.jsonl';
14
15
  const LEDGER_FILE = 'decision-ledger.jsonl';
15
16
  const HOOK_AUDIT_FILE = 'hook-audit.jsonl';
@@ -23,6 +24,7 @@ const BARRIER_LEDGER_FILE = 'barrier-ledger.jsonl';
23
24
  */
24
25
  export const RESOURCE_MAP = {
25
26
  '/api/state': STATE_FILE,
27
+ '/api/continuity': SESSION_FILE,
26
28
  '/api/history': HISTORY_FILE,
27
29
  '/api/ledger': LEDGER_FILE,
28
30
  '/api/hooks/audit': HOOK_AUDIT_FILE,
package/src/lib/export.js CHANGED
@@ -23,6 +23,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
23
23
  'agentxchain.json',
24
24
  'TALK.md',
25
25
  '.agentxchain/state.json',
26
+ '.agentxchain/session.json',
26
27
  '.agentxchain/history.jsonl',
27
28
  '.agentxchain/decision-ledger.jsonl',
28
29
  '.agentxchain/hook-audit.jsonl',
@@ -43,6 +44,7 @@ export const RUN_RESTORE_ROOTS = [
43
44
  'agentxchain.json',
44
45
  'TALK.md',
45
46
  '.agentxchain/state.json',
47
+ '.agentxchain/session.json',
46
48
  '.agentxchain/history.jsonl',
47
49
  '.agentxchain/decision-ledger.jsonl',
48
50
  '.agentxchain/hook-audit.jsonl',
@@ -35,6 +35,7 @@ import { getMaxConcurrentTurns } from './normalized-config.js';
35
35
  import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
36
36
  import { runHooks } from './hook-runner.js';
37
37
  import { emitNotifications } from './notification-runner.js';
38
+ import { writeSessionCheckpoint } from './session-checkpoint.js';
38
39
 
39
40
  // ── Constants ────────────────────────────────────────────────────────────────
40
41
 
@@ -2483,6 +2484,11 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2483
2484
  }, currentTurn);
2484
2485
  }
2485
2486
 
2487
+ // Session checkpoint — non-fatal, written after every successful acceptance
2488
+ writeSessionCheckpoint(root, updatedState, 'turn_accepted', {
2489
+ role: historyEntry?.role,
2490
+ });
2491
+
2486
2492
  return {
2487
2493
  ok: true,
2488
2494
  state: attachLegacyCurrentTurnAlias(updatedState),
@@ -2791,6 +2797,9 @@ export function approvePhaseTransition(root, config) {
2791
2797
 
2792
2798
  writeState(root, updatedState);
2793
2799
 
2800
+ // Session checkpoint — non-fatal
2801
+ writeSessionCheckpoint(root, updatedState, 'phase_approved');
2802
+
2794
2803
  return {
2795
2804
  ok: true,
2796
2805
  state: attachLegacyCurrentTurnAlias(updatedState),
@@ -2889,6 +2898,9 @@ export function approveRunCompletion(root, config) {
2889
2898
  requested_by_turn: completion.requested_by_turn || null,
2890
2899
  }, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
2891
2900
 
2901
+ // Session checkpoint — non-fatal
2902
+ writeSessionCheckpoint(root, updatedState, 'run_completed');
2903
+
2892
2904
  return {
2893
2905
  ok: true,
2894
2906
  state: attachLegacyCurrentTurnAlias(updatedState),
@@ -34,6 +34,7 @@ const OPERATIONAL_PATH_PREFIXES = [
34
34
  // These are written exclusively by the orchestrator (§4.1 State Ownership Rule).
35
35
  const ORCHESTRATOR_STATE_FILES = [
36
36
  '.agentxchain/state.json',
37
+ '.agentxchain/session.json',
37
38
  '.agentxchain/history.jsonl',
38
39
  '.agentxchain/decision-ledger.jsonl',
39
40
  '.agentxchain/lock.json',
package/src/lib/report.js CHANGED
@@ -503,6 +503,30 @@ export function extractWorkflowKitArtifacts(artifact) {
503
503
  .sort((a, b) => a.path.localeCompare(b.path, 'en'));
504
504
  }
505
505
 
506
+ function extractContinuityMetadata(artifact) {
507
+ const checkpoint = extractFileData(artifact, '.agentxchain/session.json');
508
+ if (!checkpoint || typeof checkpoint !== 'object') return null;
509
+
510
+ const runId = artifact.summary?.run_id || artifact.state?.run_id || null;
511
+ const staleCheckpoint = !!(
512
+ checkpoint.run_id
513
+ && runId
514
+ && checkpoint.run_id !== runId
515
+ );
516
+
517
+ return {
518
+ session_id: checkpoint.session_id || null,
519
+ run_id: checkpoint.run_id || null,
520
+ started_at: checkpoint.started_at || null,
521
+ last_checkpoint_at: checkpoint.last_checkpoint_at || null,
522
+ last_turn_id: checkpoint.last_turn_id || null,
523
+ last_phase: checkpoint.last_phase || null,
524
+ last_role: checkpoint.last_role || null,
525
+ checkpoint_reason: checkpoint.checkpoint_reason || null,
526
+ stale_checkpoint: staleCheckpoint,
527
+ };
528
+ }
529
+
506
530
  function buildRunSubject(artifact) {
507
531
  const activeTurns = artifact.summary?.active_turn_ids || [];
508
532
  const retainedTurns = artifact.summary?.retained_turn_ids || [];
@@ -519,6 +543,7 @@ function buildRunSubject(artifact) {
519
543
  const gateSummary = extractGateSummary(artifact);
520
544
  const intakeLinks = extractIntakeLinks(artifact);
521
545
  const recoverySummary = extractRecoverySummary(artifact);
546
+ const continuity = extractContinuityMetadata(artifact);
522
547
 
523
548
  return {
524
549
  kind: 'governed_run',
@@ -550,6 +575,7 @@ function buildRunSubject(artifact) {
550
575
  gate_summary: gateSummary,
551
576
  intake_links: intakeLinks,
552
577
  recovery_summary: recoverySummary,
578
+ continuity,
553
579
  workflow_kit_artifacts: extractWorkflowKitArtifacts(artifact),
554
580
  },
555
581
  artifacts: {
@@ -620,6 +646,7 @@ function buildCoordinatorSubject(artifact) {
620
646
  base.hook_summary = extractHookSummary(childExport);
621
647
  base.gate_summary = extractGateSummary(childExport);
622
648
  base.recovery_summary = extractRecoverySummary(childExport);
649
+ base.continuity = extractContinuityMetadata(childExport);
623
650
  base.blocked_on = childExport.state?.blocked_on || null;
624
651
  return base;
625
652
  });
@@ -843,6 +870,18 @@ export function formatGovernanceReportText(report) {
843
870
  lines.push(` Turn retained: ${run.recovery_summary.turn_retained == null ? 'n/a' : yesNo(run.recovery_summary.turn_retained)}`);
844
871
  }
845
872
 
873
+ if (run.continuity) {
874
+ lines.push('', 'Continuity:');
875
+ lines.push(` Session: ${run.continuity.session_id || 'unknown'}`);
876
+ lines.push(` Checkpoint: ${run.continuity.checkpoint_reason || 'unknown'} at ${run.continuity.last_checkpoint_at || 'n/a'}`);
877
+ lines.push(` Last turn: ${run.continuity.last_turn_id || 'none'}`);
878
+ lines.push(` Last role: ${run.continuity.last_role || 'unknown'}`);
879
+ lines.push(` Last phase: ${run.continuity.last_phase || 'unknown'}`);
880
+ if (run.continuity.stale_checkpoint) {
881
+ lines.push(` WARNING: checkpoint tracks run ${run.continuity.run_id}, but export tracks ${run.run_id}`);
882
+ }
883
+ }
884
+
846
885
  if (Array.isArray(run.workflow_kit_artifacts) && run.workflow_kit_artifacts.length > 0) {
847
886
  lines.push('', `Workflow Artifacts (${run.phase || 'unknown'} phase):`);
848
887
  for (const art of run.workflow_kit_artifacts) {
@@ -985,6 +1024,17 @@ export function formatGovernanceReportText(report) {
985
1024
  if (repo.recovery_summary) {
986
1025
  repoLines.push(` Recovery: ${repo.recovery_summary.category || 'unknown'} — ${repo.recovery_summary.typed_reason || 'unknown'} (owner: ${repo.recovery_summary.owner || 'unknown'})`);
987
1026
  }
1027
+ if (repo.continuity) {
1028
+ repoLines.push(' Continuity:');
1029
+ repoLines.push(` Session: ${repo.continuity.session_id || 'unknown'}`);
1030
+ repoLines.push(` Checkpoint: ${repo.continuity.checkpoint_reason || 'unknown'} at ${repo.continuity.last_checkpoint_at || 'n/a'}`);
1031
+ repoLines.push(` Last turn: ${repo.continuity.last_turn_id || 'none'}`);
1032
+ repoLines.push(` Last role: ${repo.continuity.last_role || 'unknown'}`);
1033
+ repoLines.push(` Last phase: ${repo.continuity.last_phase || 'unknown'}`);
1034
+ if (repo.continuity.stale_checkpoint) {
1035
+ repoLines.push(` WARNING: checkpoint tracks run ${repo.continuity.run_id}, but repo export tracks ${repo.run_id}`);
1036
+ }
1037
+ }
988
1038
  return repoLines;
989
1039
  }));
990
1040
  return lines.join('\n');
@@ -1110,6 +1160,18 @@ export function formatGovernanceReportMarkdown(report) {
1110
1160
  lines.push(`- Turn retained: \`${run.recovery_summary.turn_retained == null ? 'n/a' : yesNo(run.recovery_summary.turn_retained)}\``);
1111
1161
  }
1112
1162
 
1163
+ if (run.continuity) {
1164
+ lines.push('', '## Continuity', '');
1165
+ lines.push(`- Session: \`${run.continuity.session_id || 'unknown'}\``);
1166
+ lines.push(`- Checkpoint: \`${run.continuity.checkpoint_reason || 'unknown'}\` at \`${run.continuity.last_checkpoint_at || 'n/a'}\``);
1167
+ lines.push(`- Last turn: \`${run.continuity.last_turn_id || 'none'}\``);
1168
+ lines.push(`- Last role: \`${run.continuity.last_role || 'unknown'}\``);
1169
+ lines.push(`- Last phase: \`${run.continuity.last_phase || 'unknown'}\``);
1170
+ if (run.continuity.stale_checkpoint) {
1171
+ lines.push(`- **Warning:** checkpoint tracks run \`${run.continuity.run_id}\`, but export tracks \`${run.run_id}\``);
1172
+ }
1173
+ }
1174
+
1113
1175
  if (Array.isArray(run.workflow_kit_artifacts) && run.workflow_kit_artifacts.length > 0) {
1114
1176
  lines.push('', '## Workflow Artifacts', '');
1115
1177
  lines.push(`Phase: \`${run.phase || 'unknown'}\``, '');
@@ -1258,6 +1320,17 @@ export function formatGovernanceReportMarkdown(report) {
1258
1320
  if (repo.recovery_summary) {
1259
1321
  repoLines.push('', '#### Recovery', '', `- Category: \`${repo.recovery_summary.category || 'unknown'}\``, `- Typed reason: \`${repo.recovery_summary.typed_reason || 'unknown'}\``, `- Owner: \`${repo.recovery_summary.owner || 'unknown'}\``, `- Action: \`${repo.recovery_summary.recovery_action || 'n/a'}\``);
1260
1322
  }
1323
+ if (repo.continuity) {
1324
+ repoLines.push('', '#### Continuity', '');
1325
+ repoLines.push(`- Session: \`${repo.continuity.session_id || 'unknown'}\``);
1326
+ repoLines.push(`- Checkpoint: \`${repo.continuity.checkpoint_reason || 'unknown'}\` at \`${repo.continuity.last_checkpoint_at || 'n/a'}\``);
1327
+ repoLines.push(`- Last turn: \`${repo.continuity.last_turn_id || 'none'}\``);
1328
+ repoLines.push(`- Last role: \`${repo.continuity.last_role || 'unknown'}\``);
1329
+ repoLines.push(`- Last phase: \`${repo.continuity.last_phase || 'unknown'}\``);
1330
+ if (repo.continuity.stale_checkpoint) {
1331
+ repoLines.push(`- **Warning:** checkpoint tracks run \`${repo.continuity.run_id}\`, but repo export tracks \`${repo.run_id}\``);
1332
+ }
1333
+ }
1261
1334
  repoLines.push('');
1262
1335
  return repoLines;
1263
1336
  }));
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Session checkpoint — automatic state markers for cross-session restart.
3
+ *
4
+ * Writes .agentxchain/session.json at every governance boundary
5
+ * (turn acceptance, phase transition, gate approval, run completion)
6
+ * so that `agentxchain restart` can reconstruct dispatch context
7
+ * without any in-memory session state.
8
+ *
9
+ * Design rules:
10
+ * - Checkpoint writes are non-fatal: failures log a warning and do not
11
+ * block the governance operation.
12
+ * - The file is always overwritten, not appended.
13
+ * - run_id in session.json must agree with state.json; mismatch is a
14
+ * corruption signal.
15
+ */
16
+
17
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
18
+ import { join, dirname } from 'path';
19
+ import { randomBytes } from 'crypto';
20
+
21
+ const SESSION_PATH = '.agentxchain/session.json';
22
+
23
+ /**
24
+ * Generate a new session ID.
25
+ */
26
+ function generateSessionId() {
27
+ return `session_${randomBytes(8).toString('hex')}`;
28
+ }
29
+
30
+ /**
31
+ * Read the current session checkpoint, or null if none exists.
32
+ */
33
+ export function readSessionCheckpoint(root) {
34
+ const filePath = join(root, SESSION_PATH);
35
+ if (!existsSync(filePath)) return null;
36
+ try {
37
+ return JSON.parse(readFileSync(filePath, 'utf8'));
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Write or update the session checkpoint.
45
+ *
46
+ * @param {string} root - project root
47
+ * @param {object} state - current governed state (from state.json)
48
+ * @param {string} reason - checkpoint reason (e.g. 'turn_accepted', 'phase_approved', 'run_completed')
49
+ * @param {object} [extra] - optional extra context fields
50
+ */
51
+ export function writeSessionCheckpoint(root, state, reason, extra = {}) {
52
+ const filePath = join(root, SESSION_PATH);
53
+ try {
54
+ // Read existing session to preserve session_id across checkpoints in the same session
55
+ const existing = readSessionCheckpoint(root);
56
+ const sessionId = (existing && existing.run_id === state.run_id)
57
+ ? existing.session_id
58
+ : generateSessionId();
59
+
60
+ const currentTurn = state.current_turn || null;
61
+ const lastTurnId = currentTurn?.id || currentTurn?.turn_id || state.last_completed_turn_id || null;
62
+ const lastRole = currentTurn?.role || currentTurn?.assigned_role || extra.role || null;
63
+ const lastPhase = state.current_phase || state.phase || null;
64
+
65
+ const checkpoint = {
66
+ session_id: sessionId,
67
+ run_id: state.run_id,
68
+ started_at: existing?.started_at || new Date().toISOString(),
69
+ last_checkpoint_at: new Date().toISOString(),
70
+ last_turn_id: lastTurnId,
71
+ last_phase: lastPhase,
72
+ last_role: lastRole,
73
+ run_status: state.status || null,
74
+ checkpoint_reason: reason,
75
+ agent_context: {
76
+ adapter: extra.adapter || null,
77
+ dispatch_dir: extra.dispatch_dir || null,
78
+ },
79
+ };
80
+
81
+ const dir = dirname(filePath);
82
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
83
+ writeFileSync(filePath, JSON.stringify(checkpoint, null, 2) + '\n');
84
+ } catch (err) {
85
+ // Non-fatal — warn but do not block governance operations
86
+ if (process.env.AGENTXCHAIN_DEBUG) {
87
+ console.error(`[session-checkpoint] Warning: failed to write checkpoint: ${err.message}`);
88
+ }
89
+ }
90
+ }
91
+
92
+ export { SESSION_PATH };