@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.
Files changed (55) hide show
  1. package/.github/CODEOWNERS +2 -0
  2. package/.github/workflows/ci.yml +4 -4
  3. package/.github/workflows/docs-pages.yml +1 -1
  4. package/.github/workflows/kit-gates-demo.yml +2 -2
  5. package/.github/workflows/publish-npm.yml +2 -2
  6. package/.github/workflows/runtime-compat.yml +2 -2
  7. package/.github/workflows/trust-reconcile.yml +1 -1
  8. package/CHANGELOG.md +29 -0
  9. package/build/src/cli/sidecar-claim-explain.d.ts +45 -0
  10. package/build/src/cli/sidecar-claim-explain.js +87 -0
  11. package/build/src/cli/telemetry-doctor.js +2 -4
  12. package/build/src/cli/usage-feedback.js +16 -8
  13. package/build/src/cli/workflow-artifact-cleanup-audit.js +4 -2
  14. package/build/src/cli/workflow-sidecar.d.ts +2 -44
  15. package/build/src/cli/workflow-sidecar.js +18 -87
  16. package/build/src/index.d.ts +1 -0
  17. package/build/src/index.js +1 -0
  18. package/build/src/lib/local-artifact-root.d.ts +11 -0
  19. package/build/src/lib/local-artifact-root.js +30 -0
  20. package/build/src/tools/validate-source-tree.js +1 -0
  21. package/context/scripts/telemetry/lib/config.sh +3 -4
  22. package/docs/adr/0018-freeze-local-shell-heuristics.md +95 -0
  23. package/docs/repository-structure.md +7 -3
  24. package/evals/integration/test_bundle_lifecycle.sh +9 -9
  25. package/evals/integration/test_gate_lockdown.sh +25 -6
  26. package/evals/integration/test_migrate_local_artifacts.sh +102 -0
  27. package/evals/integration/test_workflow_sidecar_writer.sh +34 -0
  28. package/evals/run.sh +2 -0
  29. package/evals/static/test_unit_helpers.sh +26 -0
  30. package/integrations/strands-ts/package.json +3 -0
  31. package/integrations/strands-ts/src/telemetry.ts +31 -50
  32. package/kits/knowledge/adapters/flow-runner/index.js +1 -1
  33. package/kits/knowledge/adapters/flow-runner/telemetry.js +2 -2
  34. package/package.json +2 -1
  35. package/scripts/README.md +1 -0
  36. package/scripts/ci/trust-reconcile.js +7 -23
  37. package/scripts/hooks/config-protection.js +6 -0
  38. package/scripts/hooks/evidence-capture.js +26 -47
  39. package/scripts/hooks/lib/local-artifact-paths.js +32 -0
  40. package/scripts/hooks/stop-goal-fit.js +37 -58
  41. package/scripts/hooks/workflow-steering.js +5 -3
  42. package/scripts/lib/command-log-chain.js +78 -0
  43. package/scripts/migrate-local-artifacts.mjs +230 -0
  44. package/scripts/repair-command-log.js +8 -15
  45. package/scripts/telemetry/lib/config.sh +3 -4
  46. package/src/cli/public-api.test.mjs +21 -0
  47. package/src/cli/sidecar-claim-explain.ts +130 -0
  48. package/src/cli/sidecar-pure-helpers.test.mjs +168 -0
  49. package/src/cli/telemetry-doctor.ts +2 -4
  50. package/src/cli/usage-feedback.ts +15 -8
  51. package/src/cli/workflow-artifact-cleanup-audit.ts +4 -2
  52. package/src/cli/workflow-sidecar.ts +17 -131
  53. package/src/index.ts +14 -0
  54. package/src/lib/local-artifact-root.ts +39 -0
  55. 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
- const GENESIS = gate.CHAIN_GENESIS_VERIFY;
30
-
31
- function canon(rec) {
32
- const keys = Object.keys(rec).filter((k) => k !== '_chain').sort();
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(canon(a)).digest('hex');
81
- const hb = crypto.createHash('sha256').update(canon(b)).digest('hex');
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 = hashLink(prev, rec);
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 = hashLink(prev, marker);
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. Three levels escaped into the workspace's PARENT directory
11
- # (caught by live acceptance smoke 2026-06-11: events landed in /tmp/.telemetry
12
- # instead of the installed workspace).
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
- // Must mirror scripts/telemetry/lib/config.sh: the sink lives INSIDE the
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 {