@kontourai/flow-agents 2.1.1 → 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 (47) hide show
  1. package/.github/CODEOWNERS +2 -0
  2. package/CHANGELOG.md +22 -0
  3. package/build/src/cli/sidecar-claim-explain.d.ts +45 -0
  4. package/build/src/cli/sidecar-claim-explain.js +87 -0
  5. package/build/src/cli/telemetry-doctor.js +2 -4
  6. package/build/src/cli/usage-feedback.js +16 -8
  7. package/build/src/cli/workflow-artifact-cleanup-audit.js +4 -2
  8. package/build/src/cli/workflow-sidecar.d.ts +2 -44
  9. package/build/src/cli/workflow-sidecar.js +18 -87
  10. package/build/src/index.d.ts +1 -0
  11. package/build/src/index.js +1 -0
  12. package/build/src/lib/local-artifact-root.d.ts +11 -0
  13. package/build/src/lib/local-artifact-root.js +30 -0
  14. package/build/src/tools/validate-source-tree.js +1 -0
  15. package/context/scripts/telemetry/lib/config.sh +3 -4
  16. package/docs/adr/0018-freeze-local-shell-heuristics.md +95 -0
  17. package/docs/repository-structure.md +7 -3
  18. package/evals/integration/test_bundle_lifecycle.sh +9 -9
  19. package/evals/integration/test_gate_lockdown.sh +4 -0
  20. package/evals/integration/test_migrate_local_artifacts.sh +102 -0
  21. package/evals/integration/test_workflow_sidecar_writer.sh +34 -0
  22. package/evals/run.sh +2 -0
  23. package/evals/static/test_unit_helpers.sh +26 -0
  24. package/integrations/strands-ts/package.json +3 -0
  25. package/integrations/strands-ts/src/telemetry.ts +31 -50
  26. package/kits/knowledge/adapters/flow-runner/index.js +1 -1
  27. package/kits/knowledge/adapters/flow-runner/telemetry.js +2 -2
  28. package/package.json +2 -1
  29. package/scripts/README.md +1 -0
  30. package/scripts/hooks/config-protection.js +6 -0
  31. package/scripts/hooks/evidence-capture.js +16 -10
  32. package/scripts/hooks/lib/local-artifact-paths.js +32 -0
  33. package/scripts/hooks/stop-goal-fit.js +19 -13
  34. package/scripts/hooks/workflow-steering.js +5 -3
  35. package/scripts/lib/command-log-chain.js +5 -0
  36. package/scripts/migrate-local-artifacts.mjs +230 -0
  37. package/scripts/telemetry/lib/config.sh +3 -4
  38. package/src/cli/public-api.test.mjs +21 -0
  39. package/src/cli/sidecar-claim-explain.ts +130 -0
  40. package/src/cli/sidecar-pure-helpers.test.mjs +168 -0
  41. package/src/cli/telemetry-doctor.ts +2 -4
  42. package/src/cli/usage-feedback.ts +15 -8
  43. package/src/cli/workflow-artifact-cleanup-audit.ts +4 -2
  44. package/src/cli/workflow-sidecar.ts +17 -131
  45. package/src/index.ts +14 -0
  46. package/src/lib/local-artifact-root.ts +39 -0
  47. package/src/tools/validate-source-tree.ts +1 -0
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const KONTOURAI_DIR = '.kontourai';
7
+ const LEGACY_FLOW_AGENTS_DIR = '.flow-agents';
8
+
9
+ function flowAgentsArtifactRoot(cwd = process.cwd()) {
10
+ return path.resolve(cwd, KONTOURAI_DIR, 'flow-agents');
11
+ }
12
+
13
+ function legacyFlowAgentsArtifactRoot(cwd = process.cwd()) {
14
+ return path.resolve(cwd, LEGACY_FLOW_AGENTS_DIR);
15
+ }
16
+
17
+ function flowAgentsArtifactRootsForRead(cwd = process.cwd()) {
18
+ const roots = [flowAgentsArtifactRoot(cwd), legacyFlowAgentsArtifactRoot(cwd)];
19
+ return roots.filter((root, index) => roots.indexOf(root) === index && fs.existsSync(root));
20
+ }
21
+
22
+ function defaultArtifactRootForRead(cwd = process.cwd()) {
23
+ const roots = flowAgentsArtifactRootsForRead(cwd);
24
+ return roots[0] || flowAgentsArtifactRoot(cwd);
25
+ }
26
+
27
+ module.exports = {
28
+ flowAgentsArtifactRoot,
29
+ legacyFlowAgentsArtifactRoot,
30
+ flowAgentsArtifactRootsForRead,
31
+ defaultArtifactRootForRead,
32
+ };
@@ -38,6 +38,10 @@ const {
38
38
  canonicalJsonForChain,
39
39
  hasLaunderingOperator,
40
40
  } = require('../lib/command-log-chain.js');
41
+ const {
42
+ flowAgentsArtifactRoot,
43
+ flowAgentsArtifactRootsForRead,
44
+ } = require('./lib/local-artifact-paths');
41
45
 
42
46
  const MAX_STDIN = 1024 * 1024;
43
47
  const ACTIVE_STATUSES = new Set([
@@ -1575,11 +1579,11 @@ const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence
1575
1579
  const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —|command-log integrity check FAILED|gate misconfiguration:|surface unavailable —|expected capture log is missing|exit-code-laundered/;
1576
1580
 
1577
1581
  async function analyze(root, now = Date.now()) {
1578
- const flowAgentsDir = path.join(root, '.flow-agents');
1582
+ const flowAgentsDirs = flowAgentsArtifactRootsForRead(root);
1579
1583
  // Scope to the session's current task when current.json names one, so an
1580
1584
  // unrelated active workflow elsewhere in the repo does not gate this stop.
1581
- const scoped = preferredArtifactDir(flowAgentsDir);
1582
- const searchDirs = scoped ? [scoped] : [flowAgentsDir];
1585
+ const scoped = flowAgentsDirs.map(preferredArtifactDir).find(Boolean);
1586
+ const searchDirs = scoped ? [scoped] : flowAgentsDirs;
1583
1587
  const artifacts = searchDirs
1584
1588
  .flatMap(dir => walkMarkdown(dir))
1585
1589
  .map(readArtifact)
@@ -1589,6 +1593,7 @@ async function analyze(root, now = Date.now()) {
1589
1593
  if (artifacts.length === 0) return { warnings: [], blocking: false };
1590
1594
 
1591
1595
  const latest = artifacts[0];
1596
+ const latestArtifactDir = path.dirname(latest.file);
1592
1597
  const warnings = [];
1593
1598
  const relPath = relative(root, latest.file);
1594
1599
  const status = latest.status || 'unknown';
@@ -1604,11 +1609,11 @@ async function analyze(root, now = Date.now()) {
1604
1609
 
1605
1610
  // ADR 0016 P-c: load the active FlowDefinition gate (fail-open: null when absent).
1606
1611
  // Null → existing workflow.* fallback path unchanged. Non-null → expects[]-driven claim selection.
1607
- const activeFlowStep = loadActiveFlowStep(flowAgentsDir);
1612
+ const activeFlowStep = loadActiveFlowStep(path.dirname(latestArtifactDir));
1608
1613
 
1609
- warnings.push(...sidecarValidation(root, path.dirname(latest.file)));
1610
- warnings.push(...sidecarGuidance(root, path.dirname(latest.file), activeFlowStep));
1611
- const captureWarnings = captureCrossReference(root, path.dirname(latest.file), activeFlowStep);
1614
+ warnings.push(...sidecarValidation(root, latestArtifactDir));
1615
+ warnings.push(...sidecarGuidance(root, latestArtifactDir, activeFlowStep));
1616
+ const captureWarnings = captureCrossReference(root, latestArtifactDir, activeFlowStep);
1612
1617
  warnings.push(...captureWarnings);
1613
1618
  // Dedup: bundleEnforcement and captureCrossReference can both fire "caught false-completion"
1614
1619
  // for the same disputed claim. Suppress the bundleEnforcement warning ONLY when
@@ -1622,7 +1627,7 @@ async function analyze(root, now = Date.now()) {
1622
1627
  const m = /evidence check ([^\s:]+):/.exec(w);
1623
1628
  if (m) captureHardBlockIds.add(m[1]);
1624
1629
  }
1625
- const bundleWarnings = (await bundleEnforcement(path.dirname(latest.file), activeFlowStep)).filter(w => {
1630
+ const bundleWarnings = (await bundleEnforcement(latestArtifactDir, activeFlowStep)).filter(w => {
1626
1631
  if (!captureHardBlockIds.size) return true;
1627
1632
  // bundleEnforcement warns: "trust.bundle claim disputed: <subjectId> ..."
1628
1633
  const m = /trust\.bundle claim (?:disputed|tampered): ([^\s(]+)/.exec(w);
@@ -1634,21 +1639,21 @@ async function analyze(root, now = Date.now()) {
1634
1639
  return !captureHardBlockIds.has(checkId);
1635
1640
  });
1636
1641
  warnings.push(...bundleWarnings);
1637
- warnings.push(...missingBundleOrStateSignal(path.dirname(latest.file), activeFlowStep));
1642
+ warnings.push(...missingBundleOrStateSignal(latestArtifactDir, activeFlowStep));
1638
1643
 
1639
1644
  // A pre-execution task (not started) OR a terminal task (which is itself a
1640
1645
  // completion *claim*) must not block on mere incompleteness — but a FALSE claim
1641
1646
  // (capture/evidence contradiction) still blocks at any phase. This is the whole
1642
1647
  // point of the capture cross-reference: catch a task that falsely claims done.
1643
- const gateState = readJsonFile(path.join(path.dirname(latest.file), 'state.json'));
1648
+ const gateState = readJsonFile(path.join(latestArtifactDir, 'state.json'));
1644
1649
  const taskStatus = gateState ? normalizedStatus(gateState.status) : normalizedStatus(status);
1645
- const preExecution = isPreExecution(path.dirname(latest.file), status);
1650
+ const preExecution = isPreExecution(latestArtifactDir, status);
1646
1651
  const terminal = TERMINAL_STATUSES.has(taskStatus);
1647
1652
 
1648
1653
  // Namespace-agnostic captured-FAIL reconciliation (AC1 — closes the allowlist bypass).
1649
1654
  // Fix A: status-independent — runs on EVERY stop. A claim contradicting the capture
1650
1655
  // is a false-completion whether or not the agent says the task is 'done'.
1651
- warnings.push(...capturedFailReconciliation(root, path.dirname(latest.file), taskStatus));
1656
+ warnings.push(...capturedFailReconciliation(root, latestArtifactDir, taskStatus));
1652
1657
 
1653
1658
  // Use module-scope HARD_BLOCK / FULL_BLOCK (defined above analyze()).
1654
1659
  // pre-execution/terminal tasks: only HARD_BLOCK signals cause a block.
@@ -1686,7 +1691,8 @@ function resolveMaxBlocks() {
1686
1691
  }
1687
1692
 
1688
1693
  function blockStreakFile(root) {
1689
- return path.join(root, '.flow-agents', '.goal-fit-block-streak.json');
1694
+ const artifactRoot = flowAgentsArtifactRoot(root);
1695
+ return path.join(artifactRoot, '.goal-fit-block-streak.json');
1690
1696
  }
1691
1697
 
1692
1698
  function reasonsHash(warnings) {
@@ -17,6 +17,7 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
20
+ const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
20
21
 
21
22
  const STEERING = {
22
23
  'tool-planner': [
@@ -101,7 +102,8 @@ function readJson(file) {
101
102
  }
102
103
 
103
104
  function latestWorkflowState(root) {
104
- const states = walkStateFiles(path.join(root, '.flow-agents'))
105
+ const states = flowAgentsArtifactRootsForRead(root)
106
+ .flatMap(artifactRoot => walkStateFiles(artifactRoot))
105
107
  .map(file => {
106
108
  let stat;
107
109
  try { stat = fs.statSync(file); } catch { return null; }
@@ -295,8 +297,8 @@ function resumeSteering(root, current) {
295
297
 
296
298
  // Liveness advisory
297
299
  try {
298
- const livenessFile = path.join(root, '.flow-agents', 'liveness', 'events.jsonl');
299
- const events = readLivenessEvents(livenessFile);
300
+ const events = flowAgentsArtifactRootsForRead(root)
301
+ .flatMap(artifactRoot => readLivenessEvents(path.join(artifactRoot, 'liveness', 'events.jsonl')));
300
302
  if (events.length > 0) {
301
303
  const selfActor = (process.env.FLOW_AGENTS_ACTOR || '').trim() || 'local';
302
304
  const holders = freshHolders(events, slug, selfActor, Date.now());
@@ -51,6 +51,11 @@ function computeChainHash(prevHash, record) {
51
51
  * - ANY `||` (e.g. `npm test || exit 0`, `|| echo ok`, `|| /bin/true`)
52
52
  * - `| true` (pipe into true — the pipeline absorbs the exit code)
53
53
  * - trailing `; true` / `; :` / `; exit 0` / `; /bin/true` (and `\n` variants)
54
+ *
55
+ * FROZEN bar-raiser (ADR 0018). Do NOT add new evasion-pattern rules here; route new
56
+ * laundering shapes to the external CI anchor (trust-reconcile + the anti-gaming suite).
57
+ * Accepted limitation: the blanket `||` rule over-blocks legitimate control-flow `||`
58
+ * (e.g. `test -d node_modules || npm ci`) — it fails toward blocking by design.
54
59
  */
55
60
  function hasLaunderingOperator(cmd) {
56
61
  // ANY || in a claimed verification command is an exit-code mask.
@@ -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
+ }
@@ -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
+ }