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.
- package/bin/agentxchain.js +18 -0
- package/package.json +1 -1
- package/src/commands/attention.js +66 -0
- package/src/commands/ship-status.js +104 -0
- package/src/lib/adapters/local-cli-adapter.js +37 -0
- package/src/lib/ci-reporter.js +24 -12
- package/src/lib/continuous-run.js +66 -4
- package/src/lib/dispatch-bundle.js +12 -0
- package/src/lib/human-attention.js +381 -0
- package/src/lib/report.js +15 -0
- package/src/lib/ship-status.js +545 -0
- package/src/lib/stream-json-cost-parser.js +169 -0
- package/src/lib/turn-result-validator.js +61 -5
|
@@ -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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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) => (
|