@kontourai/flow-agents 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/publish-npm.yml +1 -1
  3. package/.github/workflows/release-please.yml +31 -0
  4. package/.github/workflows/runtime-compat.yml +118 -0
  5. package/CHANGELOG.md +38 -0
  6. package/CONTRIBUTING.md +4 -0
  7. package/README.md +58 -19
  8. package/build/src/cli/init.js +215 -5
  9. package/build/src/cli/utterance-check.js +236 -0
  10. package/build/src/cli.js +3 -0
  11. package/build/src/tools/build-universal-bundles.js +268 -0
  12. package/build/src/tools/filter-installed-packs.js +3 -0
  13. package/build/src/tools/validate-source-tree.js +6 -1
  14. package/context/scripts/telemetry/lib/config.sh +5 -1
  15. package/context/settings/flow-agents-settings.json +7 -0
  16. package/docs/agent-system-guidebook.md +4 -5
  17. package/docs/context-map.md +1 -0
  18. package/docs/index.md +46 -6
  19. package/docs/integrations/conformance.md +246 -0
  20. package/docs/integrations/framework-adapter.md +275 -0
  21. package/docs/integrations/harness-install.md +213 -0
  22. package/docs/integrations/index.md +54 -0
  23. package/docs/north-star.md +3 -3
  24. package/docs/repository-structure.md +1 -1
  25. package/docs/skills-map.md +10 -4
  26. package/docs/spec/runtime-hook-surface.md +472 -0
  27. package/docs/survey-utterance-check.md +308 -0
  28. package/docs/vision.md +45 -0
  29. package/docs/workflow-usage-guide.md +1 -1
  30. package/evals/acceptance/run.sh +4 -2
  31. package/evals/acceptance/test_opencode_harness.sh +121 -0
  32. package/evals/acceptance/test_pi_harness.sh +98 -0
  33. package/evals/integration/test_bundle_install.sh +226 -1
  34. package/evals/integration/test_bundle_lifecycle.sh +641 -0
  35. package/evals/integration/test_utterance_check.sh +518 -0
  36. package/evals/run.sh +2 -0
  37. package/evals/static/test_universal_bundles.sh +137 -2
  38. package/integrations/strands/README.md +256 -0
  39. package/integrations/strands/example.py +74 -0
  40. package/integrations/strands/flow_agents_strands/__init__.py +27 -0
  41. package/integrations/strands/flow_agents_strands/hooks.py +194 -0
  42. package/integrations/strands/flow_agents_strands/policy.py +348 -0
  43. package/integrations/strands/flow_agents_strands/steering.py +172 -0
  44. package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
  45. package/integrations/strands/pyproject.toml +38 -0
  46. package/integrations/strands/tests/__init__.py +0 -0
  47. package/integrations/strands/tests/test_hooks.py +304 -0
  48. package/integrations/strands/tests/test_policy.py +315 -0
  49. package/integrations/strands/tests/test_telemetry.py +184 -0
  50. package/integrations/strands-ts/README.md +224 -0
  51. package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
  52. package/integrations/strands-ts/package.json +53 -0
  53. package/integrations/strands-ts/src/hooks.ts +208 -0
  54. package/integrations/strands-ts/src/index.ts +22 -0
  55. package/integrations/strands-ts/src/policy.ts +345 -0
  56. package/integrations/strands-ts/src/telemetry.ts +251 -0
  57. package/integrations/strands-ts/test/test-policy.ts +322 -0
  58. package/integrations/strands-ts/test/test-telemetry.ts +226 -0
  59. package/integrations/strands-ts/tsconfig.json +20 -0
  60. package/package.json +7 -2
  61. package/packaging/conformance/README.md +142 -0
  62. package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
  63. package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
  64. package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
  65. package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
  66. package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
  67. package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
  68. package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
  69. package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
  70. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
  71. package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
  72. package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
  73. package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
  74. package/packaging/conformance/package.json +4 -0
  75. package/packaging/conformance/run-conformance.js +322 -0
  76. package/packaging/manifest.json +59 -0
  77. package/schemas/flow-agents-settings.schema.json +48 -0
  78. package/scripts/README.md +5 -0
  79. package/scripts/dogfood.js +16 -0
  80. package/scripts/hooks/opencode-hook-adapter.js +123 -0
  81. package/scripts/hooks/opencode-telemetry-hook.js +101 -0
  82. package/scripts/hooks/pi-hook-adapter.js +123 -0
  83. package/scripts/hooks/pi-telemetry-hook.js +105 -0
  84. package/scripts/hooks/run-hook.js +8 -0
  85. package/scripts/hooks/utterance-check.js +327 -0
  86. package/scripts/telemetry/lib/config.sh +5 -1
  87. package/skills/idea-to-backlog/SKILL.md +1 -1
  88. package/src/cli/init.ts +219 -6
  89. package/src/cli/utterance-check.ts +324 -0
  90. package/src/cli.ts +3 -0
  91. package/src/tools/build-universal-bundles.ts +266 -0
  92. package/src/tools/filter-installed-packs.ts +3 -0
  93. package/src/tools/validate-source-tree.ts +6 -1
  94. package/build/src/cli/docs-preview.js +0 -39
  95. package/build/src/cli/export-bookmarks.js +0 -38
  96. package/build/src/cli/import-bookmarks.js +0 -50
  97. package/build/src/cli/instinct-cli.js +0 -93
@@ -0,0 +1,251 @@
1
+ /**
2
+ * telemetry.ts — Canonical Flow Agents telemetry event builder and JSONL sink.
3
+ *
4
+ * Event taxonomy mirrors the JS telemetry hooks exactly.
5
+ * JSONL output is structurally identical to claude-telemetry-hook.js and
6
+ * codex-telemetry-hook.js output.
7
+ *
8
+ * Canonical → schema event_type mapping mirrors _CANONICAL_TO_SCHEMA from
9
+ * integrations/strands/flow_agents_strands/telemetry.py and
10
+ * scripts/telemetry/telemetry.sh schema_event_type().
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Strands TS → canonical event-name mapping
19
+ // Mirrors STRANDS_TO_CANONICAL in integrations/strands/flow_agents_strands/telemetry.py
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Maps Strands TypeScript event class names to canonical Flow Agents event names.
24
+ * This is the source-of-truth mapping for the TS adapter.
25
+ */
26
+ export const STRANDS_TO_CANONICAL: Readonly<Record<string, string>> = {
27
+ // Strands event class name → canonical Flow Agents event name
28
+ BeforeInvocationEvent: "userPromptSubmit",
29
+ AfterInvocationEvent: "stop",
30
+ BeforeToolCallEvent: "preToolUse",
31
+ AfterToolCallEvent: "postToolUse",
32
+ AgentInitializedEvent: "agentSpawn",
33
+ AfterModelCallEvent: "postToolUse", // closest analogue; no tool name
34
+ MessageAddedEvent: "userPromptSubmit",
35
+ } as const;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Canonical → schema event_type
39
+ // Mirrors _CANONICAL_TO_SCHEMA in telemetry.py and schema_event_type() in telemetry.sh
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const CANONICAL_TO_SCHEMA: Readonly<Record<string, string>> = {
43
+ agentSpawn: "session.start",
44
+ userPromptSubmit: "turn.user",
45
+ preToolUse: "tool.invoke",
46
+ permissionRequest: "tool.permission_request",
47
+ postToolUse: "tool.result",
48
+ stop: "session.end",
49
+ subagentStart: "agent.delegate",
50
+ subagentStop: "agent.delegate",
51
+ } as const;
52
+
53
+ function schemaEventType(canonical: string): string {
54
+ return CANONICAL_TO_SCHEMA[canonical] ?? "unknown";
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Tool-name normalizer — mirrors _normalize_tool_name in telemetry.py
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const TOOL_NAME_MAP: Readonly<Record<string, string>> = {
62
+ bash: "execute_bash",
63
+ execute_bash: "execute_bash",
64
+ shell: "execute_bash",
65
+ edit: "fs_write",
66
+ write: "fs_write",
67
+ fs_write: "fs_write",
68
+ apply_patch: "fs_write",
69
+ read: "fs_read",
70
+ fs_read: "fs_read",
71
+ task: "use_subagent",
72
+ agent: "use_subagent",
73
+ use_subagent: "use_subagent",
74
+ } as const;
75
+
76
+ export function normalizeToolName(name: string): string {
77
+ return TOOL_NAME_MAP[name.toLowerCase()] ?? name;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Event shape — mirrors build_base_event() in telemetry.sh
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export interface TelemetryEvent {
85
+ schema_version: string;
86
+ timestamp: string;
87
+ session_id: string;
88
+ event_id: string;
89
+ event_type: string;
90
+ agent: {
91
+ name: string;
92
+ runtime: string;
93
+ version: string;
94
+ };
95
+ hook: {
96
+ event_name: string;
97
+ runtime_session_id: string;
98
+ turn_id: string;
99
+ transcript_path: string;
100
+ model: string;
101
+ source: string;
102
+ stop_hook_active: null;
103
+ last_assistant_message: string;
104
+ raw_input: null;
105
+ };
106
+ [key: string]: unknown;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // TelemetrySink
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export interface TelemetrySinkOptions {
114
+ /** Directory or file path for JSONL telemetry output.
115
+ * Default: <workspace>/.telemetry/full.jsonl */
116
+ sinkPath?: string;
117
+ /** Root of the workspace (for resolving .telemetry/ subdir). Default: cwd */
118
+ workspace?: string;
119
+ /** Agent identifier embedded in telemetry events. Default: "strands-agent" */
120
+ agentName?: string;
121
+ /** Runtime label embedded in telemetry events. Default: "strands-ts" */
122
+ runtime?: string;
123
+ }
124
+
125
+ export const SCHEMA_VERSION = "0.3.0";
126
+ const DEFAULT_TELEMETRY_SUBDIR = ".telemetry";
127
+ const DEFAULT_FILENAME = "full.jsonl";
128
+
129
+ export class TelemetrySink {
130
+ private readonly agentName: string;
131
+ private readonly runtime: string;
132
+ private readonly logFile: string;
133
+ private _sessionId: string | null = null;
134
+
135
+ constructor(options: TelemetrySinkOptions = {}) {
136
+ this.agentName = options.agentName ?? "strands-agent";
137
+ this.runtime = options.runtime ?? "strands-ts";
138
+
139
+ const ws = options.workspace ?? process.cwd();
140
+
141
+ if (options.sinkPath) {
142
+ const p = options.sinkPath;
143
+ // If it looks like a directory (no extension), append full.jsonl
144
+ if (!path.extname(p)) {
145
+ this.logFile = path.join(p, DEFAULT_FILENAME);
146
+ } else {
147
+ this.logFile = p;
148
+ }
149
+ } else {
150
+ this.logFile = path.join(ws, DEFAULT_TELEMETRY_SUBDIR, DEFAULT_FILENAME);
151
+ }
152
+
153
+ // Ensure parent directory exists
154
+ try {
155
+ fs.mkdirSync(path.dirname(this.logFile), { recursive: true });
156
+ } catch {
157
+ // fail-open: telemetry must never block agent work
158
+ }
159
+ }
160
+
161
+ get sessionId(): string {
162
+ if (this._sessionId === null) {
163
+ this._sessionId = randomUUID();
164
+ }
165
+ return this._sessionId;
166
+ }
167
+
168
+ private buildBaseEvent(schemaEventType: string): TelemetryEvent {
169
+ return {
170
+ schema_version: SCHEMA_VERSION,
171
+ timestamp: String(Date.now()),
172
+ session_id: this.sessionId,
173
+ event_id: randomUUID(),
174
+ event_type: schemaEventType,
175
+ agent: {
176
+ name: this.agentName,
177
+ runtime: this.runtime,
178
+ version: "unknown",
179
+ },
180
+ hook: {
181
+ event_name: "",
182
+ runtime_session_id: "",
183
+ turn_id: "",
184
+ transcript_path: "",
185
+ model: "",
186
+ source: "strands-ts",
187
+ stop_hook_active: null,
188
+ last_assistant_message: "",
189
+ raw_input: null,
190
+ },
191
+ };
192
+ }
193
+
194
+ emit(canonicalEvent: string, extra?: Record<string, unknown>): TelemetryEvent {
195
+ const schemaType = schemaEventType(canonicalEvent);
196
+ const event = this.buildBaseEvent(schemaType);
197
+
198
+ // Attach hook context — hook.event_name is the canonical name
199
+ event.hook = {
200
+ ...event.hook,
201
+ event_name: canonicalEvent,
202
+ };
203
+
204
+ if (extra) {
205
+ for (const [key, value] of Object.entries(extra)) {
206
+ event[key] = value;
207
+ }
208
+ }
209
+
210
+ try {
211
+ fs.appendFileSync(this.logFile, JSON.stringify(event) + "\n", "utf8");
212
+ } catch {
213
+ // fail-open: telemetry must never block agent work
214
+ }
215
+
216
+ return event;
217
+ }
218
+
219
+ emitSessionStart(extra?: Record<string, unknown>): TelemetryEvent {
220
+ return this.emit("agentSpawn", extra);
221
+ }
222
+
223
+ emitSessionEnd(durationMs?: number): TelemetryEvent {
224
+ const durationS = durationMs != null ? durationMs / 1000 : 0;
225
+ return this.emit("stop", { session: { duration_s: durationS } });
226
+ }
227
+
228
+ emitToolInvoke(toolName: string, toolInput?: Record<string, unknown>): TelemetryEvent {
229
+ return this.emit("preToolUse", {
230
+ tool: {
231
+ name: toolName,
232
+ normalized_name: normalizeToolName(toolName),
233
+ input: toolInput ?? null,
234
+ },
235
+ });
236
+ }
237
+
238
+ emitToolResult(toolName: string, toolOutput?: unknown): TelemetryEvent {
239
+ return this.emit("postToolUse", {
240
+ tool: {
241
+ name: toolName,
242
+ normalized_name: normalizeToolName(toolName),
243
+ output: toolOutput ?? null,
244
+ },
245
+ });
246
+ }
247
+
248
+ emitUserPromptSubmit(extra?: Record<string, unknown>): TelemetryEvent {
249
+ return this.emit("userPromptSubmit", extra);
250
+ }
251
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * test-policy.ts — Tests for policy module and registry wiring.
3
+ *
4
+ * Covers:
5
+ * - Policy gate block/allow via pure-TS fallback
6
+ * - Config-protection block through THE REAL ENGINE (native import, no mocks)
7
+ * - Registry wiring with a fake registry
8
+ * - PROTECTED_FILES constant
9
+ */
10
+
11
+ import { test, describe } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { PolicyGate, PROTECTED_FILES } from "../src/policy.js";
18
+ import { FlowAgentsHooks } from "../src/hooks.js";
19
+ import type { HookRegistry, StrandsEvent } from "../src/hooks.js";
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+
24
+ // Walk up from __dirname to find the repo root (contains scripts/hooks/run-hook.js).
25
+ // Works for both source (test/) and compiled (dist/test/) layouts.
26
+ function findRepoRoot(start: string): string | null {
27
+ let current = start;
28
+ for (let i = 0; i < 10; i++) {
29
+ if (fs.existsSync(path.join(current, "scripts", "hooks", "run-hook.js"))) {
30
+ return current;
31
+ }
32
+ const parent = path.dirname(current);
33
+ if (parent === current) return null;
34
+ current = parent;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ const repoRoot = findRepoRoot(__dirname) ?? "";
40
+ const runHookPath = repoRoot ? path.join(repoRoot, "scripts", "hooks", "run-hook.js") : "";
41
+ const engineAvailableInRepo = Boolean(repoRoot) && fs.existsSync(runHookPath);
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // PROTECTED_FILES constant
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe("PROTECTED_FILES constant", () => {
48
+ test("contains expected protected file basenames", () => {
49
+ const expected = [
50
+ ".eslintrc.json",
51
+ "biome.json",
52
+ "prettier.config.js",
53
+ ".prettierrc",
54
+ "ruff.toml",
55
+ ".markdownlint.json",
56
+ ];
57
+ for (const fname of expected) {
58
+ assert.ok(PROTECTED_FILES.has(fname), `Expected PROTECTED_FILES to contain ${fname}`);
59
+ }
60
+ });
61
+
62
+ test("does not contain regular source files", () => {
63
+ assert.ok(!PROTECTED_FILES.has("package.json"));
64
+ assert.ok(!PROTECTED_FILES.has("src/main.ts"));
65
+ assert.ok(!PROTECTED_FILES.has("README.md"));
66
+ });
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // PolicyGate — pure-TS fallback (engine root nonexistent)
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("PolicyGate pure-TS fallback", () => {
74
+ const fallbackGate = new PolicyGate({
75
+ engineRoot: "/nonexistent/path/that/does/not/exist",
76
+ suppressFallbackWarning: true,
77
+ });
78
+
79
+ test("blocks write to .eslintrc.json (fallback)", () => {
80
+ const reason = fallbackGate.checkToolCall("write", { path: "/repo/.eslintrc.json" });
81
+ assert.ok(reason !== null, "Expected block reason");
82
+ assert.ok(reason.includes("BLOCKED"));
83
+ assert.ok(reason.includes(".eslintrc.json"));
84
+ });
85
+
86
+ test("allows write to regular file (fallback)", () => {
87
+ const reason = fallbackGate.checkToolCall("write", { path: "src/main.ts" });
88
+ assert.strictEqual(reason, null);
89
+ });
90
+
91
+ test("allows read on protected file (fallback - tool-name pre-filter)", () => {
92
+ const reason = fallbackGate.checkToolCall("read", { path: ".eslintrc.json" });
93
+ assert.strictEqual(reason, null);
94
+ });
95
+
96
+ test("allows write without path (fallback)", () => {
97
+ const reason = fallbackGate.checkToolCall("write", {});
98
+ assert.strictEqual(reason, null);
99
+ });
100
+
101
+ test("blocks edit to biome.json via file_path key (fallback)", () => {
102
+ const reason = fallbackGate.checkToolCall("edit", { file_path: "biome.json" });
103
+ assert.ok(reason !== null);
104
+ assert.ok(reason.includes("biome.json"));
105
+ });
106
+
107
+ test("blocks all canonical protected files (fallback)", () => {
108
+ for (const fname of PROTECTED_FILES) {
109
+ const reason = fallbackGate.checkToolCall("write", { path: `/repo/${fname}` });
110
+ assert.ok(reason !== null, `Expected ${fname} to be blocked`);
111
+ assert.ok(reason.includes("BLOCKED"), `Block reason for ${fname} missing BLOCKED`);
112
+ }
113
+ });
114
+ });
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // PolicyGate — custom protected files
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("PolicyGate custom protected files", () => {
121
+ test("custom set blocks custom file, not built-in files", () => {
122
+ const customGate = new PolicyGate({
123
+ customProtectedFiles: new Set(["pyproject.toml"]),
124
+ });
125
+ const blocked = customGate.checkToolCall("write", { path: "pyproject.toml" });
126
+ assert.ok(blocked !== null, "Expected custom set to block pyproject.toml");
127
+
128
+ const allowed = customGate.checkToolCall("write", { path: ".eslintrc.json" });
129
+ assert.strictEqual(allowed, null, "Custom set should not block .eslintrc.json");
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // PolicyGate — REAL engine via native import (no mocks, no subprocess)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("PolicyGate via real native engine", () => {
138
+ test("engineAvailable is true when repo engine is available", () => {
139
+ if (!engineAvailableInRepo) return;
140
+ const gate = new PolicyGate({ engineRoot: repoRoot });
141
+ assert.ok(gate.engineAvailable, "engineAvailable should be true in repo context");
142
+ });
143
+
144
+ test("engineAvailable is false when engineRoot is invalid", () => {
145
+ const gate = new PolicyGate({
146
+ engineRoot: "/nonexistent/path",
147
+ suppressFallbackWarning: true,
148
+ });
149
+ assert.ok(!gate.engineAvailable, "engineAvailable should be false with invalid path");
150
+ });
151
+
152
+ test("blocks write to .eslintrc.json via native engine", () => {
153
+ if (!engineAvailableInRepo) return;
154
+ const gate = new PolicyGate({ engineRoot: repoRoot });
155
+ const reason = gate.checkToolCall("write", { path: "/repo/.eslintrc.json" });
156
+ assert.ok(reason !== null, "Native engine should block .eslintrc.json write");
157
+ assert.ok(reason.includes("BLOCKED"), "Block reason should contain BLOCKED");
158
+ assert.ok(reason.includes(".eslintrc.json"), "Block reason should mention file");
159
+ });
160
+
161
+ test("allows write to src/main.ts via native engine", () => {
162
+ if (!engineAvailableInRepo) return;
163
+ const gate = new PolicyGate({ engineRoot: repoRoot });
164
+ const reason = gate.checkToolCall("write", { path: "src/main.ts" });
165
+ assert.strictEqual(reason, null, "Native engine should allow src/main.ts");
166
+ });
167
+
168
+ test("allows read on .eslintrc.json (tool-name pre-filter, no engine call)", () => {
169
+ if (!engineAvailableInRepo) return;
170
+ const gate = new PolicyGate({ engineRoot: repoRoot });
171
+ const reason = gate.checkToolCall("read", { path: ".eslintrc.json" });
172
+ assert.strictEqual(reason, null, "Read tool should never be blocked");
173
+ });
174
+
175
+ test("blocks edit to biome.json via file_path key via native engine", () => {
176
+ if (!engineAvailableInRepo) return;
177
+ const gate = new PolicyGate({ engineRoot: repoRoot });
178
+ const reason = gate.checkToolCall("edit", { file_path: "biome.json" });
179
+ assert.ok(reason !== null, "Should block biome.json edit");
180
+ assert.ok(reason.includes("biome.json"));
181
+ });
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // FlowAgentsHooks — registry wiring with a fake registry
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe("FlowAgentsHooks registry wiring (fake registry)", () => {
189
+ test("registerHooks calls addCallback for expected events or throws ImportError", () => {
190
+ const hooks = new FlowAgentsHooks({ engineRoot: repoRoot });
191
+
192
+ const registered: Array<{ eventClass: unknown; callback: unknown }> = [];
193
+ const fakeRegistry: HookRegistry = {
194
+ addCallback(eventClass, callback) {
195
+ registered.push({ eventClass, callback });
196
+ },
197
+ };
198
+
199
+ // Without strands-agents installed this throws; both outcomes are acceptable.
200
+ try {
201
+ hooks.registerHooks(fakeRegistry);
202
+ // If SDK is installed, we should have callbacks
203
+ assert.ok(registered.length >= 1, "Expected at least one addCallback call");
204
+ } catch (err) {
205
+ assert.ok(err instanceof Error);
206
+ const msg = (err as Error).message;
207
+ assert.ok(
208
+ msg.includes("strands-agents") || msg.includes("Cannot find module"),
209
+ `Expected error about strands-agents, got: ${msg}`
210
+ );
211
+ }
212
+ });
213
+
214
+ test("onBeforeInvocation emits turn.user event", () => {
215
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fa-ts-reg-"));
216
+ try {
217
+ const hooks = new FlowAgentsHooks({ sinkPath: tmpDir, engineRoot: repoRoot });
218
+ hooks.onBeforeInvocation({});
219
+ const events = fs
220
+ .readFileSync(path.join(tmpDir, "full.jsonl"), "utf8")
221
+ .trim()
222
+ .split("\n")
223
+ .filter(Boolean)
224
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
225
+ assert.ok(events.some((e) => e.event_type === "turn.user"), "Expected turn.user event");
226
+ } finally {
227
+ fs.rmSync(tmpDir, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test("onAfterInvocation emits session.end event", () => {
232
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fa-ts-reg2-"));
233
+ try {
234
+ const hooks = new FlowAgentsHooks({ sinkPath: tmpDir, engineRoot: repoRoot });
235
+ hooks.onAfterInvocation({});
236
+ const events = fs
237
+ .readFileSync(path.join(tmpDir, "full.jsonl"), "utf8")
238
+ .trim()
239
+ .split("\n")
240
+ .filter(Boolean)
241
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
242
+ assert.ok(events.some((e) => e.event_type === "session.end"), "Expected session.end event");
243
+ } finally {
244
+ fs.rmSync(tmpDir, { recursive: true, force: true });
245
+ }
246
+ });
247
+
248
+ test("onBeforeToolCall emits tool.invoke event", () => {
249
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fa-ts-reg3-"));
250
+ try {
251
+ const hooks = new FlowAgentsHooks({ sinkPath: tmpDir, engineRoot: repoRoot });
252
+ const event: StrandsEvent = { toolName: "bash", toolInput: { command: "ls" } };
253
+ hooks.onBeforeToolCall(event);
254
+ const events = fs
255
+ .readFileSync(path.join(tmpDir, "full.jsonl"), "utf8")
256
+ .trim()
257
+ .split("\n")
258
+ .filter(Boolean)
259
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
260
+ assert.ok(events.some((e) => e.event_type === "tool.invoke"), "Expected tool.invoke event");
261
+ } finally {
262
+ fs.rmSync(tmpDir, { recursive: true, force: true });
263
+ }
264
+ });
265
+
266
+ test("onAfterToolCall emits tool.result event", () => {
267
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fa-ts-reg4-"));
268
+ try {
269
+ const hooks = new FlowAgentsHooks({ sinkPath: tmpDir, engineRoot: repoRoot });
270
+ const event: StrandsEvent = { toolName: "read", result: "file contents" };
271
+ hooks.onAfterToolCall(event);
272
+ const events = fs
273
+ .readFileSync(path.join(tmpDir, "full.jsonl"), "utf8")
274
+ .trim()
275
+ .split("\n")
276
+ .filter(Boolean)
277
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
278
+ assert.ok(events.some((e) => e.event_type === "tool.result"), "Expected tool.result event");
279
+ } finally {
280
+ fs.rmSync(tmpDir, { recursive: true, force: true });
281
+ }
282
+ });
283
+
284
+ // -------------------------------------------------------------------------
285
+ // Config-protection block THROUGH THE REAL ENGINE (native call, no mocks)
286
+ // -------------------------------------------------------------------------
287
+
288
+ test("config-protection block: event.cancel set for protected write (REAL ENGINE)", () => {
289
+ if (!engineAvailableInRepo) return;
290
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fa-ts-block-"));
291
+ try {
292
+ const hooks = new FlowAgentsHooks({ sinkPath: tmpDir, engineRoot: repoRoot });
293
+ const event: StrandsEvent = {
294
+ toolName: "write",
295
+ toolInput: { path: "/repo/.eslintrc.json" },
296
+ };
297
+ hooks.onBeforeToolCall(event);
298
+ assert.ok(
299
+ typeof event.cancel === "string" && event.cancel.includes("BLOCKED"),
300
+ `Expected event.cancel to contain BLOCKED, got: ${JSON.stringify(event.cancel)}`
301
+ );
302
+ } finally {
303
+ fs.rmSync(tmpDir, { recursive: true, force: true });
304
+ }
305
+ });
306
+
307
+ test("config-protection allow: event.cancel NOT set for safe write (REAL ENGINE)", () => {
308
+ if (!engineAvailableInRepo) return;
309
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fa-ts-allow-"));
310
+ try {
311
+ const hooks = new FlowAgentsHooks({ sinkPath: tmpDir, engineRoot: repoRoot });
312
+ const event: StrandsEvent = {
313
+ toolName: "write",
314
+ toolInput: { path: "src/main.ts" },
315
+ };
316
+ hooks.onBeforeToolCall(event);
317
+ assert.strictEqual(event.cancel, undefined, "Expected no cancel for safe file");
318
+ } finally {
319
+ fs.rmSync(tmpDir, { recursive: true, force: true });
320
+ }
321
+ });
322
+ });