agentxchain 2.156.0 → 2.157.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.156.0",
3
+ "version": "2.157.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,10 @@ import {
44
44
  parseRateLimitResetMs,
45
45
  resolveClaudeCompatibleNodeBinary,
46
46
  } from '../claude-local-auth.js';
47
+ import {
48
+ createStreamJsonCostParser,
49
+ buildCostFromStreamJson,
50
+ } from '../stream-json-cost-parser.js';
47
51
 
48
52
  const DIAGNOSTIC_ENV_KEYS = [
49
53
  'PATH',
@@ -147,6 +151,16 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
147
151
  };
148
152
  }
149
153
 
154
+ // Create stream-json cost parser for Claude runtimes with stream-json output.
155
+ // Only Claude runtimes emit parseable NDJSON with result events; other runtimes
156
+ // (Codex, Cursor, Windsurf, OpenCode) have different stdout formats.
157
+ const tokens = [command, ...args].filter((t) => typeof t === 'string');
158
+ const usesStreamJson = tokens.includes('--output-format=stream-json')
159
+ || (tokens.includes('--output-format') && tokens[tokens.indexOf('--output-format') + 1] === 'stream-json');
160
+ const costParser = isClaudeLocalCliRuntime(runtime) && usesStreamJson
161
+ ? createStreamJsonCostParser()
162
+ : null;
163
+
150
164
  // Compute timeout from explicit dispatch deadline, turn deadline, or default (20 minutes).
151
165
  const timeoutMs = options.dispatchTimeoutMs != null
152
166
  ? options.dispatchTimeoutMs
@@ -425,6 +439,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
425
439
  stdoutBytes += Buffer.byteLength(text);
426
440
  recordFirstOutput('stdout');
427
441
  logs.push(text);
442
+ if (costParser) costParser.push(text);
428
443
  if (onStdout) onStdout(text);
429
444
  });
430
445
  }
@@ -558,6 +573,28 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
558
573
  appendDiagnostic(logs, 'process_exit', exitDiagnostic);
559
574
 
560
575
  if (hasResult) {
576
+ // Enrich cost from stream-json if available (best-effort, never blocks settle)
577
+ if (costParser) {
578
+ try {
579
+ const parsedCost = costParser.getResult();
580
+ if (parsedCost) {
581
+ const stagingPath = join(root, getTurnStagingResultPath(turn.turn_id));
582
+ const raw = readFileSync(stagingPath, 'utf8');
583
+ const turnResult = JSON.parse(raw);
584
+ turnResult.cost = buildCostFromStreamJson(parsedCost, config);
585
+ writeFileSync(stagingPath, JSON.stringify(turnResult, null, 2) + '\n');
586
+ appendDiagnostic(logs, 'stream_json_cost_enriched', {
587
+ input_tokens: parsedCost.input_tokens,
588
+ output_tokens: parsedCost.output_tokens,
589
+ cost_usd: parsedCost.cost_usd,
590
+ model: parsedCost.model,
591
+ computed_usd: turnResult.cost.usd,
592
+ });
593
+ }
594
+ } catch {
595
+ // Cost enrichment is best-effort — if it fails, agent-reported cost is preserved
596
+ }
597
+ }
561
598
  settle({ ok: true, exitCode, timedOut: false, aborted: false, logs, firstOutputAt });
562
599
  } else if (isClaudeLocalCliRuntime(runtime) && hasClaudeAuthFailureOutput(logs)) {
563
600
  const recovery = 'Refresh Claude credentials before resuming: export a valid ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, then run agentxchain step --resume.';
@@ -1,5 +1,22 @@
1
1
  import { appendFileSync } from 'node:fs';
2
2
 
3
+ /**
4
+ * Normalize gate_summary to [gateName, statusString] entries.
5
+ * Handles both array format ([{ gate_id, status }]) from the real report pipeline
6
+ * and object format ({ gateName: statusString }) from legacy/synthetic fixtures.
7
+ */
8
+ function normalizeGateEntries(gates) {
9
+ if (Array.isArray(gates)) {
10
+ return gates
11
+ .filter(g => typeof g?.gate_id === 'string' && typeof g?.status === 'string')
12
+ .map(g => [g.gate_id, g.status]);
13
+ }
14
+ if (gates && typeof gates === 'object') {
15
+ return Object.entries(gates).filter(([, v]) => typeof v === 'string');
16
+ }
17
+ return [];
18
+ }
19
+
3
20
  /**
4
21
  * Detect CI environment from process.env.
5
22
  * @returns {{ provider: string, run_url: string|null, run_id: string|null, ref: string|null, sha: string|null } | null}
@@ -55,16 +72,12 @@ export function formatGitHubAnnotations(report) {
55
72
  }
56
73
 
57
74
  // Gate annotations
58
- const gates = report.subject?.run?.gate_summary;
59
- if (gates && typeof gates === 'object') {
60
- for (const [gateName, gateStatus] of Object.entries(gates)) {
61
- if (typeof gateStatus !== 'string') continue;
62
- const normalizedStatus = gateStatus.toLowerCase();
63
- if (normalizedStatus === 'satisfied' || normalizedStatus === 'pass' || normalizedStatus === 'passed') {
64
- lines.push(`::notice title=Gate ${gateName}::satisfied`);
65
- } else {
66
- lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
67
- }
75
+ for (const [gateName, gateStatus] of normalizeGateEntries(report.subject?.run?.gate_summary)) {
76
+ const normalizedStatus = gateStatus.toLowerCase();
77
+ if (normalizedStatus === 'satisfied' || normalizedStatus === 'pass' || normalizedStatus === 'passed') {
78
+ lines.push(`::notice title=Gate ${gateName}::satisfied`);
79
+ } else {
80
+ lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
68
81
  }
69
82
  }
70
83
 
@@ -113,7 +126,6 @@ export function writeGitHubOutputVars(report, outputPath) {
113
126
  */
114
127
  export function formatJUnitXml(report) {
115
128
  const run = report.subject?.run || {};
116
- const gates = run.gate_summary || {};
117
129
  const turns = run.turns || [];
118
130
 
119
131
  // Escape XML special characters
@@ -124,7 +136,7 @@ export function formatJUnitXml(report) {
124
136
  .replace(/"/g, '"');
125
137
 
126
138
  // Build gate test cases
127
- const gateEntries = Object.entries(gates).filter(([, v]) => typeof v === 'string');
139
+ const gateEntries = normalizeGateEntries(run.gate_summary);
128
140
  const gateFailures = gateEntries.filter(([, status]) => {
129
141
  const s = status.toLowerCase();
130
142
  return s !== 'satisfied' && s !== 'pass' && s !== 'passed';
@@ -1141,10 +1141,27 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
1141
1141
  }
1142
1142
 
1143
1143
  const errorClass = result.error_class || 'reconcile_refused';
1144
+ // RB-14: `reconcile-state --accept-operator-head` runs the SAME safety check, so it
1145
+ // cannot clear an unsafe-edit refusal — recommending it for governance_state_modified
1146
+ // or critical_artifact_deleted is a dead end. Give the recovery that actually applies.
1147
+ const offendingCommit = result.offending_commit || result.commit || null;
1148
+ const offendingPath = result.offending_path || result.path || null;
1149
+ let recoveryAction;
1150
+ let recoveryDetailTail;
1151
+ if (errorClass === 'governance_state_modified') {
1152
+ recoveryAction = offendingCommit ? `git revert ${offendingCommit.slice(0, 12)}` : 'git revert <offending-commit>';
1153
+ recoveryDetailTail = `${offendingPath || 'A commit'} edits governed state under .agentxchain/ directly, which the anti-tamper guard never auto-accepts (reconcile-state --accept-operator-head enforces the same check). Revert it (${recoveryAction}) to restore the governed lineage, or restart from an explicit prior checkpoint.`;
1154
+ } else if (errorClass === 'critical_artifact_deleted') {
1155
+ recoveryAction = (offendingCommit && offendingPath) ? `git checkout ${offendingCommit.slice(0, 12)}^ -- ${offendingPath}` : 'git checkout <commit>^ -- <deleted-path>';
1156
+ recoveryDetailTail = `Critical governed evidence ${offendingPath || ''} was deleted; restore it (${recoveryAction}), then resume.`;
1157
+ } else {
1158
+ recoveryAction = 'agentxchain reconcile-state --accept-operator-head';
1159
+ recoveryDetailTail = `Run: ${recoveryAction} once the unsafe changes are resolved, or revert them.`;
1160
+ }
1144
1161
  const detailLines = [
1145
1162
  `Operator-commit auto-reconcile refused (${errorClass}).`,
1146
1163
  result.error || 'Unsafe operator commits detected; manual recovery required.',
1147
- 'Run: agentxchain reconcile-state --accept-operator-head once the unsafe changes are resolved, or revert them.',
1164
+ recoveryDetailTail,
1148
1165
  ];
1149
1166
  const detail = detailLines.join(' ');
1150
1167
 
@@ -1164,7 +1181,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
1164
1181
  ...((state.blocked_reason || {}).recovery || {}),
1165
1182
  typed_reason: 'operator_commit_reconcile_refused',
1166
1183
  owner: 'human',
1167
- recovery_action: 'agentxchain reconcile-state --accept-operator-head',
1184
+ recovery_action: recoveryAction,
1168
1185
  turn_retained: false,
1169
1186
  detail,
1170
1187
  },
@@ -1196,7 +1213,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
1196
1213
  status: 'blocked',
1197
1214
  action: 'operator_commit_reconcile_refused',
1198
1215
  run_id: session.current_run_id,
1199
- recovery_action: 'agentxchain reconcile-state --accept-operator-head',
1216
+ recovery_action: recoveryAction,
1200
1217
  blocked_category: 'operator_commit_reconcile_refused',
1201
1218
  error_class: errorClass,
1202
1219
  };
@@ -2579,10 +2596,47 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
2579
2596
  process.on('SIGINT', sigHandler);
2580
2597
 
2581
2598
  try {
2599
+ // RB-13: circuit-breaker for the recover-and-continue path. A deterministic,
2600
+ // recurring failure (e.g. an unclean baseline) must escalate, not busy-loop
2601
+ // forever — without this, the same start error spun 582 times in seconds.
2602
+ // Track consecutive identical recoveries; halt and escalate after a cap, and
2603
+ // back off between attempts so even allowed retries can't tight-loop.
2604
+ let consecutiveRecoveries = 0;
2605
+ let lastRecoveryReason = null;
2606
+ const maxConsecutiveRecoveries = Math.max(1, contOpts.maxConsecutiveRecoveries ?? 3);
2607
+
2582
2608
  while (!stopping) {
2583
2609
  const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
2584
2610
 
2585
2611
  if (step.status === 'failed' && isGovernedRunStillActiveForSession(root, context.config, session)) {
2612
+ const recoveryReason = step.stop_reason || step.action || 'unknown';
2613
+ if (recoveryReason === lastRecoveryReason) {
2614
+ consecutiveRecoveries += 1;
2615
+ } else {
2616
+ consecutiveRecoveries = 1;
2617
+ lastRecoveryReason = recoveryReason;
2618
+ }
2619
+
2620
+ if (consecutiveRecoveries >= maxConsecutiveRecoveries) {
2621
+ // Circuit open: the same recoverable failure keeps recurring without
2622
+ // progress. Escalate to a human instead of spinning the loop.
2623
+ session.status = 'blocked';
2624
+ writeContinuousSession(root, session);
2625
+ emitRunEvent(root, 'continuous_recovery_circuit_open', {
2626
+ run_id: session.current_run_id || null,
2627
+ phase: null,
2628
+ status: 'blocked',
2629
+ payload: {
2630
+ session_id: session.session_id,
2631
+ failed_action: step.action || null,
2632
+ failed_reason: recoveryReason,
2633
+ consecutive_recoveries: consecutiveRecoveries,
2634
+ },
2635
+ });
2636
+ log(`Continuous loop halted (circuit-breaker): the same recoverable failure recurred ${consecutiveRecoveries} times with no progress — escalating to a human instead of spinning. Reason: ${recoveryReason}`);
2637
+ return { exitCode: 1, session };
2638
+ }
2639
+
2586
2640
  session.status = 'running';
2587
2641
  writeContinuousSession(root, session);
2588
2642
  emitRunEvent(root, 'session_failed_recovered_active_run', {
@@ -2593,12 +2647,20 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
2593
2647
  session_id: session.session_id,
2594
2648
  failed_action: step.action || null,
2595
2649
  failed_reason: step.stop_reason || null,
2650
+ consecutive_recoveries: consecutiveRecoveries,
2596
2651
  },
2597
2652
  });
2598
- log('Session failure recovered - governed run still active, continuing.');
2653
+ log(`Session failure recovered - governed run still active, continuing (recovery ${consecutiveRecoveries}/${maxConsecutiveRecoveries}).`);
2654
+ // Backoff before retrying so a recurring failure cannot busy-loop.
2655
+ const recoveryBackoffMs = Math.max(1000, (contOpts.cooldownSeconds ?? 5) * 1000);
2656
+ await new Promise((resolve) => setTimeout(resolve, recoveryBackoffMs));
2599
2657
  continue;
2600
2658
  }
2601
2659
 
2660
+ // A non-recovery step means the loop made forward progress — reset the breaker.
2661
+ consecutiveRecoveries = 0;
2662
+ lastRecoveryReason = null;
2663
+
2602
2664
  // Terminal states
2603
2665
  if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped' || step.status === 'vision_exhausted' || step.status === 'vision_expansion_exhausted' || step.status === 'session_budget') {
2604
2666
  const terminalMessage = describeContinuousTerminalStep(step, contOpts);
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Stream-JSON cost parser — extracts usage/cost metadata from Claude Code's
3
+ * stream-json NDJSON stdout output.
4
+ *
5
+ * Claude Code with `--output-format stream-json --verbose` emits newline-
6
+ * delimited JSON events. The final `result` event contains a `usage` object
7
+ * with `input_tokens`, `output_tokens`, and optionally cache token counts,
8
+ * plus a top-level `cost_usd` and `model` field.
9
+ *
10
+ * This parser accumulates stdout chunks, splits by newline, parses each
11
+ * complete line as JSON, and stores the last `result` event's cost data.
12
+ *
13
+ * Resilience guarantees:
14
+ * - Partial JSON lines are buffered until the next newline arrives.
15
+ * - Non-JSON lines are silently skipped (no throw, no error log).
16
+ * - Multiple `result` events: last one wins.
17
+ * - Missing `usage` in a `result` event: getResult() returns null.
18
+ */
19
+
20
+ // Claude-specific bundled cost rates (USD per million tokens).
21
+ // Duplicated from api-proxy-adapter.js for this module's use only — DEC-004
22
+ // defers extraction to a shared module.
23
+ const CLAUDE_COST_RATES = {
24
+ 'claude-sonnet-4-6': { input_per_1m: 3.00, output_per_1m: 15.00 },
25
+ 'claude-opus-4-6': { input_per_1m: 5.00, output_per_1m: 25.00 },
26
+ 'claude-haiku-4-5-20251001': { input_per_1m: 1.00, output_per_1m: 5.00 },
27
+ };
28
+
29
+ /**
30
+ * Resolve cost rates for a model. Checks operator-supplied overrides first,
31
+ * then falls back to the bundled Claude rates.
32
+ *
33
+ * @param {string} model
34
+ * @param {object} [config]
35
+ * @returns {{ input_per_1m: number, output_per_1m: number } | null}
36
+ */
37
+ export function getClaudeCostRates(model, config) {
38
+ const operatorRates = config?.budget?.cost_rates;
39
+ if (operatorRates && typeof operatorRates === 'object' && operatorRates[model]) {
40
+ const r = operatorRates[model];
41
+ if (Number.isFinite(r.input_per_1m) && Number.isFinite(r.output_per_1m)) {
42
+ return r;
43
+ }
44
+ }
45
+ return CLAUDE_COST_RATES[model] || null;
46
+ }
47
+
48
+ /**
49
+ * @typedef {Object} StreamJsonCostResult
50
+ * @property {number} input_tokens
51
+ * @property {number} output_tokens
52
+ * @property {number|null} cost_usd - Claude Code's self-reported cost (if present)
53
+ * @property {string|null} model - Model identifier for rate lookup
54
+ * @property {number|null} cache_creation_input_tokens
55
+ * @property {number|null} cache_read_input_tokens
56
+ */
57
+
58
+ /**
59
+ * Create a stateful parser that accumulates stream-json chunks and extracts
60
+ * the final result event containing usage metadata.
61
+ *
62
+ * @returns {{ push(chunk: string): void, getResult(): StreamJsonCostResult | null }}
63
+ */
64
+ export function createStreamJsonCostParser() {
65
+ let buffer = '';
66
+ /** @type {StreamJsonCostResult | null} */
67
+ let lastResult = null;
68
+
69
+ return {
70
+ /**
71
+ * Feed a stdout chunk into the parser.
72
+ * @param {string} chunk
73
+ */
74
+ push(chunk) {
75
+ buffer += chunk;
76
+ const lines = buffer.split('\n');
77
+ // Last element is either empty (line ended with \n) or a partial line
78
+ buffer = lines.pop() || '';
79
+
80
+ for (const line of lines) {
81
+ const trimmed = line.trim();
82
+ if (!trimmed) continue;
83
+
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(trimmed);
87
+ } catch {
88
+ // Non-JSON line (tool output, diagnostic text) — skip silently
89
+ continue;
90
+ }
91
+
92
+ if (
93
+ parsed &&
94
+ typeof parsed === 'object' &&
95
+ parsed.type === 'result' &&
96
+ parsed.usage &&
97
+ typeof parsed.usage === 'object' &&
98
+ Number.isFinite(parsed.usage.input_tokens) &&
99
+ Number.isFinite(parsed.usage.output_tokens)
100
+ ) {
101
+ lastResult = {
102
+ input_tokens: parsed.usage.input_tokens,
103
+ output_tokens: parsed.usage.output_tokens,
104
+ cost_usd: typeof parsed.cost_usd === 'number' && Number.isFinite(parsed.cost_usd)
105
+ ? parsed.cost_usd
106
+ : null,
107
+ model: typeof parsed.model === 'string' ? parsed.model : null,
108
+ cache_creation_input_tokens: Number.isFinite(parsed.usage.cache_creation_input_tokens)
109
+ ? parsed.usage.cache_creation_input_tokens
110
+ : null,
111
+ cache_read_input_tokens: Number.isFinite(parsed.usage.cache_read_input_tokens)
112
+ ? parsed.usage.cache_read_input_tokens
113
+ : null,
114
+ };
115
+ }
116
+ }
117
+ },
118
+
119
+ /**
120
+ * Return the extracted cost data, or null if no valid result event was found.
121
+ * @returns {StreamJsonCostResult | null}
122
+ */
123
+ getResult() {
124
+ return lastResult;
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Build a cost object from parsed stream-json data for turn-result enrichment.
131
+ *
132
+ * @param {StreamJsonCostResult} parsedCost
133
+ * @param {object} [config] - normalized config (for operator cost_rates overrides)
134
+ * @returns {{ input_tokens: number, output_tokens: number, usd: number, source: string, model?: string, cache_creation_input_tokens?: number, cache_read_input_tokens?: number }}
135
+ */
136
+ export function buildCostFromStreamJson(parsedCost, config) {
137
+ const cost = {
138
+ input_tokens: parsedCost.input_tokens,
139
+ output_tokens: parsedCost.output_tokens,
140
+ source: 'stream_json',
141
+ };
142
+
143
+ // Prefer Claude Code's own cost_usd when available
144
+ if (typeof parsedCost.cost_usd === 'number' && Number.isFinite(parsedCost.cost_usd)) {
145
+ cost.usd = Math.round(parsedCost.cost_usd * 1000) / 1000;
146
+ } else if (parsedCost.model) {
147
+ const rates = getClaudeCostRates(parsedCost.model, config);
148
+ if (rates) {
149
+ cost.usd = Math.round(
150
+ ((parsedCost.input_tokens / 1_000_000) * rates.input_per_1m +
151
+ (parsedCost.output_tokens / 1_000_000) * rates.output_per_1m) * 1000
152
+ ) / 1000;
153
+ } else {
154
+ cost.usd = 0;
155
+ }
156
+ } else {
157
+ cost.usd = 0;
158
+ }
159
+
160
+ if (parsedCost.model) cost.model = parsedCost.model;
161
+ if (parsedCost.cache_creation_input_tokens != null) {
162
+ cost.cache_creation_input_tokens = parsedCost.cache_creation_input_tokens;
163
+ }
164
+ if (parsedCost.cache_read_input_tokens != null) {
165
+ cost.cache_read_input_tokens = parsedCost.cache_read_input_tokens;
166
+ }
167
+
168
+ return cost;
169
+ }
@@ -737,6 +737,16 @@ function validateArtifact(tr, config, state = null) {
737
737
  `Role "${tr.role}" completed an implementation turn without product code changes in files_changed. ` +
738
738
  'Implementation-phase completion requires at least one non-planning, non-review repo path; planning artifacts alone are not sufficient.'
739
739
  );
740
+ } else if (productFiles.every(f => isTestPath(f))) {
741
+ // Work-substance signal: an implementation turn that changed ONLY test files produced
742
+ // no implementation source. That is legitimate for acceptance/verification objectives,
743
+ // but thin for an "implement X" objective — surface it for QA/operator review instead
744
+ // of silently accepting (this test-only pattern slipped through QA during dogfooding).
745
+ warnings.push(
746
+ `Role "${tr.role}" completed an implementation turn that changed only test files ` +
747
+ `(${productFiles.join(', ')}) with no implementation source. This is valid for acceptance or ` +
748
+ 'verification work; if the objective was to implement behavior, review whether the turn is test-only before accepting.'
749
+ );
740
750
  }
741
751
  }
742
752
 
@@ -792,6 +802,16 @@ function isProductChangePath(filePath) {
792
802
  && !filePath.startsWith('.agentxchain/staging/');
793
803
  }
794
804
 
805
+ // A test/spec file path — used to flag implementation turns that produced only tests
806
+ // (no implementation source) so test-only work is reviewed rather than silently accepted.
807
+ function isTestPath(filePath) {
808
+ if (typeof filePath !== 'string') {
809
+ return false;
810
+ }
811
+ return /(^|\/)(tests?|__tests__|spec)\//.test(filePath)
812
+ || /\.(test|spec)\.[cm]?[jt]sx?$/.test(filePath);
813
+ }
814
+
795
815
  function hasCheckpointableProducedFiles(tr) {
796
816
  return Array.isArray(tr.verification?.produced_files)
797
817
  && tr.verification.produced_files.some((entry) => (