agentxchain 2.156.0 → 2.158.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -138,7 +138,7 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
138
138
  }
139
139
 
140
140
  // ── Stage C: Artifact Validation ───────────────────────────────────────
141
- const artifactResult = validateArtifact(turnResult, config, state);
141
+ const artifactResult = validateArtifact(turnResult, config, state, root);
142
142
  if (artifactResult.errors.length > 0) {
143
143
  return result('artifact', 'artifact_error', artifactResult.errors, artifactResult.warnings);
144
144
  }
@@ -670,7 +670,7 @@ function validateAssignment(tr, state) {
670
670
 
671
671
  // ── Stage C: Artifact Validation ─────────────────────────────────────────────
672
672
 
673
- function validateArtifact(tr, config, state = null) {
673
+ function validateArtifact(tr, config, state = null, root = null) {
674
674
  const errors = [];
675
675
  const warnings = [];
676
676
 
@@ -733,9 +733,27 @@ function validateArtifact(tr, config, state = null) {
733
733
  if (writeAuthority === 'authoritative' && state?.phase === 'implementation' && tr.status === 'completed') {
734
734
  const productFiles = (tr.files_changed || []).filter(f => isProductChangePath(f));
735
735
  if (productFiles.length === 0) {
736
- errors.push(
737
- `Role "${tr.role}" completed an implementation turn without product code changes in files_changed. ` +
738
- 'Implementation-phase completion requires at least one non-planning, non-review repo path; planning artifacts alone are not sufficient.'
736
+ // A planning/review-only completion is legitimate ONLY as a follow-on once the
737
+ // objective's product code has already been delivered in a prior accepted turn of this
738
+ // run (e.g. QA, or a Dev re-run, finalizing the gate-required IMPLEMENTATION_NOTES
739
+ // ## Changes / ## Verification sections after the implementation itself was committed).
740
+ // If no product code has been committed for the run yet, planning artifacts alone still
741
+ // do not satisfy the implementation_complete gate.
742
+ if (!runHasCommittedProductCode(root, state?.run_id)) {
743
+ errors.push(
744
+ `Role "${tr.role}" completed an implementation turn without product code changes in files_changed. ` +
745
+ 'Implementation-phase completion requires at least one non-planning, non-review repo path; planning artifacts alone are not sufficient.'
746
+ );
747
+ }
748
+ } else if (productFiles.every(f => isTestPath(f))) {
749
+ // Work-substance signal: an implementation turn that changed ONLY test files produced
750
+ // no implementation source. That is legitimate for acceptance/verification objectives,
751
+ // but thin for an "implement X" objective — surface it for QA/operator review instead
752
+ // of silently accepting (this test-only pattern slipped through QA during dogfooding).
753
+ warnings.push(
754
+ `Role "${tr.role}" completed an implementation turn that changed only test files ` +
755
+ `(${productFiles.join(', ')}) with no implementation source. This is valid for acceptance or ` +
756
+ 'verification work; if the objective was to implement behavior, review whether the turn is test-only before accepting.'
739
757
  );
740
758
  }
741
759
  }
@@ -792,6 +810,44 @@ function isProductChangePath(filePath) {
792
810
  && !filePath.startsWith('.agentxchain/staging/');
793
811
  }
794
812
 
813
+ // Whether any prior accepted turn in this run already committed product code. A follow-on
814
+ // implementation-phase turn that only finalizes planning artifacts (e.g. the gate-required
815
+ // IMPLEMENTATION_NOTES ## Changes / ## Verification sections) is legitimate once the
816
+ // objective's implementation is delivered; a run that has produced no product code at all is
817
+ // still held to the "code, not just docs" requirement. Reads the governed accepted-turn log.
818
+ function runHasCommittedProductCode(root, runId) {
819
+ if (!root || !runId) return false;
820
+ try {
821
+ const histPath = join(root, '.agentxchain', 'history.jsonl');
822
+ if (!existsSync(histPath)) return false;
823
+ for (const line of readFileSync(histPath, 'utf8').split('\n')) {
824
+ if (!line.trim()) continue;
825
+ let entry;
826
+ try { entry = JSON.parse(line); } catch { continue; }
827
+ if (entry.run_id !== runId) continue;
828
+ // Only a COMPLETED IMPLEMENTATION turn counts as "the objective's code was delivered" — a
829
+ // planning-phase or non-completed turn that incidentally touched a file must not disarm the
830
+ // guard (adversarial-review hardening).
831
+ if (entry.status !== 'completed' || entry.phase !== 'implementation') continue;
832
+ const files = Array.isArray(entry.files_changed) ? entry.files_changed : [];
833
+ if (files.some((f) => isProductChangePath(f))) return true;
834
+ }
835
+ } catch {
836
+ // History unreadable — fall back to the strict requirement (treat as no prior product code).
837
+ }
838
+ return false;
839
+ }
840
+
841
+ // A test/spec file path — used to flag implementation turns that produced only tests
842
+ // (no implementation source) so test-only work is reviewed rather than silently accepted.
843
+ function isTestPath(filePath) {
844
+ if (typeof filePath !== 'string') {
845
+ return false;
846
+ }
847
+ return /(^|\/)(tests?|__tests__|spec)\//.test(filePath)
848
+ || /\.(test|spec)\.[cm]?[jt]sx?$/.test(filePath);
849
+ }
850
+
795
851
  function hasCheckpointableProducedFiles(tr) {
796
852
  return Array.isArray(tr.verification?.produced_files)
797
853
  && tr.verification.produced_files.some((entry) => (