agentxchain 2.36.0 → 2.38.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.
@@ -154,8 +154,19 @@ function renderContinuityPanel(continuity) {
154
154
  html += `<div class="turn-detail"><span class="detail-label">Checkpoint:</span> No session checkpoint recorded</div>`;
155
155
  }
156
156
 
157
- if (continuity.restart_recommended) {
158
- html += `<div class="turn-detail"><span class="detail-label">Restart:</span> <span class="mono">agentxchain restart</span></div>`;
157
+ if (continuity.drift_detected === true && Array.isArray(continuity.drift_warnings) && continuity.drift_warnings.length > 0) {
158
+ html += `<div class="turn-detail risks"><span class="detail-label">Drift:</span><ul>`;
159
+ for (const warning of continuity.drift_warnings) {
160
+ html += `<li>${esc(warning)}</li>`;
161
+ }
162
+ html += `</ul></div>`;
163
+ } else if (continuity.drift_detected === false) {
164
+ html += `<div class="turn-detail"><span class="detail-label">Drift:</span> none detected since checkpoint</div>`;
165
+ }
166
+
167
+ if (continuity.recommended_command) {
168
+ const detail = continuity.recommended_detail ? ` (${continuity.recommended_detail})` : '';
169
+ html += `<div class="turn-detail"><span class="detail-label">Action:</span> <span class="mono">${esc(continuity.recommended_command)}</span>${esc(detail)}</div>`;
159
170
  }
160
171
 
161
172
  if (continuity.recovery_report_path) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.36.0",
3
+ "version": "2.38.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -121,7 +121,11 @@ echo " OK: tag v${TARGET_VERSION} does not exist"
121
121
  # Ensures all governed version surfaces already reference the target version
122
122
  # BEFORE the bump commit is created. This catches stale drift that would
123
123
  # otherwise only be discovered after minting local release identities.
124
- echo "[4/8] Verifying version-surface alignment for ${TARGET_VERSION}..."
124
+ #
125
+ # NOTE: Homebrew mirror formula and README are NOT checked here. They are
126
+ # auto-aligned in step 5 because the registry SHA256 is inherently a
127
+ # post-publish artifact. See DEC-HOMEBREW-SHA-SPLIT-001.
128
+ echo "[4/9] Verifying version-surface alignment for ${TARGET_VERSION}..."
125
129
  SURFACE_ERRORS=()
126
130
 
127
131
  # 4a. CHANGELOG top heading
@@ -164,25 +168,6 @@ if ! grep -qE "^# Launch Evidence Report — AgentXchain v${ESCAPED_VERSION}" "$
164
168
  SURFACE_ERRORS+=("LAUNCH_EVIDENCE_REPORT.md title does not carry v${TARGET_VERSION}")
165
169
  fi
166
170
 
167
- # 4h. Homebrew mirror formula version
168
- HOMEBREW_MIRROR="${REPO_ROOT}/cli/homebrew/agentxchain.rb"
169
- if [[ -f "$HOMEBREW_MIRROR" ]]; then
170
- if ! grep -q "agentxchain-${TARGET_VERSION}\.tgz" "$HOMEBREW_MIRROR" 2>/dev/null; then
171
- SURFACE_ERRORS+=("homebrew mirror formula does not reference agentxchain-${TARGET_VERSION}.tgz")
172
- fi
173
- fi
174
-
175
- # 4i. Homebrew mirror maintainer README version
176
- HOMEBREW_MIRROR_README="${REPO_ROOT}/cli/homebrew/README.md"
177
- if [[ -f "$HOMEBREW_MIRROR_README" ]]; then
178
- if ! grep -q -- "- version: \`${TARGET_VERSION}\`" "$HOMEBREW_MIRROR_README" 2>/dev/null; then
179
- SURFACE_ERRORS+=("homebrew mirror README does not declare version ${TARGET_VERSION}")
180
- fi
181
- if ! grep -q "agentxchain-${TARGET_VERSION}\.tgz" "$HOMEBREW_MIRROR_README" 2>/dev/null; then
182
- SURFACE_ERRORS+=("homebrew mirror README does not reference agentxchain-${TARGET_VERSION}.tgz")
183
- fi
184
- fi
185
-
186
171
  if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
187
172
  echo "FAIL: ${#SURFACE_ERRORS[@]} version-surface(s) not aligned to ${TARGET_VERSION}:" >&2
188
173
  printf ' - %s\n' "${SURFACE_ERRORS[@]}" >&2
@@ -191,15 +176,47 @@ if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
191
176
  echo "create release identity when governed surfaces are stale." >&2
192
177
  exit 1
193
178
  fi
194
- echo " OK: all 10 governed version surfaces reference ${TARGET_VERSION}"
179
+ echo " OK: all 7 governed version surfaces reference ${TARGET_VERSION}"
180
+
181
+ # 5. Auto-align Homebrew mirror to target version
182
+ # The formula URL and README version/tarball are updated automatically.
183
+ # The SHA256 is carried from the previous version — it is inherently a
184
+ # post-publish artifact (npm registry tarballs are not byte-identical to
185
+ # local npm-pack output). sync-homebrew.sh corrects the SHA after publish.
186
+ echo "[5/9] Auto-aligning Homebrew mirror to ${TARGET_VERSION}..."
187
+ HOMEBREW_MIRROR="${REPO_ROOT}/cli/homebrew/agentxchain.rb"
188
+ HOMEBREW_MIRROR_README="${REPO_ROOT}/cli/homebrew/README.md"
189
+ TARBALL_URL="https://registry.npmjs.org/agentxchain/-/agentxchain-${TARGET_VERSION}.tgz"
190
+ HOMEBREW_ALIGNED=false
191
+
192
+ if [[ -f "$HOMEBREW_MIRROR" ]]; then
193
+ ESCAPED_URL="$(printf '%s' "$TARBALL_URL" | sed 's/[&/\]/\\&/g')"
194
+ sed -i.bak -E "s|^([[:space:]]*url \").*(\")|\1${ESCAPED_URL}\2|" "$HOMEBREW_MIRROR"
195
+ rm -f "${HOMEBREW_MIRROR}.bak"
196
+ HOMEBREW_ALIGNED=true
197
+ echo " OK: formula URL -> ${TARBALL_URL}"
198
+ fi
199
+
200
+ if [[ -f "$HOMEBREW_MIRROR_README" ]]; then
201
+ sed -i.bak -E "s|^(- version: \`).*(\`)|\1${TARGET_VERSION}\2|" "$HOMEBREW_MIRROR_README"
202
+ sed -i.bak -E "s|^(- source tarball: \`).*(\`)|\1${TARBALL_URL}\2|" "$HOMEBREW_MIRROR_README"
203
+ rm -f "${HOMEBREW_MIRROR_README}.bak"
204
+ echo " OK: README version and tarball -> ${TARGET_VERSION}"
205
+ fi
206
+
207
+ if $HOMEBREW_ALIGNED; then
208
+ echo " Note: SHA carried from previous version; sync-homebrew.sh will set the real registry SHA post-publish"
209
+ else
210
+ echo " Skipped: no Homebrew mirror files found"
211
+ fi
195
212
 
196
- # 5. Update version files (no git operations)
197
- echo "[5/8] Updating version files..."
213
+ # 6. Update version files (no git operations)
214
+ echo "[6/9] Updating version files..."
198
215
  npm version "$TARGET_VERSION" --no-git-tag-version
199
216
  echo " OK: package.json updated to ${TARGET_VERSION}"
200
217
 
201
- # 6. Stage version files
202
- echo "[6/8] Staging version files..."
218
+ # 7. Stage version files
219
+ echo "[7/9] Staging version files..."
203
220
  git add -- package.json
204
221
  if [[ -f package-lock.json ]]; then
205
222
  git add -- package-lock.json
@@ -209,8 +226,8 @@ for rel_path in "${ALLOWED_RELEASE_PATHS[@]}"; do
209
226
  done
210
227
  echo " OK: version files and allowed release surfaces staged"
211
228
 
212
- # 7. Create release commit
213
- echo "[7/8] Creating release commit..."
229
+ # 8. Create release commit
230
+ echo "[8/9] Creating release commit..."
214
231
  git commit -m "${TARGET_VERSION}"
215
232
  RELEASE_SHA=$(git rev-parse HEAD)
216
233
  COMMIT_MSG=$(git log -1 --format=%s)
@@ -220,8 +237,8 @@ if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
220
237
  fi
221
238
  echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
222
239
 
223
- # 8. Create annotated tag
224
- echo "[8/8] Creating annotated tag..."
240
+ # 9. Create annotated tag
241
+ echo "[9/9] Creating annotated tag..."
225
242
  git tag -a "v${TARGET_VERSION}" -m "v${TARGET_VERSION}"
226
243
  TAG_SHA=$(git rev-parse "v${TARGET_VERSION}")
227
244
  if [[ -z "$TAG_SHA" ]]; then
@@ -23,13 +23,13 @@ import {
23
23
  HISTORY_PATH,
24
24
  LEDGER_PATH,
25
25
  } from '../lib/governed-state.js';
26
- import { readSessionCheckpoint, SESSION_PATH } from '../lib/session-checkpoint.js';
26
+ import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
27
27
 
28
28
  /**
29
29
  * Generate a session recovery report summarizing the run state
30
30
  * so a new agent session can orient quickly.
31
31
  */
32
- function generateRecoveryReport(root, state, checkpoint) {
32
+ function generateRecoveryReport(root, state, checkpoint, driftWarnings = []) {
33
33
  const lines = [
34
34
  '# Session Recovery Report',
35
35
  '',
@@ -102,7 +102,50 @@ function generateRecoveryReport(root, state, checkpoint) {
102
102
  }
103
103
  }
104
104
 
105
- lines.push('## Next Steps', '', 'The next turn has been assigned. Check the dispatch bundle for context.', '');
105
+ // Pending gate / run completion surfacing
106
+ if (state.pending_phase_transition) {
107
+ const pt = state.pending_phase_transition;
108
+ lines.push(
109
+ '## Pending Phase Transition',
110
+ '',
111
+ `- **From**: ${pt.from}`,
112
+ `- **To**: ${pt.to}`,
113
+ `- **Gate**: ${pt.gate}`,
114
+ `- **Requested by**: ${pt.requested_by_turn || 'unknown'}`,
115
+ `- **Action**: Run \`agentxchain approve-transition\` to approve`,
116
+ '',
117
+ );
118
+ }
119
+
120
+ if (state.pending_run_completion) {
121
+ const pc = state.pending_run_completion;
122
+ lines.push(
123
+ '## Pending Run Completion',
124
+ '',
125
+ `- **Gate**: ${pc.gate}`,
126
+ `- **Requested by**: ${pc.requested_by_turn || 'unknown'}`,
127
+ `- **Action**: Run \`agentxchain approve-completion\` to approve`,
128
+ '',
129
+ );
130
+ }
131
+
132
+ // Repo-drift warnings
133
+ if (checkpoint?.baseline_ref && driftWarnings?.length > 0) {
134
+ lines.push('## Continuity Warnings', '');
135
+ for (const warning of driftWarnings) {
136
+ lines.push(`- ⚠ ${warning}`);
137
+ }
138
+ lines.push('');
139
+ }
140
+
141
+ lines.push('## Next Steps', '');
142
+ if (state.pending_phase_transition) {
143
+ lines.push('A phase transition is pending approval. Run `agentxchain approve-transition` before assigning new turns.', '');
144
+ } else if (state.pending_run_completion) {
145
+ lines.push('A run completion is pending approval. Run `agentxchain approve-completion` to finalize.', '');
146
+ } else {
147
+ lines.push('The next turn has been assigned. Check the dispatch bundle for context.', '');
148
+ }
106
149
 
107
150
  return lines.join('\n');
108
151
  }
@@ -160,6 +203,42 @@ export async function restartCommand(opts) {
160
203
  process.exit(1);
161
204
  }
162
205
 
206
+ // ── Repo-drift detection ────────────────────────────────────────────────
207
+ const driftWarnings = [];
208
+ if (checkpoint?.baseline_ref) {
209
+ const currentBaseline = captureBaselineRef(root);
210
+ const prev = checkpoint.baseline_ref;
211
+
212
+ if (prev.git_head && currentBaseline.git_head && prev.git_head !== currentBaseline.git_head) {
213
+ driftWarnings.push(`Git HEAD has moved since checkpoint: ${prev.git_head.slice(0, 8)} → ${currentBaseline.git_head.slice(0, 8)}`);
214
+ }
215
+ if (prev.git_branch && currentBaseline.git_branch && prev.git_branch !== currentBaseline.git_branch) {
216
+ driftWarnings.push(`Branch changed since checkpoint: ${prev.git_branch} → ${currentBaseline.git_branch}`);
217
+ }
218
+ if (prev.workspace_dirty === false && currentBaseline.workspace_dirty === true) {
219
+ driftWarnings.push('Workspace was clean at checkpoint but is now dirty');
220
+ }
221
+ }
222
+
223
+ if (driftWarnings.length > 0) {
224
+ for (const warning of driftWarnings) {
225
+ console.log(chalk.yellow(`⚠ ${warning}`));
226
+ }
227
+ }
228
+
229
+ // ── Pending gate / completion check ────────────────────────────────────
230
+ if (state.pending_phase_transition) {
231
+ const pt = state.pending_phase_transition;
232
+ console.log(chalk.yellow(`Pending phase transition: ${pt.from} → ${pt.to} (gate: ${pt.gate})`));
233
+ console.log(chalk.dim('Run `agentxchain approve-transition` to approve before assigning new turns.'));
234
+ }
235
+
236
+ if (state.pending_run_completion) {
237
+ const pc = state.pending_run_completion;
238
+ console.log(chalk.yellow(`Pending run completion (gate: ${pc.gate})`));
239
+ console.log(chalk.dim('Run `agentxchain approve-completion` to finalize.'));
240
+ }
241
+
163
242
  // Handle abandoned active turns (assigned but never completed)
164
243
  const activeTurns = getActiveTurns(state);
165
244
  const activeTurnCount = getActiveTurnCount(state);
@@ -167,9 +246,29 @@ export async function restartCommand(opts) {
167
246
  const turnIds = Object.keys(activeTurns);
168
247
  console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
169
248
  console.log(chalk.dim('These turns will be available for the next agent to complete.'));
249
+
250
+ // Fail closed if retained turn + irreconcilable drift
251
+ if (driftWarnings.length > 0) {
252
+ console.log(chalk.yellow('Active turns exist with repo drift since checkpoint. Reconnecting with warnings.'));
253
+ console.log(chalk.dim('Inspect the drift before continuing work on the retained turns.'));
254
+ }
170
255
  }
171
256
 
172
- // If paused, reactivate
257
+ // If pending gate/completion, do not bypass — surface and exit with recovery
258
+ if (state.pending_phase_transition || state.pending_run_completion) {
259
+ // Write checkpoint for the reconnect
260
+ writeSessionCheckpoint(root, state, 'restart_reconnect');
261
+
262
+ const recoveryReport = generateRecoveryReport(root, state, checkpoint, driftWarnings);
263
+ const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
264
+ const recoveryDir = dirname(recoveryPath);
265
+ if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
266
+ writeFileSync(recoveryPath, recoveryReport);
267
+ console.log(chalk.dim(` Recovery report: .agentxchain/SESSION_RECOVERY.md`));
268
+ return;
269
+ }
270
+
271
+ // If paused or idle without a pending approval gate, reactivate.
173
272
  if (state.status === 'paused' || state.status === 'idle') {
174
273
  const reactivated = reactivateGovernedRun(root, state, {
175
274
  reason: 'session_restart',
@@ -198,6 +297,8 @@ export async function restartCommand(opts) {
198
297
  process.exit(1);
199
298
  }
200
299
 
300
+ // assignGovernedTurn already writes a checkpoint at turn_assigned
301
+
201
302
  console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
202
303
  console.log(chalk.dim(` Phase: ${phase}`));
203
304
  console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
@@ -206,6 +307,9 @@ export async function restartCommand(opts) {
206
307
  console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
207
308
  }
208
309
  } else {
310
+ // Reconnect to existing active turns — write checkpoint
311
+ writeSessionCheckpoint(root, state, 'restart_reconnect');
312
+
209
313
  console.log(chalk.green(`✓ Reconnected to run ${state.run_id}`));
210
314
  console.log(chalk.dim(` Phase: ${phase}`));
211
315
  console.log(chalk.dim(` Active turns: ${Object.keys(activeTurns).join(', ')}`));
@@ -213,7 +317,7 @@ export async function restartCommand(opts) {
213
317
  }
214
318
 
215
319
  // Write session recovery report
216
- const recoveryReport = generateRecoveryReport(root, state, checkpoint);
320
+ const recoveryReport = generateRecoveryReport(root, state, checkpoint, driftWarnings);
217
321
  const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
218
322
  const recoveryDir = dirname(recoveryPath);
219
323
  if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
@@ -268,8 +268,21 @@ function renderContinuityStatus(continuity, state) {
268
268
  console.log(` ${chalk.dim('Checkpoint:')} ${chalk.yellow('No session checkpoint recorded')}`);
269
269
  }
270
270
 
271
- if (continuity.restart_recommended) {
272
- console.log(` ${chalk.dim('Restart:')} ${chalk.cyan('agentxchain restart')} (rebuild session context from disk)`);
271
+ if (continuity.drift_detected === true) {
272
+ const [firstWarning, ...remainingWarnings] = continuity.drift_warnings || [];
273
+ if (firstWarning) {
274
+ console.log(` ${chalk.dim('Drift:')} ${chalk.yellow(firstWarning)}`);
275
+ }
276
+ for (const warning of remainingWarnings) {
277
+ console.log(` ${chalk.dim(' ')} ${chalk.yellow(warning)}`);
278
+ }
279
+ } else if (continuity.drift_detected === false) {
280
+ console.log(` ${chalk.dim('Drift:')} ${chalk.green('none detected since checkpoint')}`);
281
+ }
282
+
283
+ if (continuity.recommended_command) {
284
+ const detail = continuity.recommended_detail ? ` (${continuity.recommended_detail})` : '';
285
+ console.log(` ${chalk.dim('Action:')} ${chalk.cyan(continuity.recommended_command)}${chalk.dim(detail)}`);
273
286
  }
274
287
 
275
288
  if (continuity.recovery_report_path) {
@@ -23,7 +23,7 @@
23
23
  * All error returns include a `classified` ApiProxyError object with
24
24
  * error_class, recovery instructions, and retryable flag.
25
25
  *
26
- * Supported providers: "anthropic", "openai"
26
+ * Supported providers: "anthropic", "openai", "google"
27
27
  */
28
28
 
29
29
  import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
@@ -45,9 +45,12 @@ import {
45
45
  import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
46
46
 
47
47
  // Provider endpoint registry
48
+ // Google Gemini endpoint requires the model name interpolated at call time;
49
+ // the registry stores a template with {model} as a placeholder.
48
50
  const PROVIDER_ENDPOINTS = {
49
51
  anthropic: 'https://api.anthropic.com/v1/messages',
50
52
  openai: 'https://api.openai.com/v1/chat/completions',
53
+ google: 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
51
54
  };
52
55
 
53
56
  // Bundled cost rates per million tokens (USD).
@@ -67,6 +70,10 @@ const BUNDLED_COST_RATES = {
67
70
  'o3': { input_per_1m: 2.00, output_per_1m: 8.00 },
68
71
  'o3-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
69
72
  'o4-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
73
+ // Google Gemini — verified 2026-04-09 (training knowledge)
74
+ 'gemini-2.5-pro': { input_per_1m: 1.25, output_per_1m: 10.00 },
75
+ 'gemini-2.5-flash': { input_per_1m: 0.15, output_per_1m: 0.60 },
76
+ 'gemini-2.0-flash': { input_per_1m: 0.10, output_per_1m: 0.40 },
70
77
  };
71
78
 
72
79
  // Resolve cost rates: operator-supplied cost_rates override bundled defaults
@@ -135,6 +142,25 @@ const PROVIDER_ERROR_MAPS = {
135
142
  { provider_error_type: 'rate_limit_error', http_status: 429, error_class: 'rate_limited', retryable: true },
136
143
  ],
137
144
  },
145
+ google: {
146
+ extractErrorType(body) {
147
+ // Google errors use { error: { status: "INVALID_ARGUMENT", ... } }
148
+ return typeof body?.error?.status === 'string' ? body.error.status : null;
149
+ },
150
+ extractErrorCode(body) {
151
+ return typeof body?.error?.code === 'number' ? String(body.error.code) : null;
152
+ },
153
+ mappings: [
154
+ { provider_error_type: 'UNAUTHENTICATED', http_status: 401, error_class: 'auth_failure', retryable: false },
155
+ { provider_error_type: 'PERMISSION_DENIED', http_status: 403, error_class: 'auth_failure', retryable: false },
156
+ { provider_error_type: 'NOT_FOUND', http_status: 404, error_class: 'model_not_found', retryable: false },
157
+ { provider_error_type: 'RESOURCE_EXHAUSTED', http_status: 429, error_class: 'rate_limited', retryable: true },
158
+ { provider_error_type: 'INVALID_ARGUMENT', http_status: 400, body_pattern: /token.*limit|context|too.long/i, error_class: 'context_overflow', retryable: false },
159
+ { provider_error_type: 'INVALID_ARGUMENT', http_status: 400, error_class: 'invalid_request', retryable: false },
160
+ { provider_error_type: 'UNAVAILABLE', http_status: 503, error_class: 'provider_overloaded', retryable: true },
161
+ { provider_error_type: 'INTERNAL', http_status: 500, error_class: 'unknown_api_error', retryable: true },
162
+ ],
163
+ },
138
164
  };
139
165
 
140
166
  // ── Error classification ──────────────────────────────────────────────────────
@@ -442,6 +468,10 @@ function usageFromTelemetry(provider, model, usage, config) {
442
468
  if (provider === 'openai') {
443
469
  inputTokens = Number.isFinite(usage.prompt_tokens) ? usage.prompt_tokens : 0;
444
470
  outputTokens = Number.isFinite(usage.completion_tokens) ? usage.completion_tokens : 0;
471
+ } else if (provider === 'google') {
472
+ // Google Gemini returns usageMetadata at root level with promptTokenCount / candidatesTokenCount
473
+ inputTokens = Number.isFinite(usage.promptTokenCount) ? usage.promptTokenCount : 0;
474
+ outputTokens = Number.isFinite(usage.candidatesTokenCount) ? usage.candidatesTokenCount : 0;
445
475
  } else {
446
476
  inputTokens = Number.isFinite(usage.input_tokens) ? usage.input_tokens : 0;
447
477
  outputTokens = Number.isFinite(usage.output_tokens) ? usage.output_tokens : 0;
@@ -691,7 +721,9 @@ async function executeApiCall({
691
721
  };
692
722
  }
693
723
 
694
- const usage = usageFromTelemetry(provider, model, responseData.usage, config);
724
+ // Google Gemini returns usage at responseData.usageMetadata; others at responseData.usage
725
+ const usageSource = provider === 'google' ? responseData.usageMetadata : responseData.usage;
726
+ const usage = usageFromTelemetry(provider, model, usageSource, config);
695
727
  const extraction = extractTurnResult(responseData, provider);
696
728
 
697
729
  if (!extraction.ok) {
@@ -791,7 +823,7 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
791
823
  return errorReturn(root, turn.turn_id, classified);
792
824
  }
793
825
 
794
- const endpoint = runtime.base_url || PROVIDER_ENDPOINTS[provider];
826
+ let endpoint = runtime.base_url || PROVIDER_ENDPOINTS[provider];
795
827
  if (!endpoint) {
796
828
  const classified = classifyError(
797
829
  'unsupported_provider',
@@ -802,6 +834,12 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
802
834
  return errorReturn(root, turn.turn_id, classified);
803
835
  }
804
836
 
837
+ // Google Gemini: interpolate model into endpoint URL and append API key as query param
838
+ if (provider === 'google') {
839
+ endpoint = endpoint.replace('{model}', encodeURIComponent(model));
840
+ endpoint += (endpoint.includes('?') ? '&' : '?') + `key=${encodeURIComponent(apiKey)}`;
841
+ }
842
+
805
843
  // Build request
806
844
  const maxOutputTokens = runtime.max_output_tokens || 4096;
807
845
  const timeoutSeconds = runtime.timeout_seconds || 120;
@@ -1086,10 +1124,92 @@ function buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens) {
1086
1124
  };
1087
1125
  }
1088
1126
 
1127
+ function buildGoogleHeaders(_apiKey) {
1128
+ // Google Gemini uses API key as a query parameter, not a header
1129
+ return {
1130
+ 'Content-Type': 'application/json',
1131
+ };
1132
+ }
1133
+
1134
+ function buildGoogleRequest(promptMd, contextMd, model, maxOutputTokens) {
1135
+ const userContent = contextMd
1136
+ ? `${promptMd}${SEPARATOR}${contextMd}`
1137
+ : promptMd;
1138
+
1139
+ return {
1140
+ systemInstruction: {
1141
+ parts: [{ text: SYSTEM_PROMPT }],
1142
+ },
1143
+ contents: [
1144
+ {
1145
+ role: 'user',
1146
+ parts: [{ text: userContent }],
1147
+ },
1148
+ ],
1149
+ generationConfig: {
1150
+ maxOutputTokens,
1151
+ responseMimeType: 'application/json',
1152
+ },
1153
+ };
1154
+ }
1155
+
1156
+ function extractGoogleTurnResult(responseData) {
1157
+ const promptBlockReason = responseData?.promptFeedback?.blockReason;
1158
+ if (typeof promptBlockReason === 'string' && promptBlockReason.trim()) {
1159
+ return {
1160
+ ok: false,
1161
+ error: `Google Gemini blocked the prompt before generation (blockReason: ${promptBlockReason})`,
1162
+ };
1163
+ }
1164
+
1165
+ if (!Array.isArray(responseData?.candidates) || responseData.candidates.length === 0) {
1166
+ return { ok: false, error: 'API response has no candidates' };
1167
+ }
1168
+
1169
+ const candidate = responseData.candidates[0];
1170
+ const finishReason = typeof candidate?.finishReason === 'string'
1171
+ ? candidate.finishReason
1172
+ : null;
1173
+ const parts = candidate?.content?.parts;
1174
+ if (!Array.isArray(parts) || parts.length === 0) {
1175
+ if (finishReason && finishReason !== 'STOP') {
1176
+ return {
1177
+ ok: false,
1178
+ error: `Google Gemini candidate has no content parts (finishReason: ${finishReason})`,
1179
+ };
1180
+ }
1181
+ return { ok: false, error: 'API response candidate has no content parts' };
1182
+ }
1183
+
1184
+ const textPart = parts.find(p => typeof p.text === 'string');
1185
+ if (!textPart?.text?.trim()) {
1186
+ if (finishReason && finishReason !== 'STOP') {
1187
+ return {
1188
+ ok: false,
1189
+ error: `Google Gemini returned no extractable text (finishReason: ${finishReason})`,
1190
+ };
1191
+ }
1192
+ return { ok: false, error: 'API response has no text content part' };
1193
+ }
1194
+
1195
+ const extraction = extractTurnResultFromText(textPart.text);
1196
+ if (!extraction.ok && finishReason && finishReason !== 'STOP') {
1197
+ return {
1198
+ ok: false,
1199
+ error: `Google Gemini returned non-extractable turn JSON (finishReason: ${finishReason})`,
1200
+ };
1201
+ }
1202
+
1203
+ return extraction;
1204
+ }
1205
+
1089
1206
  function buildProviderHeaders(provider, apiKey) {
1090
1207
  if (provider === 'openai') {
1091
1208
  return buildOpenAiHeaders(apiKey);
1092
1209
  }
1210
+ if (provider === 'google') {
1211
+ return buildGoogleHeaders(apiKey);
1212
+ }
1093
1213
  return buildAnthropicHeaders(apiKey);
1094
1214
  }
1095
1215
 
@@ -1097,6 +1217,9 @@ function buildProviderRequest(provider, promptMd, contextMd, model, maxOutputTok
1097
1217
  if (provider === 'openai') {
1098
1218
  return buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens);
1099
1219
  }
1220
+ if (provider === 'google') {
1221
+ return buildGoogleRequest(promptMd, contextMd, model, maxOutputTokens);
1222
+ }
1100
1223
  return buildAnthropicRequest(promptMd, contextMd, model, maxOutputTokens);
1101
1224
  }
1102
1225
 
@@ -1184,6 +1307,9 @@ function extractTurnResult(responseData, provider = 'anthropic') {
1184
1307
  if (provider === 'openai') {
1185
1308
  return extractOpenAiTurnResult(responseData);
1186
1309
  }
1310
+ if (provider === 'google') {
1311
+ return extractGoogleTurnResult(responseData);
1312
+ }
1187
1313
  return extractAnthropicTurnResult(responseData);
1188
1314
  }
1189
1315
 
@@ -1198,6 +1324,7 @@ export {
1198
1324
  extractTurnResult,
1199
1325
  buildAnthropicRequest,
1200
1326
  buildOpenAiRequest,
1327
+ buildGoogleRequest,
1201
1328
  classifyError,
1202
1329
  classifyHttpError,
1203
1330
  BUNDLED_COST_RATES,
@@ -1,9 +1,111 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { readSessionCheckpoint } from './session-checkpoint.js';
3
+ import { captureBaselineRef, readSessionCheckpoint } from './session-checkpoint.js';
4
4
 
5
5
  export const SESSION_RECOVERY_PATH = '.agentxchain/SESSION_RECOVERY.md';
6
6
 
7
+ function deriveRecommendedContinuityAction(state) {
8
+ if (!state) {
9
+ return {
10
+ recommended_command: null,
11
+ recommended_reason: 'no_state',
12
+ recommended_detail: null,
13
+ restart_recommended: false,
14
+ };
15
+ }
16
+
17
+ if (state.pending_phase_transition) {
18
+ const pt = state.pending_phase_transition;
19
+ return {
20
+ recommended_command: 'agentxchain approve-transition',
21
+ recommended_reason: 'pending_phase_transition',
22
+ recommended_detail: `${pt.from || 'unknown'} -> ${pt.to || 'unknown'} (gate: ${pt.gate || 'unknown'})`,
23
+ restart_recommended: false,
24
+ };
25
+ }
26
+
27
+ if (state.pending_run_completion) {
28
+ const pc = state.pending_run_completion;
29
+ return {
30
+ recommended_command: 'agentxchain approve-completion',
31
+ recommended_reason: 'pending_run_completion',
32
+ recommended_detail: pc.gate ? `gate: ${pc.gate}` : null,
33
+ restart_recommended: false,
34
+ };
35
+ }
36
+
37
+ if (!['blocked', 'completed', 'failed'].includes(state.status)) {
38
+ return {
39
+ recommended_command: 'agentxchain restart',
40
+ recommended_reason: 'restart_available',
41
+ recommended_detail: 'rebuild session context from disk',
42
+ restart_recommended: true,
43
+ };
44
+ }
45
+
46
+ return {
47
+ recommended_command: null,
48
+ recommended_reason: state.status === 'blocked' ? 'blocked' : 'terminal_state',
49
+ recommended_detail: null,
50
+ restart_recommended: false,
51
+ };
52
+ }
53
+
54
+ function deriveCheckpointDrift(root, checkpoint, staleCheckpoint) {
55
+ if (!checkpoint?.baseline_ref || staleCheckpoint) {
56
+ return {
57
+ drift_detected: null,
58
+ drift_warnings: [],
59
+ };
60
+ }
61
+
62
+ const currentBaseline = captureBaselineRef(root);
63
+ const previousBaseline = checkpoint.baseline_ref;
64
+
65
+ if (
66
+ currentBaseline.git_head == null
67
+ && currentBaseline.git_branch == null
68
+ && currentBaseline.workspace_dirty == null
69
+ ) {
70
+ return {
71
+ drift_detected: null,
72
+ drift_warnings: [],
73
+ };
74
+ }
75
+
76
+ const driftWarnings = [];
77
+
78
+ if (
79
+ previousBaseline.git_head
80
+ && currentBaseline.git_head
81
+ && previousBaseline.git_head !== currentBaseline.git_head
82
+ ) {
83
+ driftWarnings.push(
84
+ `Git HEAD has moved since checkpoint: ${previousBaseline.git_head.slice(0, 8)} -> ${currentBaseline.git_head.slice(0, 8)}`
85
+ );
86
+ }
87
+
88
+ if (
89
+ previousBaseline.git_branch
90
+ && currentBaseline.git_branch
91
+ && previousBaseline.git_branch !== currentBaseline.git_branch
92
+ ) {
93
+ driftWarnings.push(`Branch changed since checkpoint: ${previousBaseline.git_branch} -> ${currentBaseline.git_branch}`);
94
+ }
95
+
96
+ if (
97
+ previousBaseline.workspace_dirty === false
98
+ && currentBaseline.workspace_dirty === true
99
+ ) {
100
+ driftWarnings.push('Workspace was clean at checkpoint but is now dirty');
101
+ }
102
+
103
+ return {
104
+ drift_detected: driftWarnings.length > 0,
105
+ drift_warnings: driftWarnings,
106
+ };
107
+ }
108
+
7
109
  export function getContinuityStatus(root, state) {
8
110
  const checkpoint = readSessionCheckpoint(root);
9
111
  const recoveryReportPath = existsSync(join(root, SESSION_RECOVERY_PATH))
@@ -18,10 +120,18 @@ export function getContinuityStatus(root, state) {
18
120
  && checkpoint.run_id !== state.run_id
19
121
  );
20
122
 
123
+ const action = deriveRecommendedContinuityAction(state);
124
+ const drift = deriveCheckpointDrift(root, checkpoint, staleCheckpoint);
125
+
21
126
  return {
22
127
  checkpoint,
23
128
  stale_checkpoint: staleCheckpoint,
24
129
  recovery_report_path: recoveryReportPath,
25
- restart_recommended: !!state && !['blocked', 'completed', 'failed'].includes(state.status),
130
+ restart_recommended: action.restart_recommended,
131
+ recommended_command: action.recommended_command,
132
+ recommended_reason: action.recommended_reason,
133
+ recommended_detail: action.recommended_detail,
134
+ drift_detected: drift.drift_detected,
135
+ drift_warnings: drift.drift_warnings,
26
136
  };
27
137
  }
@@ -1352,6 +1352,11 @@ export function markRunBlocked(root, details) {
1352
1352
 
1353
1353
  writeState(root, updatedState);
1354
1354
 
1355
+ // Session checkpoint — non-fatal, written after blocked state is persisted
1356
+ writeSessionCheckpoint(root, updatedState, 'blocked', {
1357
+ role: turnId ? (getActiveTurns(updatedState)[turnId]?.assigned_role || null) : null,
1358
+ });
1359
+
1355
1360
  emitBlockedNotification(root, details.notificationConfig, updatedState, {
1356
1361
  category: details.category,
1357
1362
  blockedOn: details.blockedOn,
@@ -1753,6 +1758,13 @@ export function assignGovernedTurn(root, config, roleId) {
1753
1758
  };
1754
1759
 
1755
1760
  writeState(root, updatedState);
1761
+
1762
+ // Session checkpoint — non-fatal, written after every successful turn assignment
1763
+ writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
1764
+ role: roleId,
1765
+ dispatch_dir: `.agentxchain/dispatch/turns/${turnId}`,
1766
+ });
1767
+
1756
1768
  const assignedTurn = updatedState.active_turns[turnId];
1757
1769
  const result = { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), turn: assignedTurn };
1758
1770
  if (warnings.length > 0) {
@@ -18,7 +18,7 @@ import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
18
18
 
19
19
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
20
20
  const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
21
- const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
21
+ const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai', 'google'];
22
22
  export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
23
23
  const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
24
24
  const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
@@ -2,9 +2,10 @@
2
2
  * Session checkpoint — automatic state markers for cross-session restart.
3
3
  *
4
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.
5
+ * (turn assignment, acceptance, phase transition, blocked state,
6
+ * gate approval, run completion, restart/reconnect) so that
7
+ * `agentxchain restart` can reconstruct dispatch context without
8
+ * any in-memory session state.
8
9
  *
9
10
  * Design rules:
10
11
  * - Checkpoint writes are non-fatal: failures log a warning and do not
@@ -12,11 +13,13 @@
12
13
  * - The file is always overwritten, not appended.
13
14
  * - run_id in session.json must agree with state.json; mismatch is a
14
15
  * corruption signal.
16
+ * - state.json is always authoritative; session.json is recovery metadata.
15
17
  */
16
18
 
17
19
  import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
18
20
  import { join, dirname } from 'path';
19
21
  import { randomBytes } from 'crypto';
22
+ import { execSync as shellExec } from 'child_process';
20
23
 
21
24
  const SESSION_PATH = '.agentxchain/session.json';
22
25
 
@@ -27,6 +30,25 @@ function generateSessionId() {
27
30
  return `session_${randomBytes(8).toString('hex')}`;
28
31
  }
29
32
 
33
+ /**
34
+ * Capture git baseline ref for repo-drift detection.
35
+ * Non-fatal: returns partial/null fields on failure.
36
+ */
37
+ export function captureBaselineRef(root) {
38
+ try {
39
+ const gitHead = shellExec('git rev-parse HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
40
+ const gitBranch = shellExec('git rev-parse --abbrev-ref HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
41
+ const statusOutput = shellExec('git status --porcelain', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
42
+ return {
43
+ git_head: gitHead,
44
+ git_branch: gitBranch,
45
+ workspace_dirty: statusOutput.length > 0,
46
+ };
47
+ } catch {
48
+ return { git_head: null, git_branch: null, workspace_dirty: null };
49
+ }
50
+ }
51
+
30
52
  /**
31
53
  * Read the current session checkpoint, or null if none exists.
32
54
  */
@@ -40,12 +62,21 @@ export function readSessionCheckpoint(root) {
40
62
  }
41
63
  }
42
64
 
65
+ /**
66
+ * Extract active turn IDs from governed state.
67
+ */
68
+ function getActiveTurnIds(state) {
69
+ const turns = state.active_turns || {};
70
+ return Object.keys(turns);
71
+ }
72
+
43
73
  /**
44
74
  * Write or update the session checkpoint.
45
75
  *
46
76
  * @param {string} root - project root
47
77
  * @param {object} state - current governed state (from state.json)
48
- * @param {string} reason - checkpoint reason (e.g. 'turn_accepted', 'phase_approved', 'run_completed')
78
+ * @param {string} reason - checkpoint reason (e.g. 'turn_assigned', 'turn_accepted',
79
+ * 'phase_approved', 'run_completed', 'blocked', 'restart_reconnect')
49
80
  * @param {object} [extra] - optional extra context fields
50
81
  */
51
82
  export function writeSessionCheckpoint(root, state, reason, extra = {}) {
@@ -58,20 +89,40 @@ export function writeSessionCheckpoint(root, state, reason, extra = {}) {
58
89
  : generateSessionId();
59
90
 
60
91
  const currentTurn = state.current_turn || null;
61
- const lastTurnId = currentTurn?.id || currentTurn?.turn_id || state.last_completed_turn_id || null;
92
+ const activeTurnIds = getActiveTurnIds(state);
93
+ const lastTurnId = currentTurn?.id || currentTurn?.turn_id
94
+ || (activeTurnIds.length > 0 ? activeTurnIds[activeTurnIds.length - 1] : null)
95
+ || state.last_completed_turn_id || null;
62
96
  const lastRole = currentTurn?.role || currentTurn?.assigned_role || extra.role || null;
63
97
  const lastPhase = state.current_phase || state.phase || null;
64
98
 
99
+ // Derive last_completed_turn_id from history or state
100
+ const lastCompletedTurnId = state.last_completed_turn_id || existing?.last_completed_turn_id || null;
101
+
102
+ // Derive pending gates
103
+ const pendingGate = state.pending_phase_transition?.gate || state.pending_transition?.gate || null;
104
+ const pendingRunCompletion = state.pending_run_completion?.gate || null;
105
+
106
+ // Capture git baseline for repo-drift detection
107
+ const baselineRef = extra.baseline_ref || captureBaselineRef(root);
108
+
65
109
  const checkpoint = {
66
110
  session_id: sessionId,
67
111
  run_id: state.run_id,
68
112
  started_at: existing?.started_at || new Date().toISOString(),
69
113
  last_checkpoint_at: new Date().toISOString(),
114
+ checkpoint_reason: reason,
115
+ run_status: state.status || null,
116
+ phase: lastPhase,
117
+ last_phase: lastPhase, // backward compat alias for report.js consumers
70
118
  last_turn_id: lastTurnId,
71
- last_phase: lastPhase,
119
+ last_completed_turn_id: lastCompletedTurnId,
120
+ active_turn_ids: activeTurnIds,
72
121
  last_role: lastRole,
73
- run_status: state.status || null,
74
- checkpoint_reason: reason,
122
+ pending_gate: pendingGate,
123
+ pending_run_completion: pendingRunCompletion ? true : null,
124
+ blocked: state.status === 'blocked',
125
+ baseline_ref: baselineRef,
75
126
  agent_context: {
76
127
  adapter: extra.adapter || null,
77
128
  dispatch_dir: extra.dispatch_dir || null,