@kontourai/flow-agents 2.1.1 → 2.3.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 +29 -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,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 {
@@ -3,11 +3,13 @@ import { fileURLToPath } from "node:url";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { parseArgs, flagBool, flagList, flagString } from "../lib/args.js";
6
+ import { defaultArtifactRootForRead, defaultTelemetryDirForRead, defaultTelemetryDirsForRead, telemetryDataDir, flowAgentsArtifactRoot, legacyFlowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
6
7
 
7
8
  const VALID_RESULTS = new Set(["success", "partial", "failure", "not_verified"]);
8
9
 
9
10
  function telemetryDir(flags: Record<string, string | boolean | string[]>): string {
10
- return path.resolve(flagString(flags, "telemetry-dir", process.env.TELEMETRY_DATA_DIR ?? ".telemetry") ?? ".telemetry");
11
+ const explicit = flagString(flags, "telemetry-dir") ?? process.env.TELEMETRY_DATA_DIR;
12
+ return explicit ? path.resolve(explicit) : telemetryDataDir();
11
13
  }
12
14
 
13
15
  function ensureSafeDir(dir: string): void {
@@ -110,7 +112,8 @@ function normalize(input: Record<string, unknown>[], runtime: string, flags: Rec
110
112
  function importTelemetry(argv: string[], defaultRuntime?: string): number {
111
113
  const { flags } = parseArgs(argv);
112
114
  const runtime = defaultRuntime ?? flagString(flags, "runtime", "codex") ?? "codex";
113
- const input = flagString(flags, "input-full-jsonl") ?? path.join(flagString(flags, "input-telemetry-dir") ?? "", "full.jsonl");
115
+ const explicitInputDir = flagString(flags, "input-telemetry-dir");
116
+ const input = flagString(flags, "input-full-jsonl") ?? path.join(explicitInputDir ? path.resolve(explicitInputDir) : defaultTelemetryDirForRead(), "full.jsonl");
114
117
  if (!input || !fs.existsSync(input)) throw new Error(`input telemetry file does not exist: ${input}`);
115
118
  const dir = telemetryDir(flags);
116
119
  ensureSafeDir(dir);
@@ -143,7 +146,7 @@ function syncArtifacts(argv: string[]): number {
143
146
  const dir = telemetryDir(flags);
144
147
  ensureSafeDir(dir);
145
148
  const artifacts = flagList(flags, "artifact-dir");
146
- const records = (artifacts.length ? artifacts : [".flow-agents"]).flatMap((item) => fs.existsSync(item) ? artifactOutcomes(item, flags) : []);
149
+ const records = (artifacts.length ? artifacts : [flowAgentsArtifactRoot(), legacyFlowAgentsArtifactRoot()]).flatMap((item) => fs.existsSync(item) ? artifactOutcomes(item, flags) : []);
147
150
  writeJsonlUpsert(path.join(dir, "outcomes.jsonl"), records, "outcome_id");
148
151
  if (!flagBool(flags, "quiet")) console.log(`synced ${records.length} artifact outcome(s) to ${path.join(dir, "outcomes.jsonl")}`);
149
152
  return 0;
@@ -288,7 +291,9 @@ function markdownReport(data: Record<string, unknown>, groupBy?: string): string
288
291
 
289
292
  function report(argv: string[]): number {
290
293
  const { flags } = parseArgs(argv);
291
- const dirs = flagList(flags, "telemetry-dir").map((dir) => path.resolve(dir));
294
+ const requestedDirs = flagList(flags, "telemetry-dir");
295
+ const dirs = (requestedDirs.length ? requestedDirs.map((dir) => path.resolve(dir)) : defaultTelemetryDirsForRead()).filter(Boolean);
296
+ if (dirs.length === 0) dirs.push(telemetryDataDir());
292
297
  dirs.forEach(ensureSafeDir);
293
298
  const data = reportData(dirs, flagString(flags, "group-by"));
294
299
  const format = flagString(flags, "format", "markdown");
@@ -319,7 +324,7 @@ function registerProject(argv: string[]): number {
319
324
  ensureSafeDir(globalDir);
320
325
  const repoRoot = path.resolve(flagString(flags, "repo-root", ".") ?? ".");
321
326
  const name = flagString(flags, "name", path.basename(repoRoot)) ?? path.basename(repoRoot);
322
- const record = { name, repo_root: repoRoot, artifact_dir: path.join(repoRoot, ".flow-agents"), input_telemetry_dir: path.join(repoRoot, ".telemetry"), runtime: flagString(flags, "runtime", "codex"), repo: flagString(flags, "repo", name), agent: flagString(flags, "agent"), profile_id: flagString(flags, "profile-id"), prompt_id: flagString(flags, "prompt-id"), prompt_variant: flagString(flags, "prompt-variant"), skill_ids: flagList(flags, "skill-id"), skill_variant: flagString(flags, "skill-variant") };
327
+ const record = { name, repo_root: repoRoot, artifact_dir: defaultArtifactRootForRead(repoRoot), input_telemetry_dir: defaultTelemetryDirForRead(repoRoot), runtime: flagString(flags, "runtime", "codex"), repo: flagString(flags, "repo", name), agent: flagString(flags, "agent"), profile_id: flagString(flags, "profile-id"), prompt_id: flagString(flags, "prompt-id"), prompt_variant: flagString(flags, "prompt-variant"), skill_ids: flagList(flags, "skill-id"), skill_variant: flagString(flags, "skill-variant") };
323
328
  const registryFile = path.join(globalDir, "projects.json");
324
329
  const existing = fs.existsSync(registryFile) ? JSON.parse(fs.readFileSync(registryFile, "utf8")) : { projects: [] };
325
330
  const projects = Array.isArray(existing) ? existing : Array.isArray(existing.projects) ? existing.projects : [];
@@ -342,7 +347,9 @@ function syncProject(project: Record<string, unknown>, globalDir: string): void
342
347
  const name = String(project.name ?? project.repo ?? "project").replace(/[^a-zA-Z0-9_.-]+/g, "-") || "project";
343
348
  const store = path.join(globalDir, "projects", name);
344
349
  ensureSafeDir(store);
345
- const artifactDir = String(project.artifact_dir ?? path.join(String(project.repo_root), ".flow-agents"));
350
+ const repoRoot = String(project.repo_root ?? process.cwd());
351
+ const configuredArtifactDir = typeof project.artifact_dir === "string" ? project.artifact_dir : "";
352
+ const artifactDir = configuredArtifactDir && fs.existsSync(configuredArtifactDir) ? configuredArtifactDir : defaultArtifactRootForRead(repoRoot);
346
353
  const flags: Record<string, string | boolean | string[]> = {
347
354
  "repo": String(project.repo ?? name),
348
355
  "runtime": String(project.runtime ?? "codex"),
@@ -361,9 +368,9 @@ function syncProject(project: Record<string, unknown>, globalDir: string): void
361
368
  function discoverProjects(root: string): Record<string, unknown>[] {
362
369
  if (!fs.existsSync(root)) return [];
363
370
  const candidates = [root, ...fs.readdirSync(root).map((name) => path.join(root, name))];
364
- return candidates.filter((candidate) => fs.existsSync(path.join(candidate, ".flow-agents"))).map((repoRoot) => {
371
+ return candidates.filter((candidate) => fs.existsSync(flowAgentsArtifactRoot(candidate)) || fs.existsSync(legacyFlowAgentsArtifactRoot(candidate))).map((repoRoot) => {
365
372
  const name = path.basename(repoRoot);
366
- return { name, repo: name, repo_root: repoRoot, artifact_dir: path.join(repoRoot, ".flow-agents"), input_telemetry_dir: path.join(repoRoot, ".telemetry"), runtime: "codex", skill_ids: [] };
373
+ return { name, repo: name, repo_root: repoRoot, artifact_dir: defaultArtifactRootForRead(repoRoot), input_telemetry_dir: defaultTelemetryDirForRead(repoRoot), runtime: "codex", skill_ids: [] };
367
374
  });
368
375
  }
369
376
 
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import * as path from "node:path";
4
4
  import { flagBool, flagString, parseArgs } from "../lib/args.js";
5
+ import { defaultArtifactRootForRead } from "../lib/local-artifact-root.js";
5
6
 
6
7
  type Classification =
7
8
  | "active_wip"
@@ -49,7 +50,7 @@ function printHelp(): void {
49
50
  console.log("Read-only dry-run audit for local workflow artifact directories.");
50
51
  console.log("");
51
52
  console.log("Options:");
52
- console.log(" --artifact-root <path> Local artifact root to scan (default: .flow-agents)");
53
+ console.log(" --artifact-root <path> Local artifact root to scan (default: .kontourai/flow-agents, with .flow-agents read fallback)");
53
54
  console.log(" --json Print stable JSON buckets instead of text");
54
55
  console.log(" --help Show this help");
55
56
  console.log("");
@@ -269,7 +270,8 @@ export function main(argv = process.argv.slice(2)): number {
269
270
  }
270
271
  let result: AuditResult;
271
272
  try {
272
- result = audit(flagString(args.flags, "artifact-root", ".flow-agents") ?? ".flow-agents");
273
+ const root = flagString(args.flags, "artifact-root") ? path.resolve(flagString(args.flags, "artifact-root")!) : defaultArtifactRootForRead();
274
+ result = audit(root);
273
275
  } catch (error) {
274
276
  console.error(`workflow-artifact-cleanup-audit: ${error instanceof Error ? error.message : String(error)}`);
275
277
  return 1;
@@ -7,6 +7,7 @@ import { createRequire } from "node:module";
7
7
  import { fileURLToPath } from "node:url";
8
8
  // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
9
9
  import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
10
+ import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
10
11
 
11
12
  type AnyObj = Record<string, any>;
12
13
 
@@ -652,7 +653,7 @@ function isUnderDir(dir: string, root: string): boolean {
652
653
  function explicitArtifactRoot(p: ReturnType<typeof parseArgs>): string {
653
654
  const explicit = opt(p, "artifact-dir");
654
655
  const configuredRoot = opt(p, "artifact-root");
655
- if (!explicit) return path.resolve(configuredRoot || ".flow-agents");
656
+ if (!explicit) return configuredRoot ? path.resolve(configuredRoot) : flowAgentsArtifactRoot();
656
657
  const dir = path.resolve(explicit);
657
658
  if (!fs.existsSync(dir)) die(`artifact directory does not exist: ${dir}`);
658
659
  if (fs.lstatSync(dir).isSymbolicLink()) die(`artifact directory must not be a symlink: ${dir}`);
@@ -879,7 +880,7 @@ function initSidecars(dir: string, slug: string, sourceRequest: string, summary:
879
880
  }
880
881
 
881
882
  function ensureSession(p: ReturnType<typeof parseArgs>): number {
882
- const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
883
+ const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
883
884
  const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
884
885
  const dir = sessionDirFor(root, slug);
885
886
  fs.mkdirSync(dir, { recursive: true });
@@ -912,7 +913,7 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
912
913
  }
913
914
 
914
915
  function current(p: ReturnType<typeof parseArgs>): number {
915
- const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
916
+ const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : defaultArtifactRootForRead();
916
917
  const dir = currentDir(root);
917
918
  if (!dir) die("no current workflow session is recorded");
918
919
  const format = opt(p, "format", "path");
@@ -1829,7 +1830,7 @@ function assertExistingLearningValid(dir: string): void {
1829
1830
  }
1830
1831
  }
1831
1832
  async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
1832
- const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
1833
+ const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : defaultArtifactRootForRead();
1833
1834
  const dir = path.resolve(opt(p, "artifact-dir") || currentDir(root) || "");
1834
1835
  requireArtifactDirUnderRoot(dir, root);
1835
1836
  assertExistingLearningValid(dir);
@@ -2265,7 +2266,7 @@ function loadSurfacePanelJs(): string {
2265
2266
  }
2266
2267
 
2267
2268
  async function renderTrustPanel(p: ReturnType<typeof parseArgs>): Promise<number> {
2268
- const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
2269
+ const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : defaultArtifactRootForRead();
2269
2270
  const dir = p.positional[0] ? artifactDirFrom(p.positional[0]) : currentDir(root);
2270
2271
  if (!dir) die("render-trust-panel requires a workflow dir or a recorded current session");
2271
2272
  let bundle: AnyObj | null = null;
@@ -2431,7 +2432,7 @@ function livenessLifecycle(taskDir: string, slug: string, kind: "claim" | "heart
2431
2432
  }
2432
2433
 
2433
2434
  async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
2434
- const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
2435
+ const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2435
2436
  const action = p.positional[0] || "";
2436
2437
  const subjectId = p.positional[1] || "";
2437
2438
  const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
@@ -2481,130 +2482,13 @@ async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
2481
2482
  // ─────────────────────────────────────────────────────────────────────────────
2482
2483
 
2483
2484
  // ─── Claim Lookup — pure helper (promotable to Surface #171) ─────────────────
2484
- // buildClaimExplanation is a PURE function: report + bundle + id in, structured
2485
- // explanation out. No fs, no CLI, no .flow-agents paths. Zero flow-agents
2486
- // specifics inside it it can be lifted to Surface unchanged (issue #171).
2487
-
2488
- export interface ClaimEvidenceItem {
2489
- evidenceType: string;
2490
- label: string;
2491
- execution: { runner: string; label: string; isError: boolean; exitCode: number | null } | null;
2492
- passing: boolean;
2493
- summary: string;
2494
- }
2495
-
2496
- export interface ClaimExplanation {
2497
- found: boolean;
2498
- status: string;
2499
- value: string;
2500
- claimType: string;
2501
- evidence: ClaimEvidenceItem[];
2502
- policy: {
2503
- id: string;
2504
- requiredEvidence: string[];
2505
- requiredMethods?: string[];
2506
- acceptanceCriteria: string[];
2507
- reviewAuthority: string;
2508
- } | null;
2509
- why: {
2510
- directInputs: AnyObj[];
2511
- leafClaims: AnyObj[];
2512
- diagnostics: AnyObj[];
2513
- transparencyGaps: AnyObj[];
2514
- changeRecords: AnyObj[];
2515
- };
2516
- }
2517
-
2518
- /**
2519
- * Build a structured explanation for a specific claim.
2520
- * PURE: report + bundle + id in, structured explanation out.
2521
- * No fs, no CLI, no .flow-agents paths. Promotable to Surface #171.
2522
- *
2523
- * @param report TrustReport from buildTrustReport(bundle) — required for derived status
2524
- * @param bundle Raw parsed trust.bundle (BundleFile shape)
2525
- * @param claimId The claim id to explain
2526
- */
2527
- export function buildClaimExplanation(
2528
- report: Record<string, unknown>,
2529
- bundle: Record<string, unknown>,
2530
- claimId: string,
2531
- ): ClaimExplanation {
2532
- const reportClaims = Array.isArray(report.claims) ? (report.claims as AnyObj[]) : [];
2533
- const reportClaim = reportClaims.find((c: AnyObj) => c.id === claimId);
2534
-
2535
- if (!reportClaim) {
2536
- return {
2537
- found: false,
2538
- status: "unknown",
2539
- value: "",
2540
- claimType: "",
2541
- evidence: [],
2542
- policy: null,
2543
- why: { directInputs: [], leafClaims: [], diagnostics: [], transparencyGaps: [], changeRecords: [] },
2544
- };
2545
- }
2546
-
2547
- const bundleClaims = Array.isArray(bundle.claims) ? (bundle.claims as AnyObj[]) : [];
2548
- const bundleClaim = bundleClaims.find((c: AnyObj) => c.id === claimId) ?? reportClaim;
2549
- const bundlePolicies = Array.isArray(bundle.policies) ? (bundle.policies as AnyObj[]) : [];
2550
- const bundleEvidence = Array.isArray(bundle.evidence) ? (bundle.evidence as AnyObj[]) : [];
2551
-
2552
- // Governing policy — follow verificationPolicyId into bundle.policies[]
2553
- const verificationPolicyId = typeof bundleClaim.verificationPolicyId === "string" ? bundleClaim.verificationPolicyId : undefined;
2554
- const rawPolicy = verificationPolicyId ? bundlePolicies.find((p: AnyObj) => p.id === verificationPolicyId) : undefined;
2555
- const policy = rawPolicy
2556
- ? {
2557
- id: String(rawPolicy.id ?? ""),
2558
- requiredEvidence: Array.isArray(rawPolicy.requiredEvidence) ? (rawPolicy.requiredEvidence as string[]) : [],
2559
- requiredMethods: Array.isArray(rawPolicy.requiredMethods) ? (rawPolicy.requiredMethods as string[]) : undefined,
2560
- acceptanceCriteria: Array.isArray(rawPolicy.acceptanceCriteria) ? (rawPolicy.acceptanceCriteria as string[]) : [],
2561
- reviewAuthority: String(rawPolicy.reviewAuthority ?? ""),
2562
- }
2563
- : null;
2564
-
2565
- // Evidence enhancement: pull evidence items for this claim, surface the execution block
2566
- const claimEvidenceItems = bundleEvidence.filter((ev: AnyObj) => ev && ev.claimId === claimId);
2567
- const evidence: ClaimEvidenceItem[] = claimEvidenceItems.map((ev: AnyObj) => {
2568
- const exec = ev.execution && typeof ev.execution === "object" ? (ev.execution as AnyObj) : null;
2569
- const execution = exec
2570
- ? {
2571
- runner: String(exec.runner ?? exec.label ?? ""),
2572
- label: String(exec.label ?? exec.runner ?? ""),
2573
- isError: Boolean(exec.isError ?? (typeof exec.exitCode === "number" && exec.exitCode !== 0)),
2574
- exitCode: typeof exec.exitCode === "number" ? exec.exitCode : null,
2575
- }
2576
- : null;
2577
- return {
2578
- evidenceType: String(ev.evidenceType ?? ev.type ?? "unknown"),
2579
- label: String(ev.label ?? ev.excerptOrSummary ?? ev.sourceRef ?? ev.id ?? ""),
2580
- execution,
2581
- passing: execution ? !execution.isError : String(ev.status ?? "") !== "disputed",
2582
- summary: String(ev.excerptOrSummary ?? ev.summary ?? ev.label ?? ""),
2583
- };
2584
- });
2585
-
2586
- // Drilldown: extract from report structure (report.transparencyGaps, report.changeRecords)
2587
- const allGaps = Array.isArray(report.transparencyGaps) ? (report.transparencyGaps as AnyObj[]) : [];
2588
- const allChanges = Array.isArray(report.changeRecords) ? (report.changeRecords as AnyObj[]) : [];
2589
- const transparencyGaps = allGaps.filter((g: AnyObj) => g && g.claimId === claimId);
2590
- const changeRecords = allChanges.filter((c: AnyObj) => c && c.claimId === claimId);
2591
-
2592
- return {
2593
- found: true,
2594
- status: String(reportClaim.status ?? "unknown"),
2595
- value: String(bundleClaim.value ?? reportClaim.value ?? ""),
2596
- claimType: String(bundleClaim.claimType ?? reportClaim.claimType ?? ""),
2597
- evidence,
2598
- policy,
2599
- why: {
2600
- directInputs: [], // populated by buildDerivationDrilldown if non-leaf
2601
- leafClaims: [],
2602
- diagnostics: [],
2603
- transparencyGaps,
2604
- changeRecords,
2605
- },
2606
- };
2607
- }
2485
+ // buildClaimExplanation + its types are extracted to ./sidecar-claim-explain.ts
2486
+ // (ops#22): a PURE projection (report + bundle + id in, structured explanation out)
2487
+ // with no fs/CLI/shared state, unit-tested in isolation. Re-exported here so the
2488
+ // library facade (src/index.ts) and the IO `claimLookup` handler below are unchanged.
2489
+ export { buildClaimExplanation } from "./sidecar-claim-explain.js";
2490
+ export type { ClaimEvidenceItem, ClaimExplanation } from "./sidecar-claim-explain.js";
2491
+ import { buildClaimExplanation } from "./sidecar-claim-explain.js";
2608
2492
 
2609
2493
  /**
2610
2494
  * claim <id> <dir>
@@ -2768,7 +2652,9 @@ Available claim ids:
2768
2652
  async function main(): Promise<number> {
2769
2653
  const p = parseArgs(process.argv.slice(2));
2770
2654
  if (!p.command) die("workflow-sidecar command is required");
2771
- const lockRoot = ["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) ? path.resolve(opt(p, "artifact-root", ".flow-agents")) : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
2655
+ const lockRoot = ["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command)
2656
+ ? (opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : (p.command === "ensure-session" ? flowAgentsArtifactRoot() : defaultArtifactRootForRead()))
2657
+ : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
2772
2658
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
2773
2659
  switch (p.command) {
2774
2660
  case "ensure-session": return ensureSession(p);
package/src/index.ts CHANGED
@@ -16,6 +16,20 @@
16
16
  import * as path from "node:path";
17
17
  import { loadJson as _loadJson, writeJson as _writeJson } from "./cli/workflow-sidecar.js";
18
18
 
19
+ export {
20
+ defaultArtifactRootForRead,
21
+ defaultTelemetryDirForRead,
22
+ defaultTelemetryDirsForRead,
23
+ firstExistingPath,
24
+ flowAgentsArtifactRoot,
25
+ KONTOURAI_DIR,
26
+ legacyFlowAgentsArtifactRoot,
27
+ LEGACY_FLOW_AGENTS_DIR,
28
+ legacyTelemetryDataDir,
29
+ LEGACY_TELEMETRY_DIR,
30
+ telemetryDataDir,
31
+ } from "./lib/local-artifact-root.js";
32
+
19
33
  export {
20
34
  // Trust-bundle (Hachure) validation — the same validator the writer uses.
21
35
  validateTrustBundle,
@@ -0,0 +1,39 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export const KONTOURAI_DIR = ".kontourai";
5
+ export const LEGACY_FLOW_AGENTS_DIR = ".flow-agents";
6
+ export const LEGACY_TELEMETRY_DIR = ".telemetry";
7
+
8
+ export function flowAgentsArtifactRoot(cwd = process.cwd()): string {
9
+ return path.resolve(cwd, KONTOURAI_DIR, "flow-agents");
10
+ }
11
+
12
+ export function legacyFlowAgentsArtifactRoot(cwd = process.cwd()): string {
13
+ return path.resolve(cwd, LEGACY_FLOW_AGENTS_DIR);
14
+ }
15
+
16
+ export function telemetryDataDir(cwd = process.cwd()): string {
17
+ return path.resolve(cwd, KONTOURAI_DIR, "telemetry");
18
+ }
19
+
20
+ export function legacyTelemetryDataDir(cwd = process.cwd()): string {
21
+ return path.resolve(cwd, LEGACY_TELEMETRY_DIR);
22
+ }
23
+
24
+ export function firstExistingPath(candidates: string[]): string {
25
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
26
+ }
27
+
28
+ export function defaultArtifactRootForRead(cwd = process.cwd()): string {
29
+ return firstExistingPath([flowAgentsArtifactRoot(cwd), legacyFlowAgentsArtifactRoot(cwd)]);
30
+ }
31
+
32
+ export function defaultTelemetryDirForRead(cwd = process.cwd()): string {
33
+ return firstExistingPath([telemetryDataDir(cwd), legacyTelemetryDataDir(cwd)]);
34
+ }
35
+
36
+ export function defaultTelemetryDirsForRead(cwd = process.cwd()): string[] {
37
+ const dirs = [telemetryDataDir(cwd), legacyTelemetryDataDir(cwd)];
38
+ return dirs.filter((dir, index) => dirs.indexOf(dir) === index && fs.existsSync(dir));
39
+ }
@@ -74,6 +74,7 @@ const hookFilePolicies = new Map<string, { category: string; requiredNeedles: st
74
74
  ["scripts/hooks/lib/audit-transport.sh", { category: "shared hook library", requiredNeedles: ["audit_emit"] }],
75
75
  ["scripts/hooks/lib/hook-flags.js", { category: "shared hook library", requiredNeedles: ["isHookEnabled"] }],
76
76
  ["scripts/hooks/lib/liveness-read.js", { category: "shared hook library", requiredNeedles: ["freshHolders", "readLivenessEvents"] }],
77
+ ["scripts/hooks/lib/local-artifact-paths.js", { category: "shared hook library", requiredNeedles: ["flowAgentsArtifactRoot", "defaultArtifactRootForRead"] }],
77
78
  ["scripts/hooks/lib/patterns.sh", { category: "shared hook library", requiredNeedles: ["_detect_secrets"] }],
78
79
  ["scripts/hooks/lib/resolve-formatter.js", { category: "shared hook library", requiredNeedles: ["resolveFormatter"] }],
79
80
  ]);