@kontourai/flow-agents 2.1.0 → 2.2.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/.github/CODEOWNERS +2 -0
- package/.github/workflows/ci.yml +4 -4
- package/.github/workflows/docs-pages.yml +1 -1
- package/.github/workflows/kit-gates-demo.yml +2 -2
- package/.github/workflows/publish-npm.yml +2 -2
- package/.github/workflows/runtime-compat.yml +2 -2
- package/.github/workflows/trust-reconcile.yml +1 -1
- package/CHANGELOG.md +29 -0
- package/build/src/cli/sidecar-claim-explain.d.ts +45 -0
- package/build/src/cli/sidecar-claim-explain.js +87 -0
- package/build/src/cli/telemetry-doctor.js +2 -4
- package/build/src/cli/usage-feedback.js +16 -8
- package/build/src/cli/workflow-artifact-cleanup-audit.js +4 -2
- package/build/src/cli/workflow-sidecar.d.ts +2 -44
- package/build/src/cli/workflow-sidecar.js +18 -87
- package/build/src/index.d.ts +1 -0
- package/build/src/index.js +1 -0
- package/build/src/lib/local-artifact-root.d.ts +11 -0
- package/build/src/lib/local-artifact-root.js +30 -0
- package/build/src/tools/validate-source-tree.js +1 -0
- package/context/scripts/telemetry/lib/config.sh +3 -4
- package/docs/adr/0018-freeze-local-shell-heuristics.md +95 -0
- package/docs/repository-structure.md +7 -3
- package/evals/integration/test_bundle_lifecycle.sh +9 -9
- package/evals/integration/test_gate_lockdown.sh +25 -6
- package/evals/integration/test_migrate_local_artifacts.sh +102 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +34 -0
- package/evals/run.sh +2 -0
- package/evals/static/test_unit_helpers.sh +26 -0
- package/integrations/strands-ts/package.json +3 -0
- package/integrations/strands-ts/src/telemetry.ts +31 -50
- package/kits/knowledge/adapters/flow-runner/index.js +1 -1
- package/kits/knowledge/adapters/flow-runner/telemetry.js +2 -2
- package/package.json +2 -1
- package/scripts/README.md +1 -0
- package/scripts/ci/trust-reconcile.js +7 -23
- package/scripts/hooks/config-protection.js +6 -0
- package/scripts/hooks/evidence-capture.js +26 -47
- package/scripts/hooks/lib/local-artifact-paths.js +32 -0
- package/scripts/hooks/stop-goal-fit.js +37 -58
- package/scripts/hooks/workflow-steering.js +5 -3
- package/scripts/lib/command-log-chain.js +78 -0
- package/scripts/migrate-local-artifacts.mjs +230 -0
- package/scripts/repair-command-log.js +8 -15
- package/scripts/telemetry/lib/config.sh +3 -4
- package/src/cli/public-api.test.mjs +21 -0
- package/src/cli/sidecar-claim-explain.ts +130 -0
- package/src/cli/sidecar-pure-helpers.test.mjs +168 -0
- package/src/cli/telemetry-doctor.ts +2 -4
- package/src/cli/usage-feedback.ts +15 -8
- package/src/cli/workflow-artifact-cleanup-audit.ts +4 -2
- package/src/cli/workflow-sidecar.ts +17 -131
- package/src/index.ts +14 -0
- package/src/lib/local-artifact-root.ts +39 -0
- package/src/tools/validate-source-tree.ts +1 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
const VERITAS_GENERATED = new Set([
|
|
6
|
+
"evidence",
|
|
7
|
+
"claims",
|
|
8
|
+
"runs",
|
|
9
|
+
"repo-conformance",
|
|
10
|
+
"external",
|
|
11
|
+
"eval-drafts",
|
|
12
|
+
"evals",
|
|
13
|
+
"checkins",
|
|
14
|
+
"init-plans",
|
|
15
|
+
"recommendations",
|
|
16
|
+
"surface-console",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const opts = { repos: [], workspaces: [], apply: false, dryRun: true, json: false, includeFlowAgents: false, force: false };
|
|
21
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
22
|
+
const arg = argv[i];
|
|
23
|
+
if (arg === "--apply") { opts.apply = true; opts.dryRun = false; continue; }
|
|
24
|
+
if (arg === "--dry-run") { opts.dryRun = true; opts.apply = false; continue; }
|
|
25
|
+
if (arg === "--json") { opts.json = true; continue; }
|
|
26
|
+
if (arg === "--include-flow-agents") { opts.includeFlowAgents = true; continue; }
|
|
27
|
+
if (arg === "--force") { opts.force = true; continue; }
|
|
28
|
+
if (arg === "--repo") { opts.repos.push(requireValue(argv, ++i, arg)); continue; }
|
|
29
|
+
if (arg === "--workspace") { opts.workspaces.push(requireValue(argv, ++i, arg)); continue; }
|
|
30
|
+
if (arg === "--help" || arg === "-h") { opts.help = true; continue; }
|
|
31
|
+
throw new Error(`unknown option: ${arg}`);
|
|
32
|
+
}
|
|
33
|
+
return opts;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireValue(argv, index, option) {
|
|
37
|
+
const value = argv[index];
|
|
38
|
+
if (!value || value.startsWith("--")) throw new Error(`${option} requires a path`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function usage() {
|
|
43
|
+
return `Usage: node scripts/migrate-local-artifacts.mjs [--workspace <path>] [--repo <path>] [--include-flow-agents] [--apply] [--force] [--json]
|
|
44
|
+
|
|
45
|
+
Dry-run is the default. --apply copies generated local artifacts into .kontourai without deleting old paths.
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function unique(values) {
|
|
50
|
+
return [...new Set(values.map((value) => path.resolve(value)))];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function repoCandidates(opts) {
|
|
54
|
+
const repos = [...opts.repos.map((repo) => path.resolve(repo))];
|
|
55
|
+
const workspaces = opts.workspaces.length ? opts.workspaces : (opts.repos.length ? [] : [process.cwd()]);
|
|
56
|
+
for (const workspace of workspaces) {
|
|
57
|
+
const root = path.resolve(workspace);
|
|
58
|
+
if (isDirectory(root)) repos.push(root);
|
|
59
|
+
let entries = [];
|
|
60
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry.isDirectory() || entry.name === "node_modules") continue;
|
|
63
|
+
repos.push(path.join(root, entry.name));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return unique(repos).filter(isDirectory);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isDirectory(file) {
|
|
70
|
+
try { return fs.statSync(file).isDirectory(); } catch { return false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function exists(file) {
|
|
74
|
+
try { fs.lstatSync(file); return true; } catch { return false; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function symlinkStatus(file) {
|
|
78
|
+
try { return fs.lstatSync(file).isSymbolicLink(); } catch { return false; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasSymlinkParent(repo, target) {
|
|
82
|
+
const relativeParent = path.relative(repo, path.dirname(target));
|
|
83
|
+
if (!relativeParent || relativeParent.startsWith("..") || path.isAbsolute(relativeParent)) return false;
|
|
84
|
+
let current = repo;
|
|
85
|
+
for (const part of relativeParent.split(path.sep)) {
|
|
86
|
+
if (!part) continue;
|
|
87
|
+
current = path.join(current, part);
|
|
88
|
+
if (symlinkStatus(current)) return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function generatedMappings(repo, includeFlowAgents) {
|
|
94
|
+
const mappings = [
|
|
95
|
+
[".kontour", ".kontourai/console"],
|
|
96
|
+
[".telemetry", ".kontourai/telemetry"],
|
|
97
|
+
[".flow/runs", ".kontourai/flow/runs"],
|
|
98
|
+
[".surface/runs", ".kontourai/surface/runs"],
|
|
99
|
+
];
|
|
100
|
+
for (const name of VERITAS_GENERATED) mappings.push([`.veritas/${name}`, `.kontourai/veritas/${name}`]);
|
|
101
|
+
mappings.push([".veritas/standards-feedback", ".kontourai/veritas/standards-feedback"]);
|
|
102
|
+
mappings.push([".veritas/standards-feedback-drafts", ".kontourai/veritas/standards-feedback-drafts"]);
|
|
103
|
+
if (includeFlowAgents) mappings.push([".flow-agents", ".kontourai/flow-agents"]);
|
|
104
|
+
return mappings.map(([from, to]) => ({ repo, from: path.join(repo, from), to: path.join(repo, to), relFrom: from, relTo: to }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function durableSkips(repo, includeFlowAgents) {
|
|
108
|
+
const paths = [
|
|
109
|
+
".flow/config.json",
|
|
110
|
+
".flow/definitions",
|
|
111
|
+
".veritas/repo-map.json",
|
|
112
|
+
".veritas/repo-standards",
|
|
113
|
+
".veritas/authority",
|
|
114
|
+
".veritas/proof-families",
|
|
115
|
+
".agents",
|
|
116
|
+
".claude/commands",
|
|
117
|
+
"docs",
|
|
118
|
+
];
|
|
119
|
+
if (!includeFlowAgents) paths.push(".flow-agents");
|
|
120
|
+
return paths.map((rel) => ({ repo, path: path.join(repo, rel), rel, reason: "durable-or-not-included" })).filter((item) => exists(item.path));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function listFiles(root) {
|
|
124
|
+
const out = [];
|
|
125
|
+
const stack = [root];
|
|
126
|
+
while (stack.length) {
|
|
127
|
+
const dir = stack.pop();
|
|
128
|
+
let entries = [];
|
|
129
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const full = path.join(dir, entry.name);
|
|
132
|
+
if (entry.isSymbolicLink()) {
|
|
133
|
+
out.push({ file: full, symlink: true });
|
|
134
|
+
} else if (entry.isDirectory()) {
|
|
135
|
+
stack.push(full);
|
|
136
|
+
} else if (entry.isFile()) {
|
|
137
|
+
out.push({ file: full, symlink: false });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out.sort((a, b) => a.file.localeCompare(b.file));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function planRepo(repo, opts) {
|
|
145
|
+
const copies = [];
|
|
146
|
+
const skipped = durableSkips(repo, opts.includeFlowAgents);
|
|
147
|
+
for (const mapping of generatedMappings(repo, opts.includeFlowAgents)) {
|
|
148
|
+
if (!exists(mapping.from)) continue;
|
|
149
|
+
const stat = fs.lstatSync(mapping.from);
|
|
150
|
+
if (stat.isSymbolicLink()) {
|
|
151
|
+
skipped.push({ repo, path: mapping.from, rel: mapping.relFrom, reason: "source-is-symlink" });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (stat.isFile()) {
|
|
155
|
+
skipped.push({ repo, path: mapping.from, rel: mapping.relFrom, reason: "generated-root-is-file" });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!stat.isDirectory()) continue;
|
|
159
|
+
for (const item of listFiles(mapping.from)) {
|
|
160
|
+
const rel = path.relative(mapping.from, item.file);
|
|
161
|
+
const target = path.join(mapping.to, rel);
|
|
162
|
+
if (item.symlink) {
|
|
163
|
+
skipped.push({ repo, path: item.file, rel: path.join(mapping.relFrom, rel), reason: "source-is-symlink" });
|
|
164
|
+
} else if (hasSymlinkParent(repo, target)) {
|
|
165
|
+
skipped.push({ repo, path: item.file, rel: path.join(mapping.relFrom, rel), target, reason: "target-parent-is-symlink" });
|
|
166
|
+
} else if (exists(target)) {
|
|
167
|
+
if (symlinkStatus(target)) {
|
|
168
|
+
skipped.push({ repo, path: item.file, rel: path.join(mapping.relFrom, rel), target, reason: "target-is-symlink" });
|
|
169
|
+
} else if (!opts.force) {
|
|
170
|
+
skipped.push({ repo, path: item.file, rel: path.join(mapping.relFrom, rel), target, reason: "target-exists" });
|
|
171
|
+
} else {
|
|
172
|
+
copies.push({ repo, source: item.file, target, relSource: path.join(mapping.relFrom, rel), relTarget: path.join(mapping.relTo, rel) });
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
copies.push({ repo, source: item.file, target, relSource: path.join(mapping.relFrom, rel), relTarget: path.join(mapping.relTo, rel) });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { repo, copies, skipped };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function copyFile(source, target) {
|
|
183
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
184
|
+
fs.copyFileSync(source, target);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function run(argv = process.argv.slice(2)) {
|
|
188
|
+
const opts = parseArgs(argv);
|
|
189
|
+
if (opts.help) return { help: usage(), reports: [], summary: { repos: 0, copies: 0, skipped: 0, applied: false, dry_run: true } };
|
|
190
|
+
const reports = repoCandidates(opts).map((repo) => planRepo(repo, opts));
|
|
191
|
+
if (opts.apply) {
|
|
192
|
+
for (const report of reports) for (const copy of report.copies) copyFile(copy.source, copy.target);
|
|
193
|
+
}
|
|
194
|
+
const summary = {
|
|
195
|
+
repos: reports.length,
|
|
196
|
+
copies: reports.reduce((sum, report) => sum + report.copies.length, 0),
|
|
197
|
+
skipped: reports.reduce((sum, report) => sum + report.skipped.length, 0),
|
|
198
|
+
applied: opts.apply,
|
|
199
|
+
dry_run: !opts.apply,
|
|
200
|
+
};
|
|
201
|
+
return { reports, summary };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function printText(result) {
|
|
205
|
+
if (result.help) {
|
|
206
|
+
process.stdout.write(result.help);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
console.log(`Local artifact migration ${result.summary.dry_run ? "dry-run" : "apply"}`);
|
|
210
|
+
console.log(`Repositories scanned: ${result.summary.repos}`);
|
|
211
|
+
console.log(`Files to copy: ${result.summary.copies}`);
|
|
212
|
+
console.log(`Skipped paths: ${result.summary.skipped}`);
|
|
213
|
+
for (const report of result.reports) {
|
|
214
|
+
if (!report.copies.length && !report.skipped.length) continue;
|
|
215
|
+
console.log(`\n${report.repo}`);
|
|
216
|
+
for (const copy of report.copies) console.log(` COPY ${copy.relSource} -> ${copy.relTarget}`);
|
|
217
|
+
for (const skip of report.skipped) console.log(` SKIP ${skip.rel}${skip.target ? ` -> ${path.relative(report.repo, skip.target)}` : ""} (${skip.reason})`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
222
|
+
try {
|
|
223
|
+
const result = run();
|
|
224
|
+
if (parseArgs(process.argv.slice(2)).json) console.log(JSON.stringify(result, null, 2));
|
|
225
|
+
else printText(result);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error(`migrate-local-artifacts: ${error instanceof Error ? error.message : String(error)}`);
|
|
228
|
+
process.exitCode = 1;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -26,17 +26,10 @@ const path = require('path');
|
|
|
26
26
|
const crypto = require('crypto');
|
|
27
27
|
|
|
28
28
|
const gate = require(path.join(__dirname, 'hooks', 'stop-goal-fit.js'));
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const obj = {};
|
|
34
|
-
for (const k of keys) obj[k] = rec[k];
|
|
35
|
-
return JSON.stringify(obj);
|
|
36
|
-
}
|
|
37
|
-
function hashLink(prev, rec) {
|
|
38
|
-
return crypto.createHash('sha256').update(prev + canon(rec), 'utf8').digest('hex');
|
|
39
|
-
}
|
|
29
|
+
// Genesis + canonicalization/hash come from the single shared module, so a repaired
|
|
30
|
+
// chain is re-linked byte-identically to how the writer/verifier compute it.
|
|
31
|
+
const { CHAIN_GENESIS, canonicalJsonForChain, computeChainHash } = require('./lib/command-log-chain.js');
|
|
32
|
+
const GENESIS = CHAIN_GENESIS;
|
|
40
33
|
|
|
41
34
|
function main() {
|
|
42
35
|
const dir = process.argv[2];
|
|
@@ -77,8 +70,8 @@ function main() {
|
|
|
77
70
|
records.sort((a, b) => {
|
|
78
71
|
const ta = String(a.capturedAt || ''), tb = String(b.capturedAt || '');
|
|
79
72
|
if (ta !== tb) return ta < tb ? -1 : 1;
|
|
80
|
-
const ha = crypto.createHash('sha256').update(
|
|
81
|
-
const hb = crypto.createHash('sha256').update(
|
|
73
|
+
const ha = crypto.createHash('sha256').update(canonicalJsonForChain(a)).digest('hex');
|
|
74
|
+
const hb = crypto.createHash('sha256').update(canonicalJsonForChain(b)).digest('hex');
|
|
82
75
|
return ha < hb ? -1 : ha > hb ? 1 : 0;
|
|
83
76
|
});
|
|
84
77
|
|
|
@@ -87,7 +80,7 @@ function main() {
|
|
|
87
80
|
let prev = GENESIS;
|
|
88
81
|
let seq = 0;
|
|
89
82
|
for (const rec of records) {
|
|
90
|
-
const h =
|
|
83
|
+
const h = computeChainHash(prev, rec);
|
|
91
84
|
out.push(JSON.stringify({ ...rec, _chain: { seq, prevHash: prev, hash: h } }));
|
|
92
85
|
prev = h; seq += 1;
|
|
93
86
|
}
|
|
@@ -100,7 +93,7 @@ function main() {
|
|
|
100
93
|
source: 'chain-repair',
|
|
101
94
|
repair: { reason, entries: records.length, forkAt: verdict.forkAt },
|
|
102
95
|
};
|
|
103
|
-
const mh =
|
|
96
|
+
const mh = computeChainHash(prev, marker);
|
|
104
97
|
out.push(JSON.stringify({ ...marker, _chain: { seq, prevHash: prev, hash: mh } }));
|
|
105
98
|
|
|
106
99
|
fs.copyFileSync(file, file + '.prebackup-repair');
|
|
@@ -7,10 +7,9 @@ TELEMETRY_CONFIG_FILE="${TELEMETRY_CONFIG_FILE:-${TELEMETRY_DIR}/telemetry.conf}
|
|
|
7
7
|
# Defaults
|
|
8
8
|
TELEMETRY_ENABLED="${TELEMETRY_ENABLED:-true}"
|
|
9
9
|
# TELEMETRY_DIR is <workspace>/scripts/telemetry, so the workspace root is
|
|
10
|
-
# two levels up.
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
TELEMETRY_DATA_DIR="${TELEMETRY_DATA_DIR:-$(cd "${TELEMETRY_DIR}/../.." && pwd)/.telemetry}"
|
|
10
|
+
# two levels up. Local runtime telemetry defaults under .kontourai/telemetry;
|
|
11
|
+
# explicit TELEMETRY_DATA_DIR still wins.
|
|
12
|
+
TELEMETRY_DATA_DIR="${TELEMETRY_DATA_DIR:-$(cd "${TELEMETRY_DIR}/../.." && pwd)/.kontourai/telemetry}"
|
|
14
13
|
TELEMETRY_SESSION_DIR="${TELEMETRY_SESSION_DIR:-${TELEMETRY_DATA_DIR}/sessions}"
|
|
15
14
|
TELEMETRY_ENRICH_SYSTEM="${TELEMETRY_ENRICH_SYSTEM:-true}"
|
|
16
15
|
TELEMETRY_ENRICH_WORKSPACE="${TELEMETRY_ENRICH_WORKSPACE:-true}"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
defaultArtifactRootForRead,
|
|
7
|
+
flowAgentsArtifactRoot,
|
|
8
|
+
KONTOURAI_DIR,
|
|
9
|
+
legacyFlowAgentsArtifactRoot,
|
|
10
|
+
LEGACY_FLOW_AGENTS_DIR,
|
|
11
|
+
} from "../../build/src/index.js";
|
|
12
|
+
|
|
13
|
+
test("public API exports local artifact root helpers", () => {
|
|
14
|
+
const cwd = path.resolve("/tmp/flow-agents-public-api");
|
|
15
|
+
|
|
16
|
+
assert.equal(KONTOURAI_DIR, ".kontourai");
|
|
17
|
+
assert.equal(LEGACY_FLOW_AGENTS_DIR, ".flow-agents");
|
|
18
|
+
assert.equal(flowAgentsArtifactRoot(cwd), path.join(cwd, ".kontourai", "flow-agents"));
|
|
19
|
+
assert.equal(legacyFlowAgentsArtifactRoot(cwd), path.join(cwd, ".flow-agents"));
|
|
20
|
+
assert.equal(defaultArtifactRootForRead(cwd), path.join(cwd, ".kontourai", "flow-agents"));
|
|
21
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Claim explanation — a PURE projection extracted from workflow-sidecar.ts (ops#22).
|
|
2
|
+
//
|
|
3
|
+
// buildClaimExplanation is a pure function: report + bundle + id in, structured
|
|
4
|
+
// explanation out. No fs, no CLI, no .flow-agents paths, no shared module state —
|
|
5
|
+
// zero flow-agents specifics inside it, so it is unit-testable in isolation and can
|
|
6
|
+
// be lifted to Surface unchanged (issue #171). The IO command handler `claimLookup`
|
|
7
|
+
// that consumes it stays in workflow-sidecar.ts.
|
|
8
|
+
|
|
9
|
+
type AnyObj = Record<string, any>;
|
|
10
|
+
|
|
11
|
+
export interface ClaimEvidenceItem {
|
|
12
|
+
evidenceType: string;
|
|
13
|
+
label: string;
|
|
14
|
+
execution: { runner: string; label: string; isError: boolean; exitCode: number | null } | null;
|
|
15
|
+
passing: boolean;
|
|
16
|
+
summary: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ClaimExplanation {
|
|
20
|
+
found: boolean;
|
|
21
|
+
status: string;
|
|
22
|
+
value: string;
|
|
23
|
+
claimType: string;
|
|
24
|
+
evidence: ClaimEvidenceItem[];
|
|
25
|
+
policy: {
|
|
26
|
+
id: string;
|
|
27
|
+
requiredEvidence: string[];
|
|
28
|
+
requiredMethods?: string[];
|
|
29
|
+
acceptanceCriteria: string[];
|
|
30
|
+
reviewAuthority: string;
|
|
31
|
+
} | null;
|
|
32
|
+
why: {
|
|
33
|
+
directInputs: AnyObj[];
|
|
34
|
+
leafClaims: AnyObj[];
|
|
35
|
+
diagnostics: AnyObj[];
|
|
36
|
+
transparencyGaps: AnyObj[];
|
|
37
|
+
changeRecords: AnyObj[];
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a structured explanation for a specific claim.
|
|
43
|
+
* PURE: report + bundle + id in, structured explanation out.
|
|
44
|
+
* No fs, no CLI, no .flow-agents paths. Promotable to Surface #171.
|
|
45
|
+
*
|
|
46
|
+
* @param report TrustReport from buildTrustReport(bundle) — required for derived status
|
|
47
|
+
* @param bundle Raw parsed trust.bundle (BundleFile shape)
|
|
48
|
+
* @param claimId The claim id to explain
|
|
49
|
+
*/
|
|
50
|
+
export function buildClaimExplanation(
|
|
51
|
+
report: Record<string, unknown>,
|
|
52
|
+
bundle: Record<string, unknown>,
|
|
53
|
+
claimId: string,
|
|
54
|
+
): ClaimExplanation {
|
|
55
|
+
const reportClaims = Array.isArray(report.claims) ? (report.claims as AnyObj[]) : [];
|
|
56
|
+
const reportClaim = reportClaims.find((c: AnyObj) => c.id === claimId);
|
|
57
|
+
|
|
58
|
+
if (!reportClaim) {
|
|
59
|
+
return {
|
|
60
|
+
found: false,
|
|
61
|
+
status: "unknown",
|
|
62
|
+
value: "",
|
|
63
|
+
claimType: "",
|
|
64
|
+
evidence: [],
|
|
65
|
+
policy: null,
|
|
66
|
+
why: { directInputs: [], leafClaims: [], diagnostics: [], transparencyGaps: [], changeRecords: [] },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const bundleClaims = Array.isArray(bundle.claims) ? (bundle.claims as AnyObj[]) : [];
|
|
71
|
+
const bundleClaim = bundleClaims.find((c: AnyObj) => c.id === claimId) ?? reportClaim;
|
|
72
|
+
const bundlePolicies = Array.isArray(bundle.policies) ? (bundle.policies as AnyObj[]) : [];
|
|
73
|
+
const bundleEvidence = Array.isArray(bundle.evidence) ? (bundle.evidence as AnyObj[]) : [];
|
|
74
|
+
|
|
75
|
+
// Governing policy — follow verificationPolicyId into bundle.policies[]
|
|
76
|
+
const verificationPolicyId = typeof bundleClaim.verificationPolicyId === "string" ? bundleClaim.verificationPolicyId : undefined;
|
|
77
|
+
const rawPolicy = verificationPolicyId ? bundlePolicies.find((p: AnyObj) => p.id === verificationPolicyId) : undefined;
|
|
78
|
+
const policy = rawPolicy
|
|
79
|
+
? {
|
|
80
|
+
id: String(rawPolicy.id ?? ""),
|
|
81
|
+
requiredEvidence: Array.isArray(rawPolicy.requiredEvidence) ? (rawPolicy.requiredEvidence as string[]) : [],
|
|
82
|
+
requiredMethods: Array.isArray(rawPolicy.requiredMethods) ? (rawPolicy.requiredMethods as string[]) : undefined,
|
|
83
|
+
acceptanceCriteria: Array.isArray(rawPolicy.acceptanceCriteria) ? (rawPolicy.acceptanceCriteria as string[]) : [],
|
|
84
|
+
reviewAuthority: String(rawPolicy.reviewAuthority ?? ""),
|
|
85
|
+
}
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
// Evidence enhancement: pull evidence items for this claim, surface the execution block
|
|
89
|
+
const claimEvidenceItems = bundleEvidence.filter((ev: AnyObj) => ev && ev.claimId === claimId);
|
|
90
|
+
const evidence: ClaimEvidenceItem[] = claimEvidenceItems.map((ev: AnyObj) => {
|
|
91
|
+
const exec = ev.execution && typeof ev.execution === "object" ? (ev.execution as AnyObj) : null;
|
|
92
|
+
const execution = exec
|
|
93
|
+
? {
|
|
94
|
+
runner: String(exec.runner ?? exec.label ?? ""),
|
|
95
|
+
label: String(exec.label ?? exec.runner ?? ""),
|
|
96
|
+
isError: Boolean(exec.isError ?? (typeof exec.exitCode === "number" && exec.exitCode !== 0)),
|
|
97
|
+
exitCode: typeof exec.exitCode === "number" ? exec.exitCode : null,
|
|
98
|
+
}
|
|
99
|
+
: null;
|
|
100
|
+
return {
|
|
101
|
+
evidenceType: String(ev.evidenceType ?? ev.type ?? "unknown"),
|
|
102
|
+
label: String(ev.label ?? ev.excerptOrSummary ?? ev.sourceRef ?? ev.id ?? ""),
|
|
103
|
+
execution,
|
|
104
|
+
passing: execution ? !execution.isError : String(ev.status ?? "") !== "disputed",
|
|
105
|
+
summary: String(ev.excerptOrSummary ?? ev.summary ?? ev.label ?? ""),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Drilldown: extract from report structure (report.transparencyGaps, report.changeRecords)
|
|
110
|
+
const allGaps = Array.isArray(report.transparencyGaps) ? (report.transparencyGaps as AnyObj[]) : [];
|
|
111
|
+
const allChanges = Array.isArray(report.changeRecords) ? (report.changeRecords as AnyObj[]) : [];
|
|
112
|
+
const transparencyGaps = allGaps.filter((g: AnyObj) => g && g.claimId === claimId);
|
|
113
|
+
const changeRecords = allChanges.filter((c: AnyObj) => c && c.claimId === claimId);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
found: true,
|
|
117
|
+
status: String(reportClaim.status ?? "unknown"),
|
|
118
|
+
value: String(bundleClaim.value ?? reportClaim.value ?? ""),
|
|
119
|
+
claimType: String(bundleClaim.claimType ?? reportClaim.claimType ?? ""),
|
|
120
|
+
evidence,
|
|
121
|
+
policy,
|
|
122
|
+
why: {
|
|
123
|
+
directInputs: [], // populated by buildDerivationDrilldown if non-leaf
|
|
124
|
+
leafClaims: [],
|
|
125
|
+
diagnostics: [],
|
|
126
|
+
transparencyGaps,
|
|
127
|
+
changeRecords,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Thin TypeScript-layer unit tests for the PURE helpers in workflow-sidecar (ops#22).
|
|
2
|
+
//
|
|
3
|
+
// Until now the ~1,924 assertions covering this module were all black-box bash that
|
|
4
|
+
// drove the CLI. These tests exercise the pure, side-effect-free helpers directly
|
|
5
|
+
// against the built JS — fast, deterministic, and isolating logic from fs/CLI. They
|
|
6
|
+
// complement (do not replace) the bash evals.
|
|
7
|
+
//
|
|
8
|
+
// Run: `npm run test:unit` (builds first). Requires `npm run build` output under build/.
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
buildClaimExplanation as buildClaimExplanationReexport,
|
|
14
|
+
deriveGateCalibration,
|
|
15
|
+
gateAdvisoryFix,
|
|
16
|
+
buildGateInquiryRecords,
|
|
17
|
+
validateEvidenceRef,
|
|
18
|
+
normalizeEvidenceRefs,
|
|
19
|
+
normalizeCheck,
|
|
20
|
+
} from "../../build/src/cli/workflow-sidecar.js";
|
|
21
|
+
import { buildClaimExplanation } from "../../build/src/cli/sidecar-claim-explain.js";
|
|
22
|
+
|
|
23
|
+
// ── buildClaimExplanation (extracted pure projection) ────────────────────────
|
|
24
|
+
|
|
25
|
+
test("buildClaimExplanation: re-export from workflow-sidecar === the extracted module", () => {
|
|
26
|
+
assert.equal(buildClaimExplanationReexport, buildClaimExplanation);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("buildClaimExplanation: unknown claim id returns found:false sentinel", () => {
|
|
30
|
+
const out = buildClaimExplanation({ claims: [] }, { claims: [] }, "missing");
|
|
31
|
+
assert.equal(out.found, false);
|
|
32
|
+
assert.equal(out.status, "unknown");
|
|
33
|
+
assert.deepEqual(out.evidence, []);
|
|
34
|
+
assert.equal(out.policy, null);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("buildClaimExplanation: projects status, policy, and evidence for a found claim", () => {
|
|
38
|
+
const report = {
|
|
39
|
+
claims: [{ id: "c1", status: "verified", value: "pass", claimType: "workflow.check" }],
|
|
40
|
+
transparencyGaps: [{ claimId: "c1", reason: "x" }, { claimId: "other" }],
|
|
41
|
+
changeRecords: [],
|
|
42
|
+
};
|
|
43
|
+
const bundle = {
|
|
44
|
+
claims: [{ id: "c1", verificationPolicyId: "p1", value: "pass", claimType: "workflow.check" }],
|
|
45
|
+
policies: [{ id: "p1", requiredEvidence: ["test"], acceptanceCriteria: ["AC1"], reviewAuthority: "owner" }],
|
|
46
|
+
evidence: [{ claimId: "c1", evidenceType: "command", execution: { runner: "npm test", label: "npm test", exitCode: 0 } }],
|
|
47
|
+
};
|
|
48
|
+
const out = buildClaimExplanation(report, bundle, "c1");
|
|
49
|
+
assert.equal(out.found, true);
|
|
50
|
+
assert.equal(out.status, "verified");
|
|
51
|
+
assert.equal(out.policy.id, "p1");
|
|
52
|
+
assert.deepEqual(out.policy.requiredEvidence, ["test"]);
|
|
53
|
+
assert.equal(out.evidence.length, 1);
|
|
54
|
+
assert.equal(out.evidence[0].passing, true); // exitCode 0 → not error → passing
|
|
55
|
+
assert.equal(out.evidence[0].execution.exitCode, 0);
|
|
56
|
+
assert.equal(out.why.transparencyGaps.length, 1); // only the c1 gap, not "other"
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── deriveGateCalibration (pure mapping) ─────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
test("deriveGateCalibration: maps outcome/status/blocked to a calibration verdict", () => {
|
|
62
|
+
assert.equal(deriveGateCalibration("unsupported", undefined, true), "missed_block");
|
|
63
|
+
assert.equal(deriveGateCalibration("matched", "disputed", true), "correct");
|
|
64
|
+
assert.equal(deriveGateCalibration("matched", "rejected", true), "correct");
|
|
65
|
+
assert.equal(deriveGateCalibration("matched", "verified", true), "false_block");
|
|
66
|
+
assert.equal(deriveGateCalibration("matched", "stale", true), "false_block"); // blocked w/o solid evidence
|
|
67
|
+
assert.equal(deriveGateCalibration("matched", "stale", false), "missed_block");
|
|
68
|
+
assert.equal(deriveGateCalibration("matched", "verified", false), "correct");
|
|
69
|
+
assert.equal(deriveGateCalibration("derived", "proposed", false), "missed_block");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── gateAdvisoryFix (pure string composition) ────────────────────────────────
|
|
73
|
+
|
|
74
|
+
test("gateAdvisoryFix: composes calibration-specific guidance naming the claim", () => {
|
|
75
|
+
assert.match(gateAdvisoryFix("correct", "c1", "disputed"), /No gate change needed/);
|
|
76
|
+
assert.match(gateAdvisoryFix("correct", "c1", "disputed"), /`c1`/);
|
|
77
|
+
assert.match(gateAdvisoryFix("false_block", "c1", "verified"), /Investigate why the gate blocked/);
|
|
78
|
+
assert.match(gateAdvisoryFix("missed_block", "c1", "stale"), /Refresh the stale claim/);
|
|
79
|
+
assert.match(gateAdvisoryFix("missed_block", "c1", "absent"), /writes a bundle claim/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── validateEvidenceRef / normalizeEvidenceRefs (pure validators) ────────────
|
|
83
|
+
|
|
84
|
+
test("validateEvidenceRef: accepts a well-formed source ref", () => {
|
|
85
|
+
const ref = { kind: "source", file: "a.ts", line_start: 1, line_end: 2, excerpt: "x" };
|
|
86
|
+
assert.deepEqual(validateEvidenceRef({ ...ref }, "refs"), ref);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("validateEvidenceRef: rejects bad kind, unsupported field, and incomplete source ref", () => {
|
|
90
|
+
assert.throws(() => validateEvidenceRef({ kind: "nope" }, "refs"), /kind must be one of/);
|
|
91
|
+
assert.throws(() => validateEvidenceRef({ kind: "command", bogus: 1, summary: "s" }, "refs"), /unsupported field/);
|
|
92
|
+
assert.throws(() => validateEvidenceRef({ kind: "source", file: "a.ts" }, "refs"), /source refs require/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("normalizeEvidenceRefs: rejects non-arrays and legacy string refs; passes valid arrays", () => {
|
|
96
|
+
assert.throws(() => normalizeEvidenceRefs("nope", "refs"), /must be an array/);
|
|
97
|
+
assert.throws(() => normalizeEvidenceRefs(["legacy"], "refs"), /legacy string refs are not supported/);
|
|
98
|
+
const ok = normalizeEvidenceRefs([{ kind: "command", summary: "ran tests" }], "refs");
|
|
99
|
+
assert.equal(ok.length, 1);
|
|
100
|
+
assert.equal(ok[0].kind, "command");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── normalizeCheck (pure path: no surface_trust_refs) ────────────────────────
|
|
104
|
+
|
|
105
|
+
test("normalizeCheck: validates required fields, kind, and status", () => {
|
|
106
|
+
assert.throws(() => normalizeCheck({ id: "x" }), /requires id, kind, status/);
|
|
107
|
+
assert.throws(() => normalizeCheck({ id: "x", kind: "bogus", status: "pass", summary: "s" }), /kind must be one of/);
|
|
108
|
+
assert.throws(() => normalizeCheck({ id: "x", kind: "test", status: "bogus", summary: "s" }), /status must be one of/);
|
|
109
|
+
const ok = normalizeCheck({ id: "x", kind: "test", status: "pass", summary: "ran" });
|
|
110
|
+
assert.equal(ok.kind, "test");
|
|
111
|
+
assert.equal(ok.status, "pass");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── buildGateInquiryRecords (pure orchestration, fake Surface injected) ───────
|
|
115
|
+
|
|
116
|
+
test("buildGateInquiryRecords: resolves each claim through the injected Surface and tags calibration", () => {
|
|
117
|
+
const fakeSurface = {
|
|
118
|
+
resolveInquiry: (_bundle, inquiry, _opts) => ({
|
|
119
|
+
id: inquiry.id,
|
|
120
|
+
inquiry,
|
|
121
|
+
outcome: "matched",
|
|
122
|
+
resolutionPath: { claimIds: [inquiry.metadata.claimId] },
|
|
123
|
+
inputSnapshot: {},
|
|
124
|
+
statusFunctionVersion: "test",
|
|
125
|
+
resolvedAt: "2026-01-01T00:00:00Z",
|
|
126
|
+
answer: { status: "disputed" },
|
|
127
|
+
}),
|
|
128
|
+
};
|
|
129
|
+
const bundle = {
|
|
130
|
+
schemaVersion: 2,
|
|
131
|
+
source: "s",
|
|
132
|
+
claims: [{ id: "c1", subjectType: "workflow-check", subjectId: "slug/AC1", fieldOrBehavior: "AC1", status: "disputed" }],
|
|
133
|
+
evidence: [], events: [], policies: [],
|
|
134
|
+
};
|
|
135
|
+
const records = buildGateInquiryRecords(
|
|
136
|
+
bundle,
|
|
137
|
+
{ blocked: true, hash: "h", count: 1 },
|
|
138
|
+
"slug",
|
|
139
|
+
[],
|
|
140
|
+
fakeSurface,
|
|
141
|
+
new Date("2026-01-01T00:00:00Z"),
|
|
142
|
+
);
|
|
143
|
+
assert.equal(records.length, 1);
|
|
144
|
+
assert.equal(records[0].outcome, "matched");
|
|
145
|
+
// blocked + disputed → "correct" calibration in answer.value
|
|
146
|
+
assert.equal(records[0].answer.value.calibration, "correct");
|
|
147
|
+
assert.equal(records[0].answer.value.gateFired, true);
|
|
148
|
+
assert.equal(records[0].answer.value.sessionSlug, "slug");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("buildGateInquiryRecords: emits a single missed_block record for an empty bundle", () => {
|
|
152
|
+
const fakeSurface = {
|
|
153
|
+
resolveInquiry: (_bundle, inquiry, _opts) => ({
|
|
154
|
+
id: inquiry.id,
|
|
155
|
+
inquiry,
|
|
156
|
+
outcome: "unsupported",
|
|
157
|
+
resolutionPath: { claimIds: [] },
|
|
158
|
+
inputSnapshot: {},
|
|
159
|
+
statusFunctionVersion: "test",
|
|
160
|
+
resolvedAt: "2026-01-01T00:00:00Z",
|
|
161
|
+
answer: { status: "unknown" },
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
164
|
+
const bundle = { schemaVersion: 2, source: "s", claims: [], evidence: [], events: [], policies: [] };
|
|
165
|
+
const records = buildGateInquiryRecords(bundle, { blocked: false, hash: null, count: 0 }, "slug", [], fakeSurface, new Date("2026-01-01T00:00:00Z"));
|
|
166
|
+
assert.equal(records.length, 1);
|
|
167
|
+
assert.equal(records[0].answer.value.calibration, "missed_block");
|
|
168
|
+
});
|
|
@@ -3,6 +3,7 @@ import * as http from "node:http";
|
|
|
3
3
|
import * as https from "node:https";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { parseArgs, flagBool, flagString } from "../lib/args.js";
|
|
6
|
+
import { telemetryDataDir as defaultTelemetryDataDir } from "../lib/local-artifact-root.js";
|
|
6
7
|
|
|
7
8
|
type Config = Record<string, string>;
|
|
8
9
|
|
|
@@ -82,10 +83,7 @@ function channelConfigValue(config: Config, channel: string, key: string, fallba
|
|
|
82
83
|
|
|
83
84
|
function telemetryDataDir(dest: string): string {
|
|
84
85
|
const configured = process.env.TELEMETRY_DATA_DIR;
|
|
85
|
-
|
|
86
|
-
// workspace at <dest>/.telemetry. The previous "../.telemetry" duplicated
|
|
87
|
-
// the parent-escape bug fixed in config.sh on 2026-06-11.
|
|
88
|
-
return configured ? path.resolve(dest, configured) : path.resolve(dest, ".telemetry");
|
|
86
|
+
return configured ? path.resolve(dest, configured) : defaultTelemetryDataDir(dest);
|
|
89
87
|
}
|
|
90
88
|
|
|
91
89
|
function deriveConsoleEndpoint(consoleUrl: string, explicitEndpoint: string): string {
|