@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,95 @@
1
+ ---
2
+ title: "ADR 0018: Freeze the Local Shell-Parsing Heuristics; Route New Enforcement to the CI Anchor"
3
+ ---
4
+
5
+ # ADR 0018: Freeze the Local Shell-Parsing Heuristics; Route New Enforcement to the CI Anchor
6
+
7
+ Status: Accepted
8
+
9
+ ## Context
10
+
11
+ ADR 0017 records the anti-gaming trust model: a **layered** defense whose *authoritative*
12
+ boundary is the external CI anchor (`trust-reconcile.js` + the required anti-gaming suite),
13
+ with the local Stop gate and `config-protection.js` as **Layer 1 bar-raisers** — explicitly
14
+ "a bar-raiser, not a wall." That model was earned by a 7-round adversarial loop (PRs
15
+ #213–#228), and ADR 0017 states the loop is "run to convergence."
16
+
17
+ The local shell-parsing heuristics have nonetheless kept accreting per-round string-matching
18
+ rules:
19
+
20
+ - `config-protection.js` — redirect detection (`>`, `>>`, `tee`), interpreter-write
21
+ detection (`node -e`, `py -c`, `sed -i`, `perl -e`), and `cp`/`mv`/`install` target
22
+ matching, grown across R5a / R6 / R7.
23
+ - `hasLaunderingOperator` (now in `scripts/lib/command-log-chain.js`) — the exit-code-mask
24
+ screen, extended in R6a / R7.
25
+
26
+ Their **own headers already admit the ceiling**: *"An agent with shell access can still
27
+ evade. The real anchor is external."* Each new round adds another regex for another evasion
28
+ shape (runtime-constructed paths, base64, exotic tools, here-docs, …) while the set remains,
29
+ by construction, non-exhaustive. This is **low-ROI churn** that also manufactures *false
30
+ confidence* in a control ADR 0017 already classifies as best-effort.
31
+
32
+ Worse, the pattern-chasing has a real cost beyond effort: the blanket `||` rule in
33
+ `hasLaunderingOperator` flags **any** `||` in a claimed verification command, which
34
+ **false-positives legitimate commands** that use `||` for control flow rather than exit-code
35
+ laundering — e.g. `test -d node_modules || npm ci`, `command -v shellcheck || npm i -g shellcheck`.
36
+ It errs toward blocking, which is acceptable for a bar-raiser but not something to keep
37
+ extending.
38
+
39
+ ## Decision
40
+
41
+ **Freeze the local shell-parsing heuristics as feature-complete bar-raisers.** Specifically:
42
+
43
+ 1. **No new evasion-pattern rules** are added to `config-protection.js`'s redirect/
44
+ interpreter/copy detection or to `hasLaunderingOperator`. The current rule sets are the
45
+ frozen surface. They stand as documented, intentionally-incomplete local bar-raisers.
46
+
47
+ 2. **New enforcement concerns route to the external anchor**, not to new local regex:
48
+ - a new way to launder a verification result → strengthen the CI **`trust-reconcile`**
49
+ reconciliation and add a case to the required **anti-gaming suite**
50
+ (`evals/ci/antigaming-suite.sh`);
51
+ - a new self-tamper / kill-switch vector → it is already defended structurally by Layer 2
52
+ (CI does not trust the agent's files or environment) and Layer 4 (CODEOWNERS + required
53
+ checks). Encode the *regression test* there, not another local path matcher.
54
+
55
+ 3. **Still allowed** under the freeze: correctness/bug fixes that do **not** expand the
56
+ pattern surface (e.g. removing a clear false-positive, consolidating duplicated copies as
57
+ in ops#20), and changes required to keep the existing rules working. The freeze targets
58
+ *scope growth*, not maintenance.
59
+
60
+ The decisive line — already the consistent finding of ADR 0017 — is that a local control
61
+ running inside the agent's environment cannot be made airtight by adding rules; effort spent
62
+ making the bar-raiser taller has sharply diminishing returns once the external anchor exists.
63
+
64
+ ## Accepted limitations (documented, not closed)
65
+
66
+ These are now **accepted properties** of the frozen Layer 1, with the CI anchor as the
67
+ authoritative backstop:
68
+
69
+ - **`config-protection.js` — incomplete coverage.** Runtime-constructed protected paths
70
+ (`homedir() + '/.bashrc'`), base64/multi-step path assembly, interpreters outside the
71
+ listed set (ruby, php, …), `rsync`/`dd`/process-substitution writes, and here-docs are
72
+ **not** caught. *Backstop:* Layer 2 does not trust those files; Layer 4 + human review.
73
+ - **`hasLaunderingOperator` — blanket `||` over-blocks.** A legitimate verification command
74
+ that uses `||` for control flow is flagged as laundering. *Backstop / workaround:* split
75
+ the conditional out of the single claimed-verify command; CI reconciles the real result
76
+ regardless. We keep the blanket rule (fail-toward-blocking) rather than enumerate "safe"
77
+ `||` forms — enumeration is exactly the churn this ADR freezes.
78
+
79
+ ## Consequences
80
+
81
+ - The adversarial-hardening loop is declared **converged at the local layer**: ongoing trust
82
+ investment goes to the external anchor (re-run freshness, reconciliation breadth, the
83
+ required suite, activation of `Trust Reconcile` as a no-bypass check — ADR 0017 §Activation,
84
+ issue #225), not to more local string parsing.
85
+ - Reviewers can decline new "Round N" local-heuristic PRs by pointing here: the bar-raiser is
86
+ frozen by decision, and the gap belongs in the CI anchor.
87
+ - No behavior change ships with this ADR. The heuristics keep doing exactly what they do; we
88
+ stop growing them and we write down why.
89
+
90
+ ## Related
91
+
92
+ ADR 0017 (anti-gaming trust security model — the layered defense this freezes the local
93
+ layer of), ADR 0016 (three-hard-boundary model), ADR 0010 (workflow trust state as a hachure
94
+ bundle). Implements ops#21. Consolidation of the frozen helpers: ops#20
95
+ (`scripts/lib/command-log-chain.js`).
@@ -10,9 +10,10 @@ This is the canonical developer-facing map for the Flow Agents repository. Use i
10
10
 
11
11
  - Edit canonical source in the repo root areas listed below, then regenerate derived output with the documented commands.
12
12
  - Do not edit `dist/`, `build/`, or `_site/` by hand. They are generated from tracked source.
13
- - Do not commit local runtime state from `.flow-agents/<slug>/`, `.codex/`, `.claude/`, `.omx/`, `.promptfoo/`, `.telemetry/`, `.surface/`, `.veritas/`, or tool caches.
13
+ - Do not commit local runtime state from `.flow-agents/<slug>/`, `.kontourai/`, `.codex/`, `.claude/`, `.omx/`, `.promptfoo/`, `.telemetry/`, `.surface/`, generated `.veritas/` output, or tool caches; intentionally tracked `.veritas/` governance/config remains durable source.
14
14
  - Runtime workflow artifacts stay local and ignored; promote reviewable or durable outcomes to docs, source, schemas, or provider records before merging to `main`.
15
15
  - Treat generated exports and installed runtime config as products of `packaging/manifest.json`, `src/tools/build-universal-bundles.ts`, `scripts/install-*.sh`, and the source directories they copy.
16
+ - Use `.kontourai/` for non-durable local or generated Kontour workspace state. Keep durable tracked files in intuitive product-owned paths such as `.veritas/`, `.flow/`, `.agents/`, `.claude/commands`, and `docs/`; existing transition ignores remain during migration.
16
17
 
17
18
  ## Target Layout
18
19
 
@@ -32,7 +33,7 @@ This is the canonical developer-facing map for the Flow Agents repository. Use i
32
33
  docs/ # durable docs and GitHub Pages source
33
34
  integrations/ # optional external integration config
34
35
  dist/ build/ _site/ # generated output; ignored
35
- .flow-agents/ .codex/ .claude/ ... # local runtime state; ignored by default
36
+ .flow-agents/ .kontourai/ .codex/ .claude/ ... # local runtime state; ignored by default
36
37
  ```
37
38
 
38
39
  ## Top-Level Inventory
@@ -40,11 +41,12 @@ This is the canonical developer-facing map for the Flow Agents repository. Use i
40
41
  | Path | Classification | Source of truth | Generated or runtime policy | Safe cleanup rule |
41
42
  | --- | --- | --- | --- | --- |
42
43
  | `.flow-agents/` | runtime state | Workflow tools write local session artifacts. | Ignored. | Do not commit task runtime roots; promote durable decisions to docs, source, schemas, or providers before merge. |
44
+ | `.kontourai/` | local/generated workspace state | Local Kontour tools and developer workflows. | Ignored. | Safe local cleanup when no active workflow needs it; durable source and decisions stay in product-owned tracked paths. |
43
45
  | `.claude/` | installed runtime config | Generated bundle or local runtime install. | Ignored. | Reinstall from `dist/claude-code/` instead of editing as source. |
44
46
  | `.codex/` | installed runtime config | Generated bundle or local runtime install. | Ignored. | Reinstall from `dist/codex/` or `scripts/install-codex-home.sh`; do not treat local hooks as canonical. |
45
47
  | `.githooks/` | canonical repo tooling | Tracked repository hook scripts. | Source, not runtime agent hooks. | Keep compatible with `npm run setup:repo-hooks` and `npm run validate:repo-hooks --`. |
46
48
  | `.github/` | canonical CI config | GitHub workflow files. | Source. | Preserve workflow command names and artifact expectations. |
47
- | `.ai/`, `.omx/`, `.promptfoo/`, `.surface/`, `.telemetry/`, `.veritas/` | runtime, cache, or integration output | Local tools and optional integrations. | Ignored runtime state. | Clean locally when not needed; promote only stable integration config under `integrations/` or durable docs. |
49
+ | `.ai/`, `.omx/`, `.promptfoo/`, `.surface/`, `.telemetry/`, `.veritas/` | runtime, cache, or integration output | Local tools, optional integrations, and generated Veritas output. | Ignored runtime state, except intentionally tracked Veritas governance/config. | Clean locally when not needed; promote only stable integration config under `integrations/` or durable docs. |
48
50
  | `.venv/`, `node_modules/`, `test-results/`, `__pycache__/` | dependency/cache output | Package managers and test tools. | Ignored. | Safe local cleanup; recreate with normal install or test commands. |
49
51
  | `_site/` | generated docs output | Built from `docs/`. | Ignored. | Recreate with docs preview/build tooling. |
50
52
  | `agent-cards/` | canonical source | Discovery metadata for routable agents. | Exported into runtime bundles. | Do not delete without checking bundle manifests and evals. |
@@ -115,6 +117,8 @@ The package requires Node `>=22`, and GitHub Actions runs CI on Node 22. Keep `@
115
117
 
116
118
  `.flow-agents/<slug>/` is workflow working memory. Keep plans, sidecars, evidence, and handoffs there while work is active. Promote stable outcomes into `docs/`, schemas, source, or provider records before final acceptance.
117
119
 
120
+ Across Kontour repos, `.kontourai/` is the simple ignored home for non-durable local/generated workspace state. It is not a durable source root. Keep durable governance, Flow config, agent source, command source, and documentation in the product-owned tracked paths where developers expect them, including `.veritas/`, `.flow/`, `.agents/`, `.claude/commands`, and `docs/`. Existing transition ignores such as `.flow-agents/`, `.kontour/`, `.surface/`, `.veritas/evidence/`, `.flow/runs/`, and `.telemetry/` remain in place during migration.
121
+
118
122
  `evals/fixtures/` ownership is tracked in [Fixture Ownership](fixture-ownership.md).
119
123
  Do not delete or add fixture directories without updating that inventory and the
120
124
  owning eval evidence.
@@ -630,14 +630,14 @@ fi
630
630
  echo ""
631
631
  echo "--- opencode Plugin Hook Chain (end-to-end telemetry persistence) ---"
632
632
  # Execute the REAL generated plugin module under node, invoke its handlers,
633
- # and assert telemetry events persist inside the workspace .telemetry/ —
633
+ # and assert telemetry events persist inside the workspace .kontourai/telemetry/ —
634
634
  # not the workspace PARENT. Pins three live-smoke findings (2026-06-11):
635
635
  # 1. spawning process.execPath fails under non-node hosts (NODE_BIN guard)
636
636
  # 2. empty stdin makes the telemetry pipeline silently skip the emit
637
637
  # 3. TELEMETRY_DATA_DIR escaping to the workspace parent (../../.. depth bug)
638
638
  CHAIN_WS="$TMPDIR_EVAL/plugin-chain-opencode"
639
639
  (cd "$ROOT_DIR/dist/opencode" && bash install.sh "$CHAIN_WS" >/dev/null 2>&1) || true
640
- rm -rf "$CHAIN_WS/.telemetry" "$TMPDIR_EVAL/.telemetry"
640
+ rm -rf "$CHAIN_WS/.kontourai/telemetry" "$CHAIN_WS/.telemetry" "$TMPDIR_EVAL/.kontourai" "$TMPDIR_EVAL/.telemetry"
641
641
 
642
642
  if (cd "$CHAIN_WS" && node --input-type=module -e "
643
643
  const mod = await import('./.opencode/plugins/flow-agents.js');
@@ -653,21 +653,21 @@ fi
653
653
  # The telemetry emit is detached (disowned) and can take a few seconds to
654
654
  # land; poll rather than fixed-sleep.
655
655
  for _i in 1 2 3 4 5 6 7 8 9 10; do
656
- [[ -s "$CHAIN_WS/.telemetry/full.jsonl" ]] && break
656
+ [[ -s "$CHAIN_WS/.kontourai/telemetry/full.jsonl" ]] && break
657
657
  sleep 1
658
658
  done
659
- if [[ -s "$CHAIN_WS/.telemetry/full.jsonl" ]] && node -e "
660
- require('fs').readFileSync('$CHAIN_WS/.telemetry/full.jsonl','utf8').trim().split('\n').map(JSON.parse);
659
+ if [[ -s "$CHAIN_WS/.kontourai/telemetry/full.jsonl" ]] && node -e "
660
+ require('fs').readFileSync('$CHAIN_WS/.kontourai/telemetry/full.jsonl','utf8').trim().split('\n').map(JSON.parse);
661
661
  " 2>/dev/null; then
662
- _pass "opencode plugin: handlers persisted telemetry events in workspace .telemetry/"
662
+ _pass "opencode plugin: handlers persisted telemetry events in workspace .kontourai/telemetry/"
663
663
  else
664
- _fail "opencode plugin: no telemetry events persisted in workspace .telemetry/"
664
+ _fail "opencode plugin: no telemetry events persisted in workspace .kontourai/telemetry/"
665
665
  fi
666
666
 
667
- if [[ ! -e "$TMPDIR_EVAL/.telemetry" ]]; then
667
+ if [[ ! -e "$TMPDIR_EVAL/.kontourai/telemetry" && ! -e "$TMPDIR_EVAL/.telemetry" ]]; then
668
668
  _pass "opencode plugin: telemetry did not leak into the workspace parent directory"
669
669
  else
670
- _fail "opencode plugin: telemetry leaked into workspace parent (.telemetry escape)"
670
+ _fail "opencode plugin: telemetry leaked into workspace parent"
671
671
  fi
672
672
 
673
673
  echo ""
@@ -802,7 +802,9 @@ cp "$ROOT/scripts/lib/command-log-chain.js" "$ISO_LIBDIR/"
802
802
  # Create isolated node context that can't find @kontourai/surface
803
803
  ISO_DIR="$TMP/surface-iso"
804
804
  mkdir -p "$ISO_DIR/repo/.flow-agents/surftest"
805
+ mkdir -p "$ISO_DIR/lib"
805
806
  cp "$GATE" "$ISO_DIR/stop-goal-fit.js"
807
+ cp "$ROOT/scripts/hooks/lib/local-artifact-paths.js" "$ISO_DIR/lib/"
806
808
  printf '# Repo\n' > "$ISO_DIR/repo/AGENTS.md"
807
809
  # Non-terminal session (execution phase, in_progress status)
808
810
  printf '%s' '{"schema_version":"1.0","task_slug":"surftest","status":"in_progress","phase":"execution","updated_at":"2026-06-27T00:00:00Z","next_action":{"status":"in_progress","summary":"running"}}' \
@@ -846,7 +848,9 @@ echo "--- AC3.1b: Low-impact-only bundle with unavailable surface → NOT blocke
846
848
 
847
849
  ISO2_DIR="$TMP/surface-iso2"
848
850
  mkdir -p "$ISO2_DIR/repo/.flow-agents/lowtest"
851
+ mkdir -p "$ISO2_DIR/lib"
849
852
  cp "$GATE" "$ISO2_DIR/stop-goal-fit.js"
853
+ cp "$ROOT/scripts/hooks/lib/local-artifact-paths.js" "$ISO2_DIR/lib/"
850
854
  printf '# Repo\n' > "$ISO2_DIR/repo/AGENTS.md"
851
855
  printf '%s' '{"schema_version":"1.0","task_slug":"lowtest","status":"in_progress","phase":"execution","updated_at":"2026-06-27T00:00:00Z","next_action":{"status":"in_progress","summary":"running"}}' \
852
856
  > "$ISO2_DIR/repo/.flow-agents/lowtest/state.json"
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5
+ TMPDIR_EVAL="$(mktemp -d)"
6
+ errors=0
7
+
8
+ cleanup() { rm -rf "$TMPDIR_EVAL"; }
9
+ trap cleanup EXIT
10
+
11
+ _pass() { echo " ✓ $1"; }
12
+ _fail() { echo " ✗ $1"; errors=$((errors + 1)); }
13
+
14
+ REPO="$TMPDIR_EVAL/repo"
15
+ mkdir -p \
16
+ "$REPO/.telemetry" \
17
+ "$REPO/.flow/runs/run-1" \
18
+ "$REPO/.surface/runs/run-2" \
19
+ "$REPO/.veritas/evidence" \
20
+ "$REPO/.veritas/repo-standards" \
21
+ "$REPO/.flow/definitions" \
22
+ "$REPO/.flow-agents/task"
23
+
24
+ printf '%s\n' '{"event":1}' > "$REPO/.telemetry/full.jsonl"
25
+ printf '%s\n' '{"run":1}' > "$REPO/.flow/runs/run-1/state.json"
26
+ printf '%s\n' '{"surface":1}' > "$REPO/.surface/runs/run-2/latest.json"
27
+ printf '%s\n' '{"evidence":1}' > "$REPO/.veritas/evidence/e.json"
28
+ printf '%s\n' '{"durable":1}' > "$REPO/.veritas/repo-map.json"
29
+ printf '%s\n' '{"standard":1}' > "$REPO/.veritas/repo-standards/std.json"
30
+ printf '%s\n' '{"definition":1}' > "$REPO/.flow/definitions/main.json"
31
+ printf '%s\n' '{"state":1}' > "$REPO/.flow-agents/task/state.json"
32
+ ln -s "$REPO/.telemetry/full.jsonl" "$REPO/.telemetry/link.jsonl"
33
+
34
+ if node "$ROOT/scripts/migrate-local-artifacts.mjs" --repo "$REPO" --json > "$TMPDIR_EVAL/dry.json"; then
35
+ if [[ ! -e "$REPO/.kontourai/telemetry/full.jsonl" ]] \
36
+ && node - "$TMPDIR_EVAL/dry.json" <<'NODE'
37
+ const fs = require("node:fs");
38
+ const data = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
39
+ if (!data.summary.dry_run || data.summary.applied) throw new Error("expected dry-run");
40
+ const text = JSON.stringify(data);
41
+ for (const needle of [".telemetry/full.jsonl", ".flow/runs/run-1/state.json", ".surface/runs/run-2/latest.json", ".veritas/evidence/e.json"]) {
42
+ if (!text.includes(needle)) throw new Error(`missing copy plan ${needle}`);
43
+ }
44
+ for (const needle of [".veritas/repo-map.json", ".veritas/repo-standards", ".flow/definitions", ".flow-agents"]) {
45
+ if (!text.includes(needle)) throw new Error(`missing durable skip ${needle}`);
46
+ }
47
+ if (!text.includes("source-is-symlink")) throw new Error("missing symlink skip");
48
+ NODE
49
+ then
50
+ _pass "migration script defaults to dry-run and reports generated copies plus durable skips"
51
+ else
52
+ _fail "migration dry-run output was wrong"
53
+ fi
54
+ else
55
+ _fail "migration dry-run command failed"
56
+ fi
57
+
58
+ if node "$ROOT/scripts/migrate-local-artifacts.mjs" --repo "$REPO" --apply --include-flow-agents > "$TMPDIR_EVAL/apply.out"; then
59
+ if [[ -f "$REPO/.kontourai/telemetry/full.jsonl" ]] \
60
+ && [[ -f "$REPO/.kontourai/flow/runs/run-1/state.json" ]] \
61
+ && [[ -f "$REPO/.kontourai/surface/runs/run-2/latest.json" ]] \
62
+ && [[ -f "$REPO/.kontourai/veritas/evidence/e.json" ]] \
63
+ && [[ -f "$REPO/.kontourai/flow-agents/task/state.json" ]] \
64
+ && [[ -f "$REPO/.telemetry/full.jsonl" ]] \
65
+ && [[ -f "$REPO/.flow-agents/task/state.json" ]] \
66
+ && [[ ! -e "$REPO/.kontourai/veritas/repo-map.json" ]] \
67
+ && [[ ! -e "$REPO/.kontourai/flow/definitions/main.json" ]]; then
68
+ _pass "migration apply copies only generated allowlisted paths without deleting old files"
69
+ else
70
+ _fail "migration apply copied wrong files or deleted old files"
71
+ fi
72
+ else
73
+ _fail "migration apply command failed"
74
+ fi
75
+
76
+ TARGET_SYMLINK_WORKSPACE="$TMPDIR_EVAL/target-symlink-workspace"
77
+ TARGET_SYMLINK_REPO="$TARGET_SYMLINK_WORKSPACE/repo"
78
+ OUTSIDE="$TMPDIR_EVAL/outside.txt"
79
+ mkdir -p "$TARGET_SYMLINK_REPO/.telemetry" "$TARGET_SYMLINK_REPO/.kontourai/telemetry"
80
+ printf '%s\n' '{"safe":true}' > "$TARGET_SYMLINK_REPO/.telemetry/full.jsonl"
81
+ printf '%s\n' 'outside-original' > "$OUTSIDE"
82
+ ln -s "$OUTSIDE" "$TARGET_SYMLINK_REPO/.kontourai/telemetry/full.jsonl"
83
+
84
+ if node "$ROOT/scripts/migrate-local-artifacts.mjs" --repo "$TARGET_SYMLINK_REPO" --apply --force --json > "$TMPDIR_EVAL/target-symlink.json"; then
85
+ if [[ "$(cat "$OUTSIDE")" == "outside-original" ]] \
86
+ && node - "$TMPDIR_EVAL/target-symlink.json" <<'NODE'
87
+ const fs = require("node:fs");
88
+ const data = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
89
+ const text = JSON.stringify(data);
90
+ if (!text.includes("target-is-symlink")) throw new Error("missing target symlink skip");
91
+ if (data.summary.copies !== 0) throw new Error(`expected no copies, got ${data.summary.copies}`);
92
+ NODE
93
+ then
94
+ _pass "migration apply --force refuses target symlinks"
95
+ else
96
+ _fail "migration apply --force followed or failed to report target symlink"
97
+ fi
98
+ else
99
+ _fail "migration apply --force target symlink command failed"
100
+ fi
101
+
102
+ exit "$errors"
@@ -37,6 +37,40 @@ VALIDATOR="validate-workflow-artifacts"
37
37
  ARTIFACT_DIR="$TMPDIR_EVAL/repo/.flow-agents/auto-sidecars"
38
38
  mkdir -p "$ARTIFACT_DIR"
39
39
 
40
+ DEFAULT_ROOT_REPO="$TMPDIR_EVAL/default-root-repo"
41
+ mkdir -p "$DEFAULT_ROOT_REPO"
42
+ if (cd "$DEFAULT_ROOT_REPO" && flow_agents_node "$WRITER" ensure-session \
43
+ --task-slug default-root \
44
+ --title "Default Root" \
45
+ --summary "Default root should use the Kontour runtime artifact home." \
46
+ --criterion "Default root exists" \
47
+ --timestamp "2026-05-09T00:00:00Z" >"$TMPDIR_EVAL/default-root.out" 2>"$TMPDIR_EVAL/default-root.err"); then
48
+ if [[ -f "$DEFAULT_ROOT_REPO/.kontourai/flow-agents/default-root/state.json" ]] \
49
+ && [[ -f "$DEFAULT_ROOT_REPO/.kontourai/flow-agents/current.json" ]] \
50
+ && [[ ! -e "$DEFAULT_ROOT_REPO/.flow-agents/default-root/state.json" ]]; then
51
+ _pass "sidecar writer defaults new sessions to .kontourai/flow-agents"
52
+ else
53
+ _fail "sidecar writer default root did not use .kontourai/flow-agents"
54
+ fi
55
+ else
56
+ _fail "sidecar writer default-root ensure-session failed: $(cat "$TMPDIR_EVAL/default-root.out" "$TMPDIR_EVAL/default-root.err")"
57
+ fi
58
+
59
+ OLD_ROOT_REPO="$TMPDIR_EVAL/old-root-repo"
60
+ mkdir -p "$OLD_ROOT_REPO/.flow-agents/old-session"
61
+ cat > "$OLD_ROOT_REPO/.flow-agents/current.json" <<'JSON'
62
+ {"schema_version":"1.0","active_slug":"old-session","artifact_dir":"old-session"}
63
+ JSON
64
+ cat > "$OLD_ROOT_REPO/.flow-agents/old-session/state.json" <<'JSON'
65
+ {"schema_version":"1.0","task_slug":"old-session","status":"planned","phase":"planning","next_action":{"status":"continue","summary":"continue"}}
66
+ JSON
67
+ if (cd "$OLD_ROOT_REPO" && flow_agents_node "$WRITER" current --format slug >"$TMPDIR_EVAL/old-current.out" 2>"$TMPDIR_EVAL/old-current.err") \
68
+ && [[ "$(cat "$TMPDIR_EVAL/old-current.out")" == "old-session" ]]; then
69
+ _pass "sidecar writer reads legacy .flow-agents current session when no new root exists"
70
+ else
71
+ _fail "sidecar writer did not read legacy current session: $(cat "$TMPDIR_EVAL/old-current.out" "$TMPDIR_EVAL/old-current.err")"
72
+ fi
73
+
40
74
  SESSION_ROOT="$TMPDIR_EVAL/repo/.flow-agents"
41
75
  if flow_agents_node "$WRITER" ensure-session \
42
76
  --artifact-root "$SESSION_ROOT" \
package/evals/run.sh CHANGED
@@ -140,6 +140,8 @@ run_static() {
140
140
  bash "$EVAL_DIR/static/test_console_presets.sh" || result=1
141
141
  echo ""
142
142
  bash "$EVAL_DIR/static/test_repo_hooks.sh" || result=1
143
+ echo ""
144
+ bash "$EVAL_DIR/static/test_unit_helpers.sh" || result=1
143
145
  return $result
144
146
  }
145
147
 
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # test_unit_helpers.sh — Layer 1: TypeScript-layer unit tests for the PURE
3
+ # workflow-sidecar helpers (ops#22).
4
+ #
5
+ # Historically every assertion covering workflow-sidecar was black-box bash driving
6
+ # the CLI. These node:test units exercise the pure projection/validation helpers
7
+ # directly against the built JS — fast, deterministic, isolated — and complement
8
+ # (do not replace) the bash evals.
9
+ set -uo pipefail
10
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
11
+ cd "$ROOT_DIR"
12
+
13
+ echo "── TS pure-helper unit tests (node --test) ──"
14
+
15
+ # The units import the build output. CI builds before evals; build here too when run
16
+ # standalone so the test is self-sufficient.
17
+ if [[ ! -f build/src/cli/workflow-sidecar.js ]]; then
18
+ npm run build --silent
19
+ fi
20
+
21
+ if node --test src/cli/*.test.mjs; then
22
+ echo " PASS: workflow-sidecar pure-helper unit tests"
23
+ else
24
+ echo " FAIL: workflow-sidecar pure-helper unit tests"
25
+ exit 1
26
+ fi
@@ -41,6 +41,9 @@
41
41
  "conformance:self": "node ../../packaging/conformance/run-conformance.js --self",
42
42
  "conformance": "node ../../packaging/conformance/run-conformance.js --adapter-cmd \"node bin/conformance-shim.mjs\" --level L2"
43
43
  },
44
+ "dependencies": {
45
+ "@kontourai/console-telemetry": "^0.1.0"
46
+ },
44
47
  "peerDependencies": {
45
48
  "@strands-agents/sdk": ">=1.0.0"
46
49
  },
@@ -14,6 +14,11 @@ import fs from "node:fs";
14
14
  import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { randomUUID } from "node:crypto";
17
+ import {
18
+ costForModel as pkgCostForModel,
19
+ currentPricingVersion as pkgPricingVersion,
20
+ setRegistry as setPkgRegistry
21
+ } from "@kontourai/console-telemetry";
17
22
 
18
23
  // ESM has no __dirname; derive it (this package is "type":"module").
19
24
  const __filename = fileURLToPath(import.meta.url);
@@ -308,7 +313,12 @@ export class TelemetrySink {
308
313
  }
309
314
 
310
315
  // ---------------------------------------------------------------------------
311
- // Usage / cost — mirror of scripts/telemetry/pricing.json (per 1M tokens, USD)
316
+ // Usage / cost — pricing math + registry shape come from the shared contract
317
+ // package @kontourai/console-telemetry (single source). This sink only loads
318
+ // the flow-agents authored registry (scripts/telemetry/pricing.json) and feeds
319
+ // it to the package so cost prices against pricing.json rather than the
320
+ // package's bundled fallback. Tokens are exact regardless; the console
321
+ // recomputes cost authoritatively.
312
322
  // ---------------------------------------------------------------------------
313
323
 
314
324
  export interface TokenCounts {
@@ -331,37 +341,15 @@ interface NormalizedTokens {
331
341
  cacheRead: number;
332
342
  }
333
343
 
334
- // Pricing is read from the single-source registry (scripts/telemetry/pricing.json),
335
- // never hand-maintained here. Resolution: TELEMETRY_PRICING_FILE /
336
- // FLOW_AGENTS_PRICING_FILE env path, else the repo-relative registry, else a
337
- // minimal fallback. Tokens are exact regardless; the console recomputes cost
338
- // authoritatively, so a missing file only degrades the sink's stamped estimate.
339
- interface PricingVersionBlock {
340
- cache_multipliers: { write_5m: number; write_1h: number; read: number };
341
- models: Record<string, { input: number; output: number }>;
342
- default: { input: number; output: number };
343
- zero_cost_models: string[];
344
- }
345
- interface PricingRegistry {
346
- current_version: string;
347
- versions: Record<string, PricingVersionBlock>;
348
- }
349
-
350
- const FALLBACK_REGISTRY: PricingRegistry = {
351
- current_version: "fallback",
352
- versions: {
353
- fallback: {
354
- cache_multipliers: { write_5m: 1.25, write_1h: 2.0, read: 0.1 },
355
- models: {},
356
- default: { input: 5.0, output: 25.0 },
357
- zero_cost_models: ["<synthetic>", "synthetic", "unknown", ""]
358
- }
359
- }
360
- };
361
-
362
- let cachedRegistry: PricingRegistry | null = null;
363
- function loadRegistry(): PricingRegistry {
364
- if (cachedRegistry) return cachedRegistry;
344
+ // Feed the package the flow-agents authored registry when present (its single
345
+ // source of truth). Resolution: TELEMETRY_PRICING_FILE / FLOW_AGENTS_PRICING_FILE
346
+ // env path, else the repo-relative registry. Runs once; on failure the package
347
+ // keeps its bundled fallback. The console recomputes cost authoritatively, so a
348
+ // missing file only degrades the sink's stamped estimate.
349
+ let registryLoaded = false;
350
+ function ensureRegistry(): void {
351
+ if (registryLoaded) return;
352
+ registryLoaded = true;
365
353
  const candidates = [
366
354
  process.env.TELEMETRY_PRICING_FILE,
367
355
  process.env.FLOW_AGENTS_PRICING_FILE,
@@ -372,19 +360,18 @@ function loadRegistry(): PricingRegistry {
372
360
  try {
373
361
  const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
374
362
  if (parsed && typeof parsed.current_version === "string" && parsed.versions) {
375
- cachedRegistry = parsed as PricingRegistry;
376
- return cachedRegistry;
363
+ setPkgRegistry(parsed);
364
+ return;
377
365
  }
378
366
  } catch {
379
367
  // try next candidate
380
368
  }
381
369
  }
382
- cachedRegistry = FALLBACK_REGISTRY;
383
- return cachedRegistry;
384
370
  }
385
371
 
386
372
  function pricingVersion(): string {
387
- return loadRegistry().current_version;
373
+ ensureRegistry();
374
+ return pkgPricingVersion();
388
375
  }
389
376
 
390
377
  function num(value: number | undefined): number {
@@ -405,17 +392,11 @@ function normalizeTokens(tokens: TokenCounts): NormalizedTokens {
405
392
  }
406
393
 
407
394
  function costForModel(model: string | undefined, tokens: NormalizedTokens): number {
408
- const registry = loadRegistry();
409
- const block = registry.versions[registry.current_version] ?? FALLBACK_REGISTRY.versions.fallback;
410
- const key = (model ?? "").trim();
411
- if (block.zero_cost_models.includes(key)) return 0;
412
- const rate = block.models[key] ?? block.default;
413
- const cm = block.cache_multipliers;
414
- return round6(
415
- (tokens.input * rate.input +
416
- tokens.output * rate.output +
417
- tokens.cacheCreation * rate.input * cm.write_5m +
418
- tokens.cacheRead * rate.input * cm.read) /
419
- 1_000_000
420
- );
395
+ ensureRegistry();
396
+ return pkgCostForModel(model, {
397
+ inputTokens: tokens.input,
398
+ outputTokens: tokens.output,
399
+ cacheCreationInputTokens: tokens.cacheCreation,
400
+ cacheReadInputTokens: tokens.cacheRead
401
+ });
421
402
  }
@@ -17,7 +17,7 @@
17
17
  * - defaultSimilarityDetector — pluggable similarity interface default (R3)
18
18
  *
19
19
  * Telemetry:
20
- * Gate events are emitted to <workspace>/.telemetry/full.jsonl using
20
+ * Gate events are emitted to <workspace>/.kontourai/telemetry/full.jsonl using
21
21
  * canonical schema v0.3.0 events (preToolUse at gate entry, postToolUse at gate exit).
22
22
  *
23
23
  * Similarity Interface (R3):
@@ -8,7 +8,7 @@
8
8
  * Zero runtime dependencies beyond Node.js built-ins.
9
9
  * Fails open: telemetry errors never block kit operations.
10
10
  *
11
- * Sink path: <workspace>/.telemetry/full.jsonl
11
+ * Sink path: <workspace>/.kontourai/telemetry/full.jsonl
12
12
  * The workspace is resolved from FLOW_AGENTS_WORKSPACE env var, falling back
13
13
  * to process.cwd().
14
14
  *
@@ -47,7 +47,7 @@ function schemaEventType(canonical) {
47
47
 
48
48
  function resolveSinkPath(workspace) {
49
49
  const ws = workspace || process.env.FLOW_AGENTS_WORKSPACE || process.cwd();
50
- return path.join(ws, ".telemetry", "full.jsonl");
50
+ return path.join(ws, ".kontourai", "telemetry", "full.jsonl");
51
51
  }
52
52
 
53
53
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/flow-agents",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
5
5
  "keywords": [
6
6
  "agents",
@@ -99,6 +99,7 @@
99
99
  "scripts": {
100
100
  "build": "tsc -p tsconfig.json",
101
101
  "typecheck": "tsc -p tsconfig.json --noEmit",
102
+ "test:unit": "npm run build --silent && node --test src/cli/*.test.mjs",
102
103
  "validate:source": "npm run build --silent && node build/src/cli.js validate-source",
103
104
  "validate:artifacts": "npm run build --silent && node build/src/cli/validate-workflow-artifacts.js",
104
105
  "context-map": "npm run build --silent && node build/src/cli.js context-map",
package/scripts/README.md CHANGED
@@ -70,6 +70,7 @@ renamed, or changes category, update the table and the validator together.
70
70
  | `lib/audit-transport.sh` | shared hook library | `evals/integration/test_hook_category_behaviors.sh`, `evals/integration/test_telemetry.sh` | Shared audit event transport functions. |
71
71
  | `lib/hook-flags.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Shared profile/disable flag parsing. |
72
72
  | `lib/liveness-read.js` | shared hook library | `evals/integration/test_session_resume_roundtrip.sh` | Shared liveness event reader + freshness check (`readLivenessEvents`, `freshHolders`); consumed by the reground hook and `workflow-sidecar liveness status`. |
73
+ | `lib/local-artifact-paths.js` | shared hook library | `evals/integration/test_migrate_local_artifacts.sh`, `evals/integration/test_workflow_sidecar_writer.sh` | Shared `.kontourai/flow-agents` artifact-root helpers with legacy `.flow-agents` read compatibility for CJS hooks. |
73
74
  | `lib/patterns.sh` | shared hook library | `evals/integration/test_hook_category_behaviors.sh`, `evals/integration/test_telemetry.sh` | Shared shell pattern constants. |
74
75
  | `lib/resolve-formatter.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Shared formatter resolution helper. |
75
76
 
@@ -40,6 +40,12 @@
40
40
  * any interpreter not in the list (ruby, php, etc.), and multiline here-docs.
41
41
  * The real anchor remains external (clean CI env + human review).
42
42
  *
43
+ * FROZEN bar-raiser (ADR 0018). The redirect / interpreter-write / cp-target detection
44
+ * is intentionally non-exhaustive and is NOT to be grown with new evasion-pattern rules.
45
+ * A newly-discovered self-tamper vector belongs in the external CI anchor (trust-reconcile
46
+ * does not trust agent-written files) and the required anti-gaming suite — not another
47
+ * local string matcher. Bug fixes that don't expand the pattern surface are still welcome.
48
+ *
43
49
  * Exit codes: 0 = allow, 2 = block
44
50
  */
45
51
 
@@ -48,6 +48,7 @@
48
48
 
49
49
  const fs = require('fs');
50
50
  const path = require('path');
51
+ const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
51
52
  const crypto = require('crypto');
52
53
 
53
54
  const MAX_STDIN = 1024 * 1024;
@@ -197,18 +198,23 @@ function latestStateDir(flowAgentsDir) {
197
198
  * to the newest-mtime state.json directory.
198
199
  */
199
200
  function resolveArtifactDir(root) {
200
- const flowAgentsDir = path.join(root, '.flow-agents');
201
- const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
202
- if (current) {
203
- const slug = current.artifact_dir || current.active_slug;
204
- if (typeof slug === 'string' && slug.trim()) {
205
- // Guard against path traversal in the slug.
206
- const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
207
- const dir = path.join(flowAgentsDir, safe);
208
- if (dir.startsWith(flowAgentsDir + path.sep) && fs.existsSync(dir)) return dir;
201
+ for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
202
+ const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
203
+ if (current) {
204
+ const slug = current.artifact_dir || current.active_slug;
205
+ if (typeof slug === 'string' && slug.trim()) {
206
+ // Guard against path traversal in the slug.
207
+ const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
208
+ const dir = path.join(flowAgentsDir, safe);
209
+ if (dir.startsWith(flowAgentsDir + path.sep) && fs.existsSync(dir)) return dir;
210
+ }
209
211
  }
210
212
  }
211
- return latestStateDir(flowAgentsDir);
213
+ for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
214
+ const latest = latestStateDir(flowAgentsDir);
215
+ if (latest) return latest;
216
+ }
217
+ return null;
212
218
  }
213
219
 
214
220
  function isCommandTool(toolName, command) {