agentxchain 2.155.73 → 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/bin/agentxchain.js +22 -0
- package/dashboard/app.js +54 -0
- package/dashboard/components/org-audit-trail.js +161 -0
- package/dashboard/components/org-history.js +140 -0
- package/dashboard/components/org-overview.js +145 -0
- package/dashboard/components/org-runs.js +168 -0
- package/dashboard/index.html +4 -0
- package/package.json +2 -1
- package/src/commands/ci-report.js +80 -0
- package/src/commands/doctor.js +22 -1
- package/src/commands/intake-approve.js +1 -0
- package/src/commands/replay.js +1 -0
- package/src/commands/run.js +47 -0
- package/src/commands/serve.js +64 -0
- package/src/commands/step.js +16 -0
- package/src/commands/verify.js +1 -0
- package/src/lib/adapters/local-cli-adapter.js +184 -0
- package/src/lib/api/execution-worker.js +192 -0
- package/src/lib/api/hosted-runner.js +494 -0
- package/src/lib/api/job-queue.js +152 -0
- package/src/lib/api/org-state-aggregator.js +428 -0
- package/src/lib/api/project-registry.js +148 -0
- package/src/lib/api/protocol-bridge.js +476 -0
- package/src/lib/approval-policy.js +12 -0
- package/src/lib/ci-reporter.js +200 -0
- package/src/lib/claude-local-auth.js +75 -1
- package/src/lib/connector-probe.js +21 -0
- package/src/lib/continuous-run.js +75 -4
- package/src/lib/dashboard/bridge-server.js +10 -5
- package/src/lib/governed-state.js +1 -1
- package/src/lib/intake.js +32 -6
- package/src/lib/normalized-config.js +2 -0
- package/src/lib/scope-overlap.js +214 -0
- package/src/lib/stream-json-cost-parser.js +169 -0
- package/src/lib/turn-checkpoint.js +21 -0
- package/src/lib/turn-result-validator.js +25 -0
- package/src/lib/validation.js +11 -3
- package/src/lib/verification-replay.js +125 -4
- package/src/lib/vision-reader.js +7 -2
|
@@ -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
|
+
}
|
|
@@ -352,6 +352,27 @@ export function checkpointAcceptedTurn(root, opts = {}) {
|
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
const entry = resolved.entry;
|
|
355
|
+
|
|
356
|
+
// Proposed turns materialize their files under .agentxchain/proposed/<turn_id>/, never
|
|
357
|
+
// the workspace — there is nothing to `git add` until `proposal apply` promotes them.
|
|
358
|
+
// Attempting to stage the declared workspace paths fails with "pathspec did not match",
|
|
359
|
+
// so checkpoint cleanly skips proposed/patch turns.
|
|
360
|
+
if (
|
|
361
|
+
entry?.artifact
|
|
362
|
+
&& typeof entry.artifact === 'object'
|
|
363
|
+
&& !Array.isArray(entry.artifact)
|
|
364
|
+
&& entry.artifact.type === 'patch'
|
|
365
|
+
&& typeof entry.artifact.ref === 'string'
|
|
366
|
+
&& entry.artifact.ref.startsWith('.agentxchain/proposed/')
|
|
367
|
+
) {
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
skipped: true,
|
|
371
|
+
turn: entry,
|
|
372
|
+
reason: 'Proposed turn materialized under .agentxchain/proposed/; no workspace checkpoint needed until proposal apply.',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
355
376
|
const supplementalFilesChanged = entry.checkpoint_sha
|
|
356
377
|
? recoverSupplementalCheckpointFiles(root, entry)
|
|
357
378
|
: [];
|
|
@@ -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) => (
|
|
@@ -1523,6 +1543,11 @@ export function normalizeTurnResult(tr, config, context = {}) {
|
|
|
1523
1543
|
&& (
|
|
1524
1544
|
context.forceReviewArtifact
|
|
1525
1545
|
|| hasExplicitNoEditLifecycleSignal
|
|
1546
|
+
// review_only roles (PM/QA) legitimately produce no-edit reviews — an empty
|
|
1547
|
+
// workspace from a COMPLETED review_only turn normalizes to a review. Authoritative
|
|
1548
|
+
// /code-writing roles claiming a completed workspace artifact with no files stay
|
|
1549
|
+
// fail-closed, and non-completed (blocked/failed) turns never normalize (BUG-78).
|
|
1550
|
+
|| (isReviewOnly && normalized.status === 'completed')
|
|
1526
1551
|
|| (normalized.status === 'needs_human' && normalized.proposed_next_role === 'human')
|
|
1527
1552
|
)
|
|
1528
1553
|
) {
|
package/src/lib/validation.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { validateStagedTurnResult, STAGING_PATH } from './turn-result-validator.js';
|
|
4
4
|
import { getActiveTurn } from './governed-state.js';
|
|
5
|
+
import { getTurnStagingResultPath } from './turn-paths.js';
|
|
5
6
|
import {
|
|
6
7
|
validateGovernedProjectTemplate,
|
|
7
8
|
validateGovernedTemplateRegistry,
|
|
@@ -191,11 +192,18 @@ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
|
|
|
191
192
|
|
|
192
193
|
// ── Staged turn-result validation (the acceptance boundary) ─────────────
|
|
193
194
|
if (mode === 'turn' && state) {
|
|
194
|
-
|
|
195
|
+
// RB-12: prefer the turn-scoped staging path for the active turn; the shared
|
|
196
|
+
// legacy path is only a fallback. Reading the legacy path unconditionally
|
|
197
|
+
// made `validate --mode turn` look at the wrong file and falsely report a
|
|
198
|
+
// missing turn result for turns staged at the turn-scoped path.
|
|
199
|
+
const activeTurn = getActiveTurn(state) || state.current_turn || null;
|
|
200
|
+
const turnScoped = activeTurn?.turn_id ? getTurnStagingResultPath(activeTurn.turn_id) : null;
|
|
201
|
+
const stagingRel = (turnScoped && existsSync(join(root, turnScoped))) ? turnScoped : STAGING_PATH;
|
|
202
|
+
const stagingAbs = join(root, stagingRel);
|
|
195
203
|
if (!existsSync(stagingAbs)) {
|
|
196
|
-
warnings.push(`No staged turn result found at ${
|
|
204
|
+
warnings.push(`No staged turn result found at ${stagingRel}. Agent has not yet emitted a turn result.`);
|
|
197
205
|
} else {
|
|
198
|
-
const turnValidation = validateStagedTurnResult(root, state, config);
|
|
206
|
+
const turnValidation = validateStagedTurnResult(root, state, config, { stagingPath: stagingRel });
|
|
199
207
|
if (!turnValidation.ok) {
|
|
200
208
|
errors.push(
|
|
201
209
|
`Staged turn result failed at stage "${turnValidation.stage}" (${turnValidation.error_class}):`,
|
|
@@ -8,7 +8,7 @@ import { isOperationalPath } from './repo-observer.js';
|
|
|
8
8
|
|
|
9
9
|
export const DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS = 30_000;
|
|
10
10
|
|
|
11
|
-
export function replayVerificationMachineEvidence({ root, verification, timeoutMs = DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS }) {
|
|
11
|
+
export function replayVerificationMachineEvidence({ root, verification, timeoutMs = DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS, allowCommandExecution = false }) {
|
|
12
12
|
const verifiedAt = new Date().toISOString();
|
|
13
13
|
const machineEvidence = Array.isArray(verification?.machine_evidence)
|
|
14
14
|
? verification.machine_evidence
|
|
@@ -28,6 +28,27 @@ export function replayVerificationMachineEvidence({ root, verification, timeoutM
|
|
|
28
28
|
return payload;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// SECURITY (verification-replay hardening): agent-declared machine_evidence
|
|
32
|
+
// commands are UNTRUSTED. Fail-safe default — they are recorded but NOT executed
|
|
33
|
+
// unless the operator has explicitly opted in (allowCommandExecution). When
|
|
34
|
+
// executed, they never run through a shell (see replayEvidenceCommand).
|
|
35
|
+
if (!allowCommandExecution) {
|
|
36
|
+
payload.overall = 'not_executed';
|
|
37
|
+
payload.reason = 'Command replay disabled: agent-declared verification commands are not executed unless explicitly enabled by the operator.';
|
|
38
|
+
payload.commands = machineEvidence.map((entry, index) => ({
|
|
39
|
+
index,
|
|
40
|
+
command: entry?.command ?? null,
|
|
41
|
+
declared_exit_code: entry?.exit_code,
|
|
42
|
+
actual_exit_code: null,
|
|
43
|
+
matched: false,
|
|
44
|
+
executed: false,
|
|
45
|
+
timed_out: false,
|
|
46
|
+
signal: null,
|
|
47
|
+
error: null,
|
|
48
|
+
}));
|
|
49
|
+
return payload;
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
const workspaceGuard = createReplayWorkspaceGuard(root);
|
|
32
53
|
try {
|
|
33
54
|
payload.commands = machineEvidence.map((entry, index) => replayEvidenceCommand(root, entry, index, timeoutMs));
|
|
@@ -41,11 +62,33 @@ export function replayVerificationMachineEvidence({ root, verification, timeoutM
|
|
|
41
62
|
return payload;
|
|
42
63
|
}
|
|
43
64
|
|
|
65
|
+
// SECURITY: agent-declared commands are NEVER run through a shell. The command is
|
|
66
|
+
// resolved to an argv vector and executed with shell:false, so shell metacharacters
|
|
67
|
+
// (; | && $() backticks globs) cannot chain, pipe, expand, or substitute. (A single
|
|
68
|
+
// declared binary still runs on the host — that is gated by the explicit
|
|
69
|
+
// allowCommandExecution opt-in upstream; a future sandbox/allowlist can constrain
|
|
70
|
+
// which binaries are permitted.)
|
|
44
71
|
export function replayEvidenceCommand(root, entry, index, timeoutMs = DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS) {
|
|
45
|
-
const
|
|
72
|
+
const argv = resolveEvidenceArgv(entry);
|
|
73
|
+
if (argv.length === 0) {
|
|
74
|
+
return {
|
|
75
|
+
index,
|
|
76
|
+
command: entry?.command ?? null,
|
|
77
|
+
declared_exit_code: entry?.exit_code,
|
|
78
|
+
actual_exit_code: null,
|
|
79
|
+
matched: false,
|
|
80
|
+
executed: false,
|
|
81
|
+
timed_out: false,
|
|
82
|
+
signal: null,
|
|
83
|
+
error: 'No executable command or argv vector could be resolved for this evidence entry.',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const [file, ...args] = argv;
|
|
88
|
+
const result = spawnSync(file, args, {
|
|
46
89
|
cwd: root,
|
|
47
90
|
encoding: 'utf8',
|
|
48
|
-
shell:
|
|
91
|
+
shell: false,
|
|
49
92
|
timeout: timeoutMs,
|
|
50
93
|
maxBuffer: 1024 * 1024,
|
|
51
94
|
});
|
|
@@ -56,16 +99,94 @@ export function replayEvidenceCommand(root, entry, index, timeoutMs = DEFAULT_VE
|
|
|
56
99
|
|
|
57
100
|
return {
|
|
58
101
|
index,
|
|
59
|
-
command: entry.command,
|
|
102
|
+
command: entry.command ?? argv.join(' '),
|
|
60
103
|
declared_exit_code: entry.exit_code,
|
|
61
104
|
actual_exit_code: actualExitCode,
|
|
62
105
|
matched: actualExitCode === entry.exit_code,
|
|
106
|
+
executed: true,
|
|
63
107
|
timed_out: timedOut,
|
|
64
108
|
signal: result.signal || null,
|
|
65
109
|
error: errorMessage,
|
|
66
110
|
};
|
|
67
111
|
}
|
|
68
112
|
|
|
113
|
+
// Resolve an evidence entry to an argv vector for shell-free execution.
|
|
114
|
+
// Prefers a structured `argv` string array; falls back to safe-tokenizing `command`.
|
|
115
|
+
export function resolveEvidenceArgv(entry) {
|
|
116
|
+
if (Array.isArray(entry?.argv) && entry.argv.length > 0 && entry.argv.every((a) => typeof a === 'string')) {
|
|
117
|
+
return entry.argv.slice();
|
|
118
|
+
}
|
|
119
|
+
if (typeof entry?.command === 'string' && entry.command.trim()) {
|
|
120
|
+
return tokenizeCommand(entry.command.trim());
|
|
121
|
+
}
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Minimal SAFE tokenizer: splits on whitespace, honoring single and double quotes
|
|
126
|
+
// and backslash escapes (so e.g. `node -e "{\"ok\":true}"` keeps its escaped quotes).
|
|
127
|
+
// Performs NO shell expansion — variables, globs, pipes, chaining, and command
|
|
128
|
+
// substitution are preserved as literal characters, never interpreted.
|
|
129
|
+
export function tokenizeCommand(command) {
|
|
130
|
+
if (typeof command !== 'string') {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const tokens = [];
|
|
134
|
+
let current = '';
|
|
135
|
+
let hasToken = false;
|
|
136
|
+
let i = 0;
|
|
137
|
+
const n = command.length;
|
|
138
|
+
while (i < n) {
|
|
139
|
+
const ch = command[i];
|
|
140
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
|
141
|
+
if (hasToken) {
|
|
142
|
+
tokens.push(current);
|
|
143
|
+
current = '';
|
|
144
|
+
hasToken = false;
|
|
145
|
+
}
|
|
146
|
+
i += 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
hasToken = true;
|
|
150
|
+
if (ch === "'") {
|
|
151
|
+
// Single-quoted: literal run until the next single quote (no escapes, POSIX-style).
|
|
152
|
+
i += 1;
|
|
153
|
+
while (i < n && command[i] !== "'") {
|
|
154
|
+
current += command[i];
|
|
155
|
+
i += 1;
|
|
156
|
+
}
|
|
157
|
+
i += 1; // consume closing quote (or run off the end)
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ch === '"') {
|
|
161
|
+
// Double-quoted: backslash escapes only the structural `"` and `\` characters.
|
|
162
|
+
i += 1;
|
|
163
|
+
while (i < n && command[i] !== '"') {
|
|
164
|
+
if (command[i] === '\\' && i + 1 < n && (command[i + 1] === '"' || command[i + 1] === '\\')) {
|
|
165
|
+
current += command[i + 1];
|
|
166
|
+
i += 2;
|
|
167
|
+
} else {
|
|
168
|
+
current += command[i];
|
|
169
|
+
i += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
i += 1; // consume closing quote (or run off the end)
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (ch === '\\' && i + 1 < n) {
|
|
176
|
+
// Bare backslash escape: take the next character literally.
|
|
177
|
+
current += command[i + 1];
|
|
178
|
+
i += 2;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
current += ch;
|
|
182
|
+
i += 1;
|
|
183
|
+
}
|
|
184
|
+
if (hasToken) {
|
|
185
|
+
tokens.push(current);
|
|
186
|
+
}
|
|
187
|
+
return tokens;
|
|
188
|
+
}
|
|
189
|
+
|
|
69
190
|
export function summarizeVerificationReplay(payload) {
|
|
70
191
|
if (!payload) {
|
|
71
192
|
return null;
|
package/src/lib/vision-reader.js
CHANGED
|
@@ -16,6 +16,11 @@ import { join, resolve as pathResolve, isAbsolute } from 'node:path';
|
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
|
|
18
18
|
const ROADMAP_TRACKING_ANNOTATION_PATTERN = /<!--\s*tracking\s*:[\s\S]*?-->/i;
|
|
19
|
+
// RB-3: a milestone left unchecked but annotated as delivered/done/shipped is
|
|
20
|
+
// already-built work — skip it so the loop derives genuinely undelivered work
|
|
21
|
+
// instead of re-picking finished items. Distinct from generic comments like
|
|
22
|
+
// `<!-- owner: dev -->`, which stay actionable.
|
|
23
|
+
const ROADMAP_DELIVERED_ANNOTATION_PATTERN = /<!--[^>]*\b(delivered|shipped|implemented|completed|done)\b[^>]*-->/i;
|
|
19
24
|
|
|
20
25
|
export function stripRoadmapTrackingAnnotations(text) {
|
|
21
26
|
if (typeof text !== 'string') return '';
|
|
@@ -266,7 +271,7 @@ export function deriveRoadmapCandidates(root, roadmapPath = '.planning/ROADMAP.m
|
|
|
266
271
|
|
|
267
272
|
const uncheckedMatch = line.match(/^\s*[-*]\s+\[\s\]\s+(.+?)\s*$/);
|
|
268
273
|
if (!uncheckedMatch || !currentMilestone) continue;
|
|
269
|
-
if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line)) continue;
|
|
274
|
+
if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line) || ROADMAP_DELIVERED_ANNOTATION_PATTERN.test(line)) continue;
|
|
270
275
|
|
|
271
276
|
const goal = stripRoadmapTrackingAnnotations(uncheckedMatch[1]);
|
|
272
277
|
if (!goal) continue;
|
|
@@ -493,7 +498,7 @@ export function detectRoadmapExhaustedVisionOpen(root, visionPath, roadmapPath =
|
|
|
493
498
|
continue;
|
|
494
499
|
}
|
|
495
500
|
if (currentMilestone && /^\s*[-*]\s+\[\s\]/.test(line)) {
|
|
496
|
-
if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line)) continue;
|
|
501
|
+
if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line) || ROADMAP_DELIVERED_ANNOTATION_PATTERN.test(line)) continue;
|
|
497
502
|
hasUnchecked = true;
|
|
498
503
|
}
|
|
499
504
|
}
|