@kontourai/flow-agents 2.0.0 → 2.1.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 (41) hide show
  1. package/.github/actions/trust-verify/action.yml +4 -2
  2. package/.github/workflows/ci.yml +12 -0
  3. package/.github/workflows/runtime-compat.yml +1 -1
  4. package/CHANGELOG.md +29 -0
  5. package/README.md +3 -3
  6. package/build/src/cli/workflow-sidecar.d.ts +16 -0
  7. package/build/src/cli/workflow-sidecar.js +72 -12
  8. package/build/src/lib/flow-resolver.d.ts +29 -0
  9. package/build/src/lib/flow-resolver.js +71 -0
  10. package/context/scripts/telemetry/lib/config.sh +15 -0
  11. package/context/scripts/telemetry/telemetry.conf +4 -0
  12. package/context/scripts/telemetry/telemetry.sh +23 -1
  13. package/docs/design/flowrun-eventsourcing-design.md +216 -0
  14. package/docs/design/workflowrun-observability-design.md +431 -0
  15. package/evals/ci/antigaming-suite.sh +2 -0
  16. package/evals/ci/run-baseline.sh +2 -0
  17. package/evals/integration/test_command_log_concurrency.sh +114 -0
  18. package/evals/integration/test_command_log_fork_classification.sh +134 -0
  19. package/evals/integration/test_kit_identity_trust.sh +393 -0
  20. package/evals/integration/test_usage_cost.sh +119 -0
  21. package/evals/integration/test_verify_cli.sh +23 -0
  22. package/evals/run.sh +2 -0
  23. package/integrations/strands/flow_agents_strands/hooks.py +126 -1
  24. package/integrations/strands/flow_agents_strands/telemetry.py +172 -0
  25. package/integrations/strands/tests/test_usage.py +129 -0
  26. package/integrations/strands-ts/src/hooks.ts +135 -1
  27. package/integrations/strands-ts/src/telemetry.ts +170 -0
  28. package/integrations/strands-ts/test/test-usage.ts +85 -0
  29. package/package.json +5 -5
  30. package/scripts/hooks/evidence-capture.js +75 -13
  31. package/scripts/hooks/stop-goal-fit.js +76 -23
  32. package/scripts/repair-command-log.js +115 -0
  33. package/scripts/telemetry/lib/config.sh +15 -0
  34. package/scripts/telemetry/lib/pricing.sh +42 -0
  35. package/scripts/telemetry/lib/usage.sh +108 -0
  36. package/scripts/telemetry/pricing.golden.json +15 -0
  37. package/scripts/telemetry/pricing.json +31 -0
  38. package/scripts/telemetry/telemetry.conf +4 -0
  39. package/scripts/telemetry/telemetry.sh +23 -1
  40. package/src/cli/workflow-sidecar.ts +73 -11
  41. package/src/lib/flow-resolver.ts +85 -0
@@ -113,7 +113,9 @@ runs:
113
113
  BUNDLE_ARG=""
114
114
  fi
115
115
 
116
- node "${{ github.action_path }}/../../scripts/ci/trust-reconcile.js" \
116
+ # action_path is .github/actions/trust-verify/ — climb THREE levels to the
117
+ # repo root where scripts/ lives (trust-verify -> actions -> .github -> root).
118
+ node "${{ github.action_path }}/../../../scripts/ci/trust-reconcile.js" \
117
119
  --commands "$VERIFY_COMMAND" \
118
120
  --repo-root "${{ github.workspace }}" \
119
121
  $BUNDLE_ARG || {
@@ -130,7 +132,7 @@ runs:
130
132
  - name: Mint attestation
131
133
  if: inputs.sign == 'true' && steps.trust-verify.outcome == 'success'
132
134
  shell: bash
133
- run: node "${{ github.action_path }}/../../scripts/ci/mint-attestation.js"
135
+ run: node "${{ github.action_path }}/../../../scripts/ci/mint-attestation.js"
134
136
 
135
137
  - name: Upload attestation
136
138
  if: inputs.sign == 'true' && steps.trust-verify.outcome == 'success'
@@ -14,6 +14,14 @@ concurrency:
14
14
  cancel-in-progress: true
15
15
 
16
16
  jobs:
17
+ # Suite-wide secret-scan gate, defined once in kontourai/.github (Hachure: one
18
+ # normative source). Scans git-tracked history; gitignored runtime/.env excluded.
19
+ secret-scan:
20
+ name: Secret Scan
21
+ uses: kontourai/.github/.github/workflows/secret-scan.yml@main
22
+ permissions:
23
+ contents: read
24
+
17
25
  source-and-static:
18
26
  name: Source and Static
19
27
  runs-on: ubuntu-latest
@@ -242,6 +250,10 @@ jobs:
242
250
  continue-on-error: true
243
251
  run: bash evals/ci/run-baseline.sh --check telemetry-doctor-integration
244
252
 
253
+ - name: Usage and cost integration
254
+ continue-on-error: true
255
+ run: bash evals/ci/run-baseline.sh --check usage-and-cost-integration
256
+
245
257
  - name: Utterance check integration
246
258
  continue-on-error: true
247
259
  run: bash evals/ci/run-baseline.sh --check utterance-check-integration
@@ -75,7 +75,7 @@ jobs:
75
75
  node-version: 24
76
76
 
77
77
  - name: Set up Python
78
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
78
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
79
79
  with:
80
80
  python-version: "3.12"
81
81
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.0](https://github.com/kontourai/flow-agents/compare/v2.0.1...v2.1.0) (2026-06-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **telemetry:** derive live pricing source from the console ([#242](https://github.com/kontourai/flow-agents/issues/242)) ([ddce44e](https://github.com/kontourai/flow-agents/commit/ddce44e813e9a3515953324f4878bf51c33252ba))
9
+ * **telemetry:** real token+cost capture with single-source versioned pricing ([#241](https://github.com/kontourai/flow-agents/issues/241)) ([b0bd4c3](https://github.com/kontourai/flow-agents/commit/b0bd4c347897ec77f60d84cae702e7f42b2871d7))
10
+
11
+
12
+ ### Fixes
13
+
14
+ * **evidence-capture:** serialize command-log appends to prevent chain forks ([#232](https://github.com/kontourai/flow-agents/issues/232)) ([bb167e9](https://github.com/kontourai/flow-agents/commit/bb167e93e7f6cc19baa88da613e96fe88a681c10))
15
+ * **flow-agents:** stop corrupting sidecar JSONL event lines ([#244](https://github.com/kontourai/flow-agents/issues/244)) ([fb65d10](https://github.com/kontourai/flow-agents/commit/fb65d1017e5cb659ce2b48da7a548f0c1f360426))
16
+ * **trust-verify action:** correct cross-repo script path (../../ → ../../../) ([#240](https://github.com/kontourai/flow-agents/issues/240)) ([a75a6d2](https://github.com/kontourai/flow-agents/commit/a75a6d28baf68b4be527a2e8cdff8f007af88bd5))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **design:** preserve WorkflowRun observability + FlowRun event-sourcing design notes ([#239](https://github.com/kontourai/flow-agents/issues/239)) ([c2dc116](https://github.com/kontourai/flow-agents/commit/c2dc11698cf63704f14087001c4494079195d197))
22
+ * **flow-agents:** advertise the real eval coverage, clearly scoped (ops[#23](https://github.com/kontourai/flow-agents/issues/23)) ([#248](https://github.com/kontourai/flow-agents/issues/248)) ([d208207](https://github.com/kontourai/flow-agents/commit/d20820749408d5fa63f2bf1470252000712de5d8))
23
+
24
+ ## [2.0.1](https://github.com/kontourai/flow-agents/compare/v2.0.0...v2.0.1) (2026-06-27)
25
+
26
+
27
+ ### Fixes
28
+
29
+ * carry KIT IDENTITY through the trust chain — stop flattening non-builder kits to "builder" ([#235](https://github.com/kontourai/flow-agents/issues/235)) ([02d2782](https://github.com/kontourai/flow-agents/commit/02d2782ca8d9158a018d0fc6c35adc6a34c827d5))
30
+ * **gate:** classify concurrent-fork vs tamper; never hard-block a benign race ([#233](https://github.com/kontourai/flow-agents/issues/233)) ([e24743b](https://github.com/kontourai/flow-agents/commit/e24743b7dbff05df64e198e420e47841ce534df3))
31
+
3
32
  ## [2.0.0](https://github.com/kontourai/flow-agents/compare/v1.4.0...v2.0.0) (2026-06-27)
4
33
 
5
34
 
package/README.md CHANGED
@@ -29,7 +29,7 @@ Flow Agents addresses this with a process-discipline layer that sits between the
29
29
  - **Four canonical policies** — workflow steering (phase reminders at each turn), quality gate (per-file checks after edits), stop-goal-fit (evidence check before the agent stops), and config protection (veto writes to linter/formatter configs). Each policy class has a canonical script under `scripts/hooks/` and compiles to the host's native hook format.
30
30
  - **Evidence over confidence** — important work ends with tests, browser checks, CI results, review findings, governance reports, or an explicit `NOT_VERIFIED` gap. Optional [Veritas](docs/veritas-integration.md) integration attaches repo-governance evidence without making it mandatory.
31
31
  - **Verifiable, un-gameable "done"** — the agent can't mark work complete that isn't: the gate re-derives the verdict from independent evidence, an external CI anchor re-runs the verification fresh and fails the merge on any divergence, and CI mints a Sigstore-signed record of what shipped. See [Verifiable Trust — why "done" actually means done](docs/verifiable-trust.md).
32
- - **Evals that keep the bundle honest** — 77 integration and 36 static bundle assertions validate the skills, contracts, fixtures, and hook influence as the bundle evolves.
32
+ - **Evals that keep the bundle honest** — 60 integration scenarios (1,829 assertions) and 7 static suites (110 assertions) validate the skills, contracts, fixtures, and hook influence as the bundle evolves.
33
33
 
34
34
  ## Flow Agents as a process-discipline layer
35
35
 
@@ -52,8 +52,8 @@ L2 means all four policy classes with blocking; L1 means steering and stop-goal-
52
52
 
53
53
  | Runtime | Ships | Tested |
54
54
  | --- | --- | --- |
55
- | Claude Code | install + hooks + bundle | 77 integration + 36 static assertions — reference implementation |
56
- | Codex | install + hooks + bundle | 77 integration + 36 static assertions — reference implementation |
55
+ | Claude Code | install + hooks + bundle | 60 integration scenarios + 7 static suites (1,939 assertions) — reference implementation |
56
+ | Codex | install + hooks + bundle | 60 integration scenarios + 7 static suites (1,939 assertions) — reference implementation |
57
57
  | Kiro | install + hooks + bundle | included in bundle assertions |
58
58
 
59
59
  **Partial support — L1 (steering + stop-goal-fit warning)**
@@ -167,6 +167,22 @@ export declare function sidecarBase(slug: string): AnyObj;
167
167
  export declare function validateEvidenceRef(ref: AnyObj, label: string): AnyObj;
168
168
  export declare function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[];
169
169
  export declare function normalizeCheck(raw: AnyObj): AnyObj;
170
+ /**
171
+ * Derive kit identity from a parsed trust.bundle by structurally reading the
172
+ * DECLARED primary claim (kit-typed) rather than hardcoding "builder".
173
+ *
174
+ * Resolution order (no fallbacks to "builder"):
175
+ * 1. First non-workflow.* claim in bundle.claims[] → claimType drives kitId + subject.
176
+ * 2. No kit-typed claim: try current.json active_flow_id adjacent to the bundle file
177
+ * (bundle lives at <session-dir>/trust.bundle → flowAgentsDir = grandparent).
178
+ * 3. Genuinely unknown: mark as "unknown" — never hardcode a kit identity.
179
+ */
180
+ export declare function kitIdentityFromBundle(raw: AnyObj, bundleFile: string): {
181
+ claimType: string;
182
+ kitId: string;
183
+ subject: string;
184
+ gateId: string;
185
+ };
170
186
  export declare function writeState(dir: string, slug: string, status: string, phase: string, timestamp: string, summary: string, next?: string): void;
171
187
  export declare function normalizeFinding(raw: AnyObj): AnyObj;
172
188
  /**
@@ -6,7 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import { createRequire } from "node:module";
7
7
  import { fileURLToPath } from "node:url";
8
8
  // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
9
- import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap } from "../lib/flow-resolver.js";
9
+ import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy } from "../lib/flow-resolver.js";
10
10
  export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
11
11
  export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
12
12
  export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
@@ -15,11 +15,17 @@ export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
15
15
  function now() { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
16
16
  function read(file) { return fs.readFileSync(file, "utf8"); }
17
17
  export function writeJson(file, payload) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`); }
18
- function printJson(payload) { console.log(JSON.stringify(payload).replace(/":/g, '": ').replace(/,"/g, ', "')); }
18
+ // Single-line but readable "key": "value" form. Built by collapsing the
19
+ // structural whitespace from an indented stringify — corruption-proof, unlike a
20
+ // regex that would also rewrite ":"/"," sequences inside string values.
21
+ function spacedLine(payload, replacer) {
22
+ return JSON.stringify(payload, replacer, 1).replace(/\n\s*/g, " ");
23
+ }
24
+ function printJson(payload) { console.log(spacedLine(payload)); }
19
25
  export function loadJson(file, fallback = {}) { return fs.existsSync(file) ? JSON.parse(read(file)) : { ...fallback }; }
20
26
  export function appendJsonl(file, payload) {
21
27
  fs.mkdirSync(path.dirname(file), { recursive: true });
22
- const line = JSON.stringify(payload, Object.keys(payload).sort()).replace(/":/g, '": ').replace(/,"/g, ', "');
28
+ const line = spacedLine(payload, Object.keys(payload).sort());
23
29
  fs.appendFileSync(file, `${line}\n`);
24
30
  }
25
31
  function die(message) { throw new Error(message); }
@@ -1071,15 +1077,63 @@ function deriveSurfaceStatus(ref) {
1071
1077
  return "fail";
1072
1078
  return "pass";
1073
1079
  }
1080
+ /**
1081
+ * Derive kit identity from a parsed trust.bundle by structurally reading the
1082
+ * DECLARED primary claim (kit-typed) rather than hardcoding "builder".
1083
+ *
1084
+ * Resolution order (no fallbacks to "builder"):
1085
+ * 1. First non-workflow.* claim in bundle.claims[] → claimType drives kitId + subject.
1086
+ * 2. No kit-typed claim: try current.json active_flow_id adjacent to the bundle file
1087
+ * (bundle lives at <session-dir>/trust.bundle → flowAgentsDir = grandparent).
1088
+ * 3. Genuinely unknown: mark as "unknown" — never hardcode a kit identity.
1089
+ */
1090
+ export function kitIdentityFromBundle(raw, bundleFile) {
1091
+ // 1. Structurally read the bundle's declared kit-typed claim.
1092
+ const claims = Array.isArray(raw.claims) ? raw.claims : [];
1093
+ for (const claim of claims) {
1094
+ const ct = typeof claim?.claimType === "string" ? claim.claimType : "";
1095
+ if (ct && !ct.startsWith("workflow.")) {
1096
+ const kitId = ct.split(".")[0] ?? "unknown";
1097
+ if (kitId && kitId !== "unknown") {
1098
+ return { claimType: ct, kitId, subject: `${kitId}-kit`, gateId: ct };
1099
+ }
1100
+ }
1101
+ }
1102
+ // 2. No kit-typed claim in bundle — try to derive kit from current.json active_flow_id.
1103
+ // The bundle lives at <session-dir>/trust.bundle, so:
1104
+ // sessionDir = path.dirname(bundleFile)
1105
+ // flowAgentsDir = path.dirname(sessionDir)
1106
+ try {
1107
+ const sessionDir = path.dirname(bundleFile);
1108
+ const flowAgentsDir = path.dirname(sessionDir);
1109
+ const currentFile = path.join(flowAgentsDir, "current.json");
1110
+ const current = JSON.parse(fs.readFileSync(currentFile, "utf8"));
1111
+ const flowId = typeof current["active_flow_id"] === "string" ? current["active_flow_id"] : null;
1112
+ if (flowId && flowId.includes(".")) {
1113
+ const kitId = flowId.split(".")[0];
1114
+ if (kitId) {
1115
+ const derivedClaimType = `${kitId}.trust.bundle`;
1116
+ return { claimType: derivedClaimType, kitId, subject: `${kitId}-kit`, gateId: derivedClaimType };
1117
+ }
1118
+ }
1119
+ }
1120
+ catch {
1121
+ // Ignore — fall through to unknown
1122
+ }
1123
+ // 3. Genuinely unknown — never fallback to "builder".
1124
+ return { claimType: "unknown.trust.bundle", kitId: "unknown", subject: "unknown-kit", gateId: "unknown.trust.bundle" };
1125
+ }
1074
1126
  function surfaceCheckFromArtifact(file, index) {
1075
1127
  const raw = JSON.parse(read(file));
1076
1128
  const lower = JSON.stringify(raw).toLowerCase();
1129
+ // Structurally read kit identity from the bundle — never hardcode "builder".
1130
+ const { claimType: bundleClaimType, subject: bundleSubject, gateId: bundleGateId } = kitIdentityFromBundle(raw, file);
1077
1131
  let ref;
1078
1132
  if (lower.includes("provider") && lower.includes("absent")) {
1079
- ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "provider.unavailable", claim_type: "builder.trust.bundle", claim_status: "unknown", subject: "builder-kit", freshness: { status: "unknown", summary: "No trust provider is configured" }, authority: { producer: "unknown", summary: "No trust provider is configured" }, integrity: { status: "unknown", summary: "Unknown" }, status: "not_verified", summary: "No trust provider is configured" };
1133
+ ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "provider.unavailable", claim_type: bundleClaimType, claim_status: "unknown", subject: bundleSubject, freshness: { status: "unknown", summary: "No trust provider is configured" }, authority: { producer: "unknown", summary: "No trust provider is configured" }, integrity: { status: "unknown", summary: "Unknown" }, status: "not_verified", summary: "No trust provider is configured" };
1080
1134
  }
1081
1135
  else if (lower.includes("artifact") && lower.includes("absent")) {
1082
- ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "artifact.unavailable", claim_type: "builder.trust.bundle", claim_status: "unknown", subject: "builder-kit", freshness: { status: "unknown", summary: "Artifact not readable" }, authority: { producer: "unknown", summary: "Artifact not readable" }, integrity: { status: "unknown", summary: "Artifact not readable" }, status: "not_verified", summary: "artifact not readable" };
1136
+ ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "artifact.unavailable", claim_type: bundleClaimType, claim_status: "unknown", subject: bundleSubject, freshness: { status: "unknown", summary: "Artifact not readable" }, authority: { producer: "unknown", summary: "Artifact not readable" }, integrity: { status: "unknown", summary: "Artifact not readable" }, status: "not_verified", summary: "artifact not readable" };
1083
1137
  }
1084
1138
  else {
1085
1139
  const claimStatus = lower.includes("rejected") ? "rejected" : "accepted";
@@ -1087,7 +1141,7 @@ function surfaceCheckFromArtifact(file, index) {
1087
1141
  const producer = lower.includes("missing-authority") ? "unknown" : "surface-local";
1088
1142
  const integrity = lower.includes("mismatch") ? "mismatch" : "matched";
1089
1143
  // Use trust.bundle as the canonical Hachure-aligned artifact_kind for all trust-backed evidence refs
1090
- ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "builder.trust.bundle", claim_type: "builder.trust.bundle", claim_status: claimStatus, subject: "builder-kit", freshness: { status: freshness, summary: freshness === "fresh" ? "fresh" : "not currently verifiable" }, authority: { producer, summary: producer === "unknown" ? "missing authority" : "Local Surface trust producer." }, integrity: { status: integrity, summary: integrity === "matched" ? "matched" : "integrity mismatch" } };
1144
+ ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: bundleGateId, claim_type: bundleClaimType, claim_status: claimStatus, subject: bundleSubject, freshness: { status: freshness, summary: freshness === "fresh" ? "fresh" : "not currently verifiable" }, authority: { producer, summary: producer === "unknown" ? "missing authority" : "Local Surface trust producer." }, integrity: { status: integrity, summary: integrity === "matched" ? "matched" : "integrity mismatch" } };
1091
1145
  ref.status = deriveSurfaceStatus(ref);
1092
1146
  ref.summary = ref.status === "pass" ? "accepted" : ref.status === "not_verified" ? "not currently verifiable" : (claimStatus === "rejected" ? "rejected" : producer === "unknown" ? "missing authority" : "integrity mismatch");
1093
1147
  }
@@ -1342,16 +1396,22 @@ async function advanceState(p) {
1342
1396
  if ((status === "archived" || status === "accepted") && prev.phase !== "learning")
1343
1397
  diagnostic(dir, "terminal_jump_rejected", "Terminal workflow states require release and learning gates.");
1344
1398
  const flow = opt(p, "flow-definition");
1345
- if (flow === "builder.build" && prev.phase === "verification" && phase === "execution") {
1399
+ // Route-back guard: FlowDefinition-driven (not hardcoded to builder.build).
1400
+ // Fires when the active flow's gate for prev.phase declares a route_back_policy
1401
+ // AND the target phase maps to a step listed in on_route_back values.
1402
+ // builder.build verify-gate already carries this declaration — behavior preserved.
1403
+ const repoRoot = flow ? findRepoRootFromDir(dir) : "";
1404
+ const routeBack = flow ? resolveRouteBackPolicy(flow, prev.phase, phase, repoRoot) : null;
1405
+ if (routeBack) {
1346
1406
  const reason = opt(p, "route-back-reason");
1347
1407
  if (!reason)
1348
- diagnostic(dir, "route_back_reason_required", "Builder Kit route-back requires implementation_defect or equivalent reason.");
1408
+ diagnostic(dir, "route_back_reason_required", `Route-back from ${prev.phase} to ${phase} requires a --route-back-reason (e.g. implementation_defect).`);
1349
1409
  const file = path.join(dir, "transition-attempts.json");
1350
1410
  const attempts = loadJson(file);
1351
- const key = `verification->execution:${reason}`;
1411
+ const key = `${prev.phase}->${phase}:${reason}`;
1352
1412
  const count = attempts[key]?.count ?? 0;
1353
- if (count >= 3)
1354
- diagnostic(dir, "route_back_attempts_exceeded", "Builder Kit route-back attempts exceeded.");
1413
+ if (count >= routeBack.maxAttempts)
1414
+ diagnostic(dir, "route_back_attempts_exceeded", `Route-back attempt limit (${routeBack.maxAttempts}) exceeded for ${prev.phase}→${phase}.`);
1355
1415
  attempts[key] = { count: count + 1, reason, updated_at: opt(p, "timestamp", now()) };
1356
1416
  writeJson(file, attempts);
1357
1417
  }
@@ -1365,7 +1425,7 @@ async function advanceState(p) {
1365
1425
  // --step-id individually. The repoRoot is derived by walking up from dir to find kits/.
1366
1426
  if (flow) {
1367
1427
  const root = path.resolve(opt(p, "artifact-root", path.dirname(dir)));
1368
- const repoRoot = findRepoRootFromDir(dir);
1428
+ // repoRoot already computed above when flow is present
1369
1429
  const phaseMap = resolvePhaseMap(flow, repoRoot);
1370
1430
  const stepId = phaseMap?.[phase] ?? undefined;
1371
1431
  if (stepId) {
@@ -80,3 +80,32 @@ export declare function resolvePhaseMap(flowId: string, repoRoot: string): Recor
80
80
  * @returns ActiveFlowStep or null when fields are absent or resolution fails.
81
81
  */
82
82
  export declare function resolveActiveFlowStep(flowAgentsDir: string): ActiveFlowStep | null;
83
+ /** The resolved route-back policy for a phase transition. */
84
+ export type RouteBackPolicy = {
85
+ /** Maximum allowed route-back attempts for this transition key. */
86
+ maxAttempts: number;
87
+ /** Action when attempts are exceeded (e.g. "block"). */
88
+ onExceeded: string;
89
+ /** The step id whose gate declared this policy (e.g. "verify"). */
90
+ fromStepId: string;
91
+ };
92
+ /**
93
+ * Resolve the route-back policy for a phase transition, if the active FlowDefinition
94
+ * declares one on the source phase's gate.
95
+ *
96
+ * A route-back is a transition where the source phase's gate declares both
97
+ * `route_back_policy` and `on_route_back`, and the target phase maps to a step
98
+ * listed as a route-back target in `on_route_back` values.
99
+ *
100
+ * This is the FlowDefinition-driven replacement for the hardcoded
101
+ * `flow === "builder.build" && prev.phase === "verification" && phase === "execution"`
102
+ * guard in advance-state. Any flow that declares `route_back_policy` on a gate
103
+ * automatically gets route-back enforcement without code changes.
104
+ *
105
+ * @param flowId e.g. "builder.build" — kitId is the prefix before the first ".".
106
+ * @param fromPhase Lifecycle phase leaving (e.g. "verification").
107
+ * @param toPhase Lifecycle phase entering (e.g. "execution").
108
+ * @param repoRoot Absolute path to the repository root (kits/ lives here).
109
+ * @returns RouteBackPolicy when the transition is a declared route-back, null otherwise.
110
+ */
111
+ export declare function resolveRouteBackPolicy(flowId: string, fromPhase: string, toPhase: string, repoRoot: string): RouteBackPolicy | null;
@@ -235,3 +235,74 @@ export function resolveActiveFlowStep(flowAgentsDir) {
235
235
  const repoRoot = findRepoRoot(path.dirname(flowAgentsDir));
236
236
  return resolveFlowStep(flowId, stepId, repoRoot);
237
237
  }
238
+ /**
239
+ * Resolve the route-back policy for a phase transition, if the active FlowDefinition
240
+ * declares one on the source phase's gate.
241
+ *
242
+ * A route-back is a transition where the source phase's gate declares both
243
+ * `route_back_policy` and `on_route_back`, and the target phase maps to a step
244
+ * listed as a route-back target in `on_route_back` values.
245
+ *
246
+ * This is the FlowDefinition-driven replacement for the hardcoded
247
+ * `flow === "builder.build" && prev.phase === "verification" && phase === "execution"`
248
+ * guard in advance-state. Any flow that declares `route_back_policy` on a gate
249
+ * automatically gets route-back enforcement without code changes.
250
+ *
251
+ * @param flowId e.g. "builder.build" — kitId is the prefix before the first ".".
252
+ * @param fromPhase Lifecycle phase leaving (e.g. "verification").
253
+ * @param toPhase Lifecycle phase entering (e.g. "execution").
254
+ * @param repoRoot Absolute path to the repository root (kits/ lives here).
255
+ * @returns RouteBackPolicy when the transition is a declared route-back, null otherwise.
256
+ */
257
+ export function resolveRouteBackPolicy(flowId, fromPhase, toPhase, repoRoot) {
258
+ if (!flowId || !fromPhase || !toPhase)
259
+ return null;
260
+ const dotIdx = flowId.indexOf(".");
261
+ if (dotIdx < 1)
262
+ return null;
263
+ const kitId = flowId.slice(0, dotIdx);
264
+ const flowName = flowId.slice(dotIdx + 1);
265
+ if (!kitId || !flowName)
266
+ return null;
267
+ const flowFilePath = resolveFlowFilePath(kitId, flowName, flowId, repoRoot);
268
+ if (!flowFilePath)
269
+ return null;
270
+ let flowDef;
271
+ try {
272
+ const raw = fs.readFileSync(flowFilePath, "utf8");
273
+ flowDef = JSON.parse(raw);
274
+ }
275
+ catch {
276
+ return null; // ENOENT, permission error, or parse error — fail-open
277
+ }
278
+ if (!flowDef || typeof flowDef !== "object")
279
+ return null;
280
+ const phaseMap = flowDef.phase_map;
281
+ if (!phaseMap || typeof phaseMap !== "object" || Array.isArray(phaseMap))
282
+ return null;
283
+ const fromStep = phaseMap[fromPhase];
284
+ const toStep = phaseMap[toPhase];
285
+ if (!fromStep || !toStep)
286
+ return null; // phases not in this flow
287
+ if (!flowDef.gates)
288
+ return null;
289
+ for (const gate of Object.values(flowDef.gates)) {
290
+ if (!gate || gate.step !== fromStep)
291
+ continue;
292
+ if (!gate.route_back_policy || !gate.on_route_back)
293
+ return null;
294
+ // Check if toStep is a valid route-back target declared in on_route_back
295
+ const routeBackTargets = Object.values(gate.on_route_back);
296
+ if (!routeBackTargets.includes(toStep))
297
+ return null;
298
+ const maxAttempts = typeof gate.route_back_policy.max_attempts === "number"
299
+ ? gate.route_back_policy.max_attempts
300
+ : 3;
301
+ return {
302
+ maxAttempts,
303
+ onExceeded: gate.route_back_policy.on_exceeded ?? "block",
304
+ fromStepId: fromStep,
305
+ };
306
+ }
307
+ return null;
308
+ }
@@ -38,6 +38,11 @@ CONSOLE_TELEMETRY_URL="${CONSOLE_TELEMETRY_URL:-${CONSOLE_URL:-}}"
38
38
  CONSOLE_TELEMETRY_ENDPOINT_URL="${CONSOLE_TELEMETRY_ENDPOINT_URL:-}"
39
39
  CONSOLE_TELEMETRY_TOKEN="${CONSOLE_TELEMETRY_TOKEN:-${CONSOLE_AUTH_TOKEN:-}}"
40
40
  CONSOLE_TENANT_ID="${CONSOLE_TENANT_ID:-}"
41
+ # Pricing registry source (consumed by lib/pricing.sh). Explicit file/URL win;
42
+ # otherwise the URL is derived from the console below so all runtimes read one
43
+ # live pricing source. Falls back to the bundled pricing.json offline.
44
+ TELEMETRY_PRICING_FILE="${TELEMETRY_PRICING_FILE:-${FLOW_AGENTS_PRICING_FILE:-}}"
45
+ TELEMETRY_PRICING_URL="${TELEMETRY_PRICING_URL:-${FLOW_AGENTS_PRICING_URL:-}}"
41
46
 
42
47
  # Load config file if it exists
43
48
  if [[ -f "$TELEMETRY_CONFIG_FILE" ]]; then
@@ -78,6 +83,9 @@ if [[ -f "$TELEMETRY_CONFIG_FILE" ]]; then
78
83
  console_telemetry_token) CONSOLE_TELEMETRY_TOKEN="$value" ;;
79
84
  console_tenant_id) CONSOLE_TENANT_ID="$value" ;;
80
85
  console_telemetry_redact) CONSOLE_TELEMETRY_REDACT="$value" ;;
86
+ console_pricing_url) TELEMETRY_PRICING_URL="$value" ;;
87
+ pricing_url) TELEMETRY_PRICING_URL="$value" ;;
88
+ pricing_file) TELEMETRY_PRICING_FILE="$value" ;;
81
89
  esac
82
90
  fi
83
91
  done < "$TELEMETRY_CONFIG_FILE"
@@ -85,5 +93,12 @@ fi
85
93
 
86
94
  CONSOLE_TELEMETRY_REDACT="${CONSOLE_TELEMETRY_REDACT:-${TELEMETRY_CHANNEL_ANALYTICS_REDACT}}"
87
95
 
96
+ # Derive the live pricing source from the console when not set explicitly, the
97
+ # same way the transport derives /api/telemetry/records. One live source for
98
+ # bash/Python/TS runtimes; lib/pricing.sh caches it and falls back to bundled.
99
+ if [[ -z "${TELEMETRY_PRICING_URL:-}" && -n "${CONSOLE_TELEMETRY_URL:-}" ]]; then
100
+ TELEMETRY_PRICING_URL="${CONSOLE_TELEMETRY_URL%/}/api/telemetry/pricing"
101
+ fi
102
+
88
103
  # Ensure directories exist
89
104
  mkdir -p "$TELEMETRY_DATA_DIR" "$TELEMETRY_SESSION_DIR" 2>/dev/null
@@ -8,6 +8,10 @@ channel.analytics.redact=tool.input,tool.output,turn.prompt_text,delegation.targ
8
8
  # The transport derives /api/telemetry/records from console_telemetry_url.
9
9
  # console_telemetry_token=
10
10
  # console_tenant_id=
11
+ # Live pricing registry source. If unset, derived from console_telemetry_url as
12
+ # <console>/api/telemetry/pricing so bash/Python/TS runtimes read one live
13
+ # source; lib/pricing.sh caches it and falls back to bundled pricing.json.
14
+ # console_pricing_url=https://console.kontourai.io/api/telemetry/pricing
11
15
  enrich_system=true
12
16
  enrich_workspace=true
13
17
  enrich_auth=true
@@ -309,13 +309,35 @@ add_stop_data_and_emit_usage() {
309
309
  tool_count=$(usage_count_tool_calls "$session_id" "$full_log")
310
310
  delegation_count=$(usage_count_delegations "$session_id" "$full_log")
311
311
 
312
+ # Ground-truth token + cost usage from the runtime transcript, when the
313
+ # runtime exposes one (Claude Code, Codex, etc. set hook.transcript_path).
314
+ # Tokens are source-of-truth; estimated_cost_usd is derived from pricing.json
315
+ # (recomputed authoritatively console-side, so pricing updates are retroactive).
316
+ local transcript_path transcript_usage
317
+ transcript_path=$(echo "$event" | jq -r '.hook.transcript_path // ""')
318
+ transcript_usage=$(usage_parse_transcript "$transcript_path")
319
+ [[ -z "$transcript_usage" ]] && transcript_usage='null'
320
+
312
321
  local usage_event
313
322
  usage_event=$(echo "$event" | jq -c \
314
323
  --arg m "$model" \
315
324
  --argjson tc "$tool_count" \
316
325
  --argjson dc "$delegation_count" \
326
+ --argjson tu "$transcript_usage" \
317
327
  '.event_type = "session.usage" | .event_id = (.event_id + "-usage") | . + {
318
- usage: {model: $m, duration_s: .session.duration_s, tool_invocations: $tc, delegations: $dc, input_tokens: null, output_tokens: null, estimated_cost_usd: null}
328
+ usage: ({
329
+ model: $m,
330
+ duration_s: .session.duration_s,
331
+ tool_invocations: $tc,
332
+ delegations: $dc,
333
+ input_tokens: ($tu.input_tokens // null),
334
+ output_tokens: ($tu.output_tokens // null),
335
+ cache_creation_input_tokens: ($tu.cache_creation_input_tokens // null),
336
+ cache_read_input_tokens: ($tu.cache_read_input_tokens // null),
337
+ estimated_cost_usd: ($tu.estimated_cost_usd // null),
338
+ pricing_version: ($tu.pricing_version // null),
339
+ by_model: ($tu.by_model // null)
340
+ })
319
341
  }')
320
342
  transport_emit "$usage_event"
321
343
  fi