@kontourai/flow-agents 0.1.2 → 0.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 (117) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/release-please.yml +31 -0
  3. package/.github/workflows/runtime-compat.yml +118 -0
  4. package/CHANGELOG.md +46 -0
  5. package/CONTRIBUTING.md +4 -0
  6. package/README.md +80 -18
  7. package/build/src/cli/flow-kit.js +9 -4
  8. package/build/src/cli/init.js +215 -5
  9. package/build/src/cli/runtime-adapter.js +9 -5
  10. package/build/src/cli/telemetry-doctor.js +4 -1
  11. package/build/src/cli/utterance-check.js +65 -1
  12. package/build/src/runtime-adapters.js +34 -0
  13. package/build/src/tools/build-universal-bundles.js +285 -0
  14. package/build/src/tools/filter-installed-packs.js +3 -0
  15. package/build/src/tools/validate-source-tree.js +5 -1
  16. package/console.telemetry.json +115 -20
  17. package/context/scripts/telemetry/lib/config.sh +5 -1
  18. package/context/settings/flow-agents-settings.json +7 -0
  19. package/docs/_layouts/default.html +2 -0
  20. package/docs/context-map.md +1 -0
  21. package/docs/index.md +53 -4
  22. package/docs/integrations/conformance.md +246 -0
  23. package/docs/integrations/framework-adapter.md +275 -0
  24. package/docs/integrations/harness-install.md +213 -0
  25. package/docs/integrations/index.md +58 -0
  26. package/docs/integrations/knowledge-kit-live.md +211 -0
  27. package/docs/kit-authoring-guide.md +169 -0
  28. package/docs/north-star.md +2 -2
  29. package/docs/spec/runtime-hook-surface.md +525 -0
  30. package/docs/survey-utterance-check.md +211 -94
  31. package/docs/vision.md +45 -0
  32. package/evals/acceptance/run.sh +13 -2
  33. package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
  34. package/evals/acceptance/test_opencode_harness.sh +121 -0
  35. package/evals/acceptance/test_pi_harness.sh +113 -0
  36. package/evals/integration/test_bundle_install.sh +226 -1
  37. package/evals/integration/test_bundle_lifecycle.sh +641 -0
  38. package/evals/integration/test_runtime_adapter_activation.sh +113 -1
  39. package/evals/integration/test_utterance_check.sh +291 -44
  40. package/evals/run.sh +2 -0
  41. package/evals/static/test_universal_bundles.sh +137 -2
  42. package/integrations/strands/README.md +256 -0
  43. package/integrations/strands/example.py +74 -0
  44. package/integrations/strands/examples/knowledge_kit_live.py +461 -0
  45. package/integrations/strands/flow_agents_strands/__init__.py +27 -0
  46. package/integrations/strands/flow_agents_strands/hooks.py +194 -0
  47. package/integrations/strands/flow_agents_strands/policy.py +348 -0
  48. package/integrations/strands/flow_agents_strands/steering.py +225 -0
  49. package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
  50. package/integrations/strands/pyproject.toml +38 -0
  51. package/integrations/strands/tests/__init__.py +0 -0
  52. package/integrations/strands/tests/test_hooks.py +392 -0
  53. package/integrations/strands/tests/test_policy.py +315 -0
  54. package/integrations/strands/tests/test_telemetry.py +184 -0
  55. package/integrations/strands-ts/README.md +224 -0
  56. package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
  57. package/integrations/strands-ts/package.json +53 -0
  58. package/integrations/strands-ts/src/hooks.ts +312 -0
  59. package/integrations/strands-ts/src/index.ts +22 -0
  60. package/integrations/strands-ts/src/policy.ts +345 -0
  61. package/integrations/strands-ts/src/telemetry.ts +251 -0
  62. package/integrations/strands-ts/test/test-policy.ts +322 -0
  63. package/integrations/strands-ts/test/test-steering.ts +159 -0
  64. package/integrations/strands-ts/test/test-telemetry.ts +226 -0
  65. package/integrations/strands-ts/tsconfig.json +20 -0
  66. package/kits/catalog.json +6 -0
  67. package/kits/knowledge/adapters/default-store/index.js +821 -0
  68. package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
  69. package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
  70. package/kits/knowledge/docs/README.md +135 -0
  71. package/kits/knowledge/docs/store-contract.md +526 -0
  72. package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
  73. package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
  74. package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
  75. package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
  76. package/kits/knowledge/flows/compile.flow.json +60 -0
  77. package/kits/knowledge/flows/consolidate.flow.json +77 -0
  78. package/kits/knowledge/flows/ingest.flow.json +60 -0
  79. package/kits/knowledge/flows/store-contract.flow.json +48 -0
  80. package/kits/knowledge/flows/synthesize.flow.json +77 -0
  81. package/kits/knowledge/kit.json +78 -0
  82. package/package.json +7 -2
  83. package/packaging/conformance/README.md +142 -0
  84. package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
  85. package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
  86. package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
  87. package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
  88. package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
  89. package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
  90. package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
  91. package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
  92. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
  93. package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
  94. package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
  95. package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
  96. package/packaging/conformance/package.json +4 -0
  97. package/packaging/conformance/run-conformance.js +322 -0
  98. package/packaging/manifest.json +59 -0
  99. package/schemas/flow-agents-settings.schema.json +48 -0
  100. package/scripts/README.md +4 -0
  101. package/scripts/dogfood.js +16 -0
  102. package/scripts/hooks/opencode-hook-adapter.js +123 -0
  103. package/scripts/hooks/opencode-telemetry-hook.js +101 -0
  104. package/scripts/hooks/pi-hook-adapter.js +123 -0
  105. package/scripts/hooks/pi-telemetry-hook.js +105 -0
  106. package/scripts/hooks/run-hook.js +8 -0
  107. package/scripts/hooks/utterance-check.js +124 -22
  108. package/scripts/telemetry/lib/config.sh +5 -1
  109. package/src/cli/flow-kit.ts +10 -4
  110. package/src/cli/init.ts +219 -6
  111. package/src/cli/runtime-adapter.ts +10 -5
  112. package/src/cli/telemetry-doctor.ts +4 -1
  113. package/src/cli/utterance-check.ts +71 -1
  114. package/src/runtime-adapters.ts +35 -0
  115. package/src/tools/build-universal-bundles.ts +283 -0
  116. package/src/tools/filter-installed-packs.ts +3 -0
  117. package/src/tools/validate-source-tree.ts +5 -1
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * opencode hook adapter for canonical Flow Agents hooks.
4
+ *
5
+ * opencode plugins receive events via the plugin function's hook registry.
6
+ * This adapter is called by the generated .opencode/plugins/flow-agents.js
7
+ * plugin when it shells out to execute policy checks. The adapter normalizes
8
+ * opencode event payloads into the shared hook runner contract and returns
9
+ * results as JSON for the plugin to interpret.
10
+ *
11
+ * Canonical hook scripts: exit 0 passes, exit 2 blocks, stderr/stdout
12
+ * carries human-readable guidance. This adapter translates that contract
13
+ * into JSON the plugin can act on.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const path = require('path');
19
+ const { spawnSync } = require('child_process');
20
+
21
+ const MAX_STDIN = 1024 * 1024;
22
+
23
+ function readStdinRaw() {
24
+ return new Promise(resolve => {
25
+ let raw = '';
26
+ let truncated = false;
27
+ process.stdin.setEncoding('utf8');
28
+ process.stdin.on('data', chunk => {
29
+ if (raw.length < MAX_STDIN) {
30
+ const remaining = MAX_STDIN - raw.length;
31
+ raw += chunk.substring(0, remaining);
32
+ if (chunk.length > remaining) truncated = true;
33
+ } else {
34
+ truncated = true;
35
+ }
36
+ });
37
+ process.stdin.on('end', () => resolve({ raw, truncated }));
38
+ process.stdin.on('error', () => resolve({ raw, truncated }));
39
+ });
40
+ }
41
+
42
+ function parseEvent(raw, fallback) {
43
+ try {
44
+ return JSON.parse(raw || '{}').hook_event_name || fallback || '';
45
+ } catch {
46
+ return fallback || '';
47
+ }
48
+ }
49
+
50
+ function messageFrom(result) {
51
+ const stderr = String(result.stderr || '').trim();
52
+ const stdout = String(result.stdout || '').trim();
53
+ return stderr || stdout || 'Blocked by Flow Agents hook policy.';
54
+ }
55
+
56
+ function guidanceFromStdout(rawInput, stdout) {
57
+ const text = String(stdout || '');
58
+ if (!text.trim()) return '';
59
+ const guidance = text.startsWith(rawInput) ? text.slice(rawInput.length) : text;
60
+ return guidance.trim();
61
+ }
62
+
63
+ function successOutput(event, additionalContext = '') {
64
+ const context = String(additionalContext || '').trim();
65
+ return {
66
+ allow: true,
67
+ context: context || undefined,
68
+ event,
69
+ };
70
+ }
71
+
72
+ function blockedOutput(event, reason) {
73
+ return {
74
+ allow: false,
75
+ reason,
76
+ event,
77
+ };
78
+ }
79
+
80
+ async function main() {
81
+ const [, , eventArg = 'unknown', hookId, relScriptPath, profilesCsv] = process.argv;
82
+ const { raw, truncated } = await readStdinRaw();
83
+ const event = parseEvent(raw, eventArg);
84
+
85
+ if (!hookId || !relScriptPath) {
86
+ process.stdout.write(`${JSON.stringify(successOutput(event))}\n`);
87
+ return;
88
+ }
89
+
90
+ const runHookPath = path.resolve(__dirname, 'run-hook.js');
91
+ const result = spawnSync(process.execPath, [runHookPath, hookId, relScriptPath, profilesCsv || ''], {
92
+ input: raw,
93
+ encoding: 'utf8',
94
+ cwd: process.cwd(),
95
+ env: {
96
+ ...process.env,
97
+ SA_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0',
98
+ SA_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN),
99
+ FLOW_AGENTS_HOOK_RUNTIME: 'opencode',
100
+ },
101
+ timeout: Number(process.env.FLOW_AGENTS_OPENCODE_HOOK_TIMEOUT_MS || 30000),
102
+ });
103
+
104
+ if (result.status === 2) {
105
+ process.stdout.write(`${JSON.stringify(blockedOutput(event, messageFrom(result)))}\n`);
106
+ return;
107
+ }
108
+
109
+ if (result.error || result.signal || result.status === null) {
110
+ const detail = result.error ? result.error.message : result.signal ? `signal ${result.signal}` : 'missing exit status';
111
+ process.stderr.write(`[OpencodeHook] ${hookId} failed open: ${detail}\n`);
112
+ process.stdout.write(`${JSON.stringify(successOutput(event))}\n`);
113
+ return;
114
+ }
115
+
116
+ if (result.stderr) process.stderr.write(result.stderr);
117
+ process.stdout.write(`${JSON.stringify(successOutput(event, guidanceFromStdout(raw, result.stdout)))}\n`);
118
+ }
119
+
120
+ main().catch(err => {
121
+ process.stderr.write(`[OpencodeHook] adapter error: ${err.message}\n`);
122
+ process.exit(0);
123
+ });
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * opencode telemetry hook wrapper.
4
+ *
5
+ * Called by the generated .opencode/plugins/flow-agents.js plugin when it
6
+ * shells out for telemetry recording. This wrapper adapts opencode plugin
7
+ * event payloads to the canonical Flow Agents telemetry script and stays
8
+ * fail-open so telemetry cannot block work.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+
16
+ const MAX_STDIN = 1024 * 1024;
17
+ const DEFAULT_FULL_REDACT = 'hook.raw_input,turn.prompt_text,tool.input,tool.output';
18
+
19
+ function readStdinRaw() {
20
+ return new Promise(resolve => {
21
+ let raw = '';
22
+ process.stdin.setEncoding('utf8');
23
+ process.stdin.on('data', chunk => {
24
+ if (raw.length < MAX_STDIN) {
25
+ raw += chunk.slice(0, MAX_STDIN - raw.length);
26
+ }
27
+ });
28
+ process.stdin.on('end', () => resolve(raw));
29
+ process.stdin.on('error', () => resolve(raw));
30
+ });
31
+ }
32
+
33
+ function parseJson(raw) {
34
+ try {
35
+ return JSON.parse(raw || '{}');
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ function canonicalEvent(cliEvent, payload) {
42
+ const event = cliEvent || payload.hook_event_name || 'unknown';
43
+ const mapping = {
44
+ 'session.created': 'agentSpawn',
45
+ 'session.idle': 'stop',
46
+ 'session.error': 'stop',
47
+ 'session.compacted': 'stop',
48
+ 'tool.execute.before': 'preToolUse',
49
+ 'tool.execute.after': 'postToolUse',
50
+ 'permission.asked': 'permissionRequest',
51
+ 'permission.replied': 'permissionRequest',
52
+ 'file.edited': 'postToolUse',
53
+ 'message.updated': 'postToolUse',
54
+ 'command.executed': 'postToolUse',
55
+ 'input': 'userPromptSubmit',
56
+ agentSpawn: 'agentSpawn',
57
+ userPromptSubmit: 'userPromptSubmit',
58
+ preToolUse: 'preToolUse',
59
+ postToolUse: 'postToolUse',
60
+ stop: 'stop',
61
+ };
62
+ return mapping[event] || event;
63
+ }
64
+
65
+ async function main() {
66
+ const [, , eventArg = 'unknown', agentName = 'dev'] = process.argv;
67
+ const raw = await readStdinRaw();
68
+ const payload = parseJson(raw);
69
+ const telemetryScript = path.resolve(__dirname, '..', 'telemetry', 'telemetry.sh');
70
+
71
+ const result = spawnSync('bash', [telemetryScript, canonicalEvent(eventArg, payload), agentName], {
72
+ input: raw,
73
+ encoding: 'utf8',
74
+ cwd: process.cwd(),
75
+ env: {
76
+ ...process.env,
77
+ FLOW_AGENTS_TELEMETRY_RUNTIME: 'opencode',
78
+ FLOW_AGENTS_TELEMETRY_FOREGROUND: process.env.FLOW_AGENTS_OPENCODE_TELEMETRY_FOREGROUND || 'false',
79
+ TELEMETRY_CHANNELS: process.env.FLOW_AGENTS_OPENCODE_TELEMETRY_CHANNELS || 'full,analytics',
80
+ TELEMETRY_CHANNEL_FULL_REDACT: process.env.TELEMETRY_CHANNEL_FULL_REDACT || DEFAULT_FULL_REDACT,
81
+ TELEMETRY_CHANNEL_ANALYTICS_REDACT:
82
+ process.env.TELEMETRY_CHANNEL_ANALYTICS_REDACT ||
83
+ 'tool.input,tool.output,turn.prompt_text,delegation.targets.query,context.cwd,hook.raw_input',
84
+ TELEMETRY_CHANNEL_FULL_ENDPOINT_URL: process.env.TELEMETRY_CHANNEL_FULL_ENDPOINT_URL || '',
85
+ TELEMETRY_USAGE_TRACKING: process.env.TELEMETRY_USAGE_TRACKING || 'true',
86
+ },
87
+ timeout: Number(process.env.FLOW_AGENTS_OPENCODE_TELEMETRY_TIMEOUT_MS || 30000),
88
+ });
89
+
90
+ if (result.stderr) process.stderr.write(result.stderr);
91
+ if (result.error || result.signal || result.status === null) {
92
+ const detail = result.error ? result.error.message : result.signal ? `signal ${result.signal}` : 'missing exit status';
93
+ process.stderr.write(`[OpencodeTelemetryHook] failed open: ${detail}\n`);
94
+ }
95
+ // opencode plugin calls this as a subprocess; just exit 0 for success
96
+ }
97
+
98
+ main().catch(err => {
99
+ process.stderr.write(`[OpencodeTelemetryHook] wrapper error: ${err.message}\n`);
100
+ process.exit(0);
101
+ });
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pi hook adapter for canonical Flow Agents hooks.
4
+ *
5
+ * The pi coding agent runs extensions that can shell out to run policy checks.
6
+ * This adapter is called by the generated .pi/extensions/flow-agents.ts
7
+ * extension when it needs to evaluate a policy decision. It normalizes
8
+ * pi event payloads into the shared hook runner contract and returns results
9
+ * as JSON for the extension to interpret.
10
+ *
11
+ * Canonical hook scripts: exit 0 passes, exit 2 blocks, stderr/stdout
12
+ * carries human-readable guidance. This adapter translates that contract
13
+ * into JSON the pi extension can act on.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const path = require('path');
19
+ const { spawnSync } = require('child_process');
20
+
21
+ const MAX_STDIN = 1024 * 1024;
22
+
23
+ function readStdinRaw() {
24
+ return new Promise(resolve => {
25
+ let raw = '';
26
+ let truncated = false;
27
+ process.stdin.setEncoding('utf8');
28
+ process.stdin.on('data', chunk => {
29
+ if (raw.length < MAX_STDIN) {
30
+ const remaining = MAX_STDIN - raw.length;
31
+ raw += chunk.substring(0, remaining);
32
+ if (chunk.length > remaining) truncated = true;
33
+ } else {
34
+ truncated = true;
35
+ }
36
+ });
37
+ process.stdin.on('end', () => resolve({ raw, truncated }));
38
+ process.stdin.on('error', () => resolve({ raw, truncated }));
39
+ });
40
+ }
41
+
42
+ function parseEvent(raw, fallback) {
43
+ try {
44
+ return JSON.parse(raw || '{}').hook_event_name || fallback || '';
45
+ } catch {
46
+ return fallback || '';
47
+ }
48
+ }
49
+
50
+ function messageFrom(result) {
51
+ const stderr = String(result.stderr || '').trim();
52
+ const stdout = String(result.stdout || '').trim();
53
+ return stderr || stdout || 'Blocked by Flow Agents hook policy.';
54
+ }
55
+
56
+ function guidanceFromStdout(rawInput, stdout) {
57
+ const text = String(stdout || '');
58
+ if (!text.trim()) return '';
59
+ const guidance = text.startsWith(rawInput) ? text.slice(rawInput.length) : text;
60
+ return guidance.trim();
61
+ }
62
+
63
+ function successOutput(event, additionalContext = '') {
64
+ const context = String(additionalContext || '').trim();
65
+ return {
66
+ allow: true,
67
+ context: context || undefined,
68
+ event,
69
+ };
70
+ }
71
+
72
+ function blockedOutput(event, reason) {
73
+ return {
74
+ allow: false,
75
+ reason,
76
+ event,
77
+ };
78
+ }
79
+
80
+ async function main() {
81
+ const [, , eventArg = 'unknown', hookId, relScriptPath, profilesCsv] = process.argv;
82
+ const { raw, truncated } = await readStdinRaw();
83
+ const event = parseEvent(raw, eventArg);
84
+
85
+ if (!hookId || !relScriptPath) {
86
+ process.stdout.write(`${JSON.stringify(successOutput(event))}\n`);
87
+ return;
88
+ }
89
+
90
+ const runHookPath = path.resolve(__dirname, 'run-hook.js');
91
+ const result = spawnSync(process.execPath, [runHookPath, hookId, relScriptPath, profilesCsv || ''], {
92
+ input: raw,
93
+ encoding: 'utf8',
94
+ cwd: process.cwd(),
95
+ env: {
96
+ ...process.env,
97
+ SA_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0',
98
+ SA_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN),
99
+ FLOW_AGENTS_HOOK_RUNTIME: 'pi',
100
+ },
101
+ timeout: Number(process.env.FLOW_AGENTS_PI_HOOK_TIMEOUT_MS || 30000),
102
+ });
103
+
104
+ if (result.status === 2) {
105
+ process.stdout.write(`${JSON.stringify(blockedOutput(event, messageFrom(result)))}\n`);
106
+ return;
107
+ }
108
+
109
+ if (result.error || result.signal || result.status === null) {
110
+ const detail = result.error ? result.error.message : result.signal ? `signal ${result.signal}` : 'missing exit status';
111
+ process.stderr.write(`[PiHook] ${hookId} failed open: ${detail}\n`);
112
+ process.stdout.write(`${JSON.stringify(successOutput(event))}\n`);
113
+ return;
114
+ }
115
+
116
+ if (result.stderr) process.stderr.write(result.stderr);
117
+ process.stdout.write(`${JSON.stringify(successOutput(event, guidanceFromStdout(raw, result.stdout)))}\n`);
118
+ }
119
+
120
+ main().catch(err => {
121
+ process.stderr.write(`[PiHook] adapter error: ${err.message}\n`);
122
+ process.exit(0);
123
+ });
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pi telemetry hook wrapper.
4
+ *
5
+ * Called by the generated .pi/extensions/flow-agents.ts extension when it
6
+ * shells out for telemetry recording. This wrapper adapts pi extension
7
+ * event payloads to the canonical Flow Agents telemetry script and stays
8
+ * fail-open so telemetry cannot block work.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+
16
+ const MAX_STDIN = 1024 * 1024;
17
+ const DEFAULT_FULL_REDACT = 'hook.raw_input,turn.prompt_text,tool.input,tool.output';
18
+
19
+ function readStdinRaw() {
20
+ return new Promise(resolve => {
21
+ let raw = '';
22
+ process.stdin.setEncoding('utf8');
23
+ process.stdin.on('data', chunk => {
24
+ if (raw.length < MAX_STDIN) {
25
+ raw += chunk.slice(0, MAX_STDIN - raw.length);
26
+ }
27
+ });
28
+ process.stdin.on('end', () => resolve(raw));
29
+ process.stdin.on('error', () => resolve(raw));
30
+ });
31
+ }
32
+
33
+ function parseJson(raw) {
34
+ try {
35
+ return JSON.parse(raw || '{}');
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ function canonicalEvent(cliEvent, payload) {
42
+ const event = cliEvent || payload.hook_event_name || 'unknown';
43
+ const mapping = {
44
+ session_start: 'agentSpawn',
45
+ session_shutdown: 'stop',
46
+ session_before_compact: 'stop',
47
+ before_agent_start: 'agentSpawn',
48
+ agent_start: 'agentSpawn',
49
+ agent_end: 'stop',
50
+ turn_start: 'userPromptSubmit',
51
+ turn_end: 'stop',
52
+ tool_call: 'preToolUse',
53
+ tool_result: 'postToolUse',
54
+ tool_execution_start: 'preToolUse',
55
+ tool_execution_end: 'postToolUse',
56
+ input: 'userPromptSubmit',
57
+ user_bash: 'preToolUse',
58
+ message_start: 'agentSpawn',
59
+ message_end: 'stop',
60
+ agentSpawn: 'agentSpawn',
61
+ userPromptSubmit: 'userPromptSubmit',
62
+ preToolUse: 'preToolUse',
63
+ postToolUse: 'postToolUse',
64
+ stop: 'stop',
65
+ };
66
+ return mapping[event] || event;
67
+ }
68
+
69
+ async function main() {
70
+ const [, , eventArg = 'unknown', agentName = 'dev'] = process.argv;
71
+ const raw = await readStdinRaw();
72
+ const payload = parseJson(raw);
73
+ const telemetryScript = path.resolve(__dirname, '..', 'telemetry', 'telemetry.sh');
74
+
75
+ const result = spawnSync('bash', [telemetryScript, canonicalEvent(eventArg, payload), agentName], {
76
+ input: raw,
77
+ encoding: 'utf8',
78
+ cwd: process.cwd(),
79
+ env: {
80
+ ...process.env,
81
+ FLOW_AGENTS_TELEMETRY_RUNTIME: 'pi',
82
+ FLOW_AGENTS_TELEMETRY_FOREGROUND: process.env.FLOW_AGENTS_PI_TELEMETRY_FOREGROUND || 'false',
83
+ TELEMETRY_CHANNELS: process.env.FLOW_AGENTS_PI_TELEMETRY_CHANNELS || 'full,analytics',
84
+ TELEMETRY_CHANNEL_FULL_REDACT: process.env.TELEMETRY_CHANNEL_FULL_REDACT || DEFAULT_FULL_REDACT,
85
+ TELEMETRY_CHANNEL_ANALYTICS_REDACT:
86
+ process.env.TELEMETRY_CHANNEL_ANALYTICS_REDACT ||
87
+ 'tool.input,tool.output,turn.prompt_text,delegation.targets.query,context.cwd,hook.raw_input',
88
+ TELEMETRY_CHANNEL_FULL_ENDPOINT_URL: process.env.TELEMETRY_CHANNEL_FULL_ENDPOINT_URL || '',
89
+ TELEMETRY_USAGE_TRACKING: process.env.TELEMETRY_USAGE_TRACKING || 'true',
90
+ },
91
+ timeout: Number(process.env.FLOW_AGENTS_PI_TELEMETRY_TIMEOUT_MS || 30000),
92
+ });
93
+
94
+ if (result.stderr) process.stderr.write(result.stderr);
95
+ if (result.error || result.signal || result.status === null) {
96
+ const detail = result.error ? result.error.message : result.signal ? `signal ${result.signal}` : 'missing exit status';
97
+ process.stderr.write(`[PiTelemetryHook] failed open: ${detail}\n`);
98
+ }
99
+ // pi extension calls this as a subprocess; just exit 0 for success
100
+ }
101
+
102
+ main().catch(err => {
103
+ process.stderr.write(`[PiTelemetryHook] wrapper error: ${err.message}\n`);
104
+ process.exit(0);
105
+ });
@@ -19,6 +19,7 @@ const { spawnSync } = require('child_process');
19
19
  const { isHookEnabled } = require('./lib/hook-flags');
20
20
 
21
21
  const MAX_STDIN = 1024 * 1024;
22
+ const CONTRACT_VERSION = '1.0';
22
23
 
23
24
  function readStdinRaw() {
24
25
  return new Promise(resolve => {
@@ -130,6 +131,13 @@ async function main() {
130
131
  process.exit(Number.isInteger(result.status) ? result.status : 0);
131
132
  }
132
133
 
134
+ // Additive: --contract-version prints the engine contract version and exits.
135
+ // Backward-compatible: existing callers never pass this flag.
136
+ if (process.argv.includes('--contract-version')) {
137
+ process.stdout.write(JSON.stringify({ contract_version: CONTRACT_VERSION, runner: 'run-hook.js' }) + '\n');
138
+ process.exit(0);
139
+ }
140
+
133
141
  main().catch(err => {
134
142
  process.stderr.write(`[Hook] run-hook error: ${err.message}\n`);
135
143
  process.exit(0);
@@ -7,19 +7,31 @@
7
7
  * agent context when concerning statements (unsupported/disputed/rejected) are
8
8
  * found.
9
9
  *
10
- * Disabled by default. Enable with:
11
- * FLOW_AGENTS_UTTERANCE_CHECK_ENABLED=true
10
+ * Activation is driven by per-repo policy in context/settings/flow-agents-settings.json:
12
11
  *
13
- * Hook category: PostToolUse / Stop (non-blocking, always exits 0).
12
+ * {
13
+ * "schema_version": "1.0",
14
+ * "utteranceCheck": {
15
+ * "enabled": true,
16
+ * "mode": "report", // "report" (default) or "strict"
17
+ * "extractor": "reference", // "reference" (default) or "anthropic"
18
+ * "bundlePath": "path/to/trust.bundle.json",
19
+ * "model": "claude-haiku-4-5",
20
+ * "agentId": "my-agent"
21
+ * }
22
+ * }
14
23
  *
15
- * Strict mode (blocks Stop when concerning badges present):
16
- * FLOW_AGENTS_UTTERANCE_CHECK_STRICT=true
24
+ * Environment variable override (force-on / force-off):
25
+ * FLOW_AGENTS_UTTERANCE_CHECK_ENABLED=true — force on regardless of config
26
+ * FLOW_AGENTS_UTTERANCE_CHECK_ENABLED=false — force off regardless of config
17
27
  *
18
- * Bundle path (optional trust bundle for claim resolution):
28
+ * Other env vars still accepted as overrides (take precedence over config):
29
+ * FLOW_AGENTS_UTTERANCE_CHECK_STRICT=true
19
30
  * FLOW_AGENTS_UTTERANCE_CHECK_BUNDLE_PATH=/path/to/bundle.json
20
- *
21
- * Agent ID (for provenance):
22
31
  * FLOW_AGENTS_UTTERANCE_CHECK_AGENT_ID=my-agent
32
+ * FLOW_AGENTS_UTTERANCE_CHECK_EXTRACTOR=anthropic
33
+ *
34
+ * Hook category: PostToolUse / Stop (non-blocking in report mode, always fails open).
23
35
  */
24
36
 
25
37
  'use strict';
@@ -33,6 +45,8 @@ const CLI_TIMEOUT_MS = 30000;
33
45
  // Maximum utterance text to pass to the CLI to keep stdin under MAX_STDIN.
34
46
  const MAX_UTTERANCE_CHARS = 8192;
35
47
 
48
+ const SETTINGS_REL_PATH = path.join('context', 'settings', 'flow-agents-settings.json');
49
+
36
50
  // ---------------------------------------------------------------------------
37
51
  // Helpers
38
52
  // ---------------------------------------------------------------------------
@@ -41,6 +55,24 @@ function parseJson(raw) {
41
55
  try { return JSON.parse(raw || '{}'); } catch { return {}; }
42
56
  }
43
57
 
58
+ /**
59
+ * Walk up from startDir to find the repo root.
60
+ * Identified by having .git or AGENTS.md present.
61
+ */
62
+ function findRepoRoot(startDir) {
63
+ let dir = path.resolve(startDir || process.cwd());
64
+ const root = path.parse(dir).root;
65
+ for (let depth = 0; depth < 40; depth++) {
66
+ if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'AGENTS.md'))) {
67
+ return dir;
68
+ }
69
+ const parent = path.dirname(dir);
70
+ if (parent === dir || dir === root) break;
71
+ dir = parent;
72
+ }
73
+ return path.resolve(startDir || process.cwd());
74
+ }
75
+
44
76
  /**
45
77
  * Walk up from startDir to find the flow-agents package root.
46
78
  * Identified by having both package.json and build/src/cli.js present.
@@ -62,6 +94,76 @@ function findPackageRoot(startDir) {
62
94
  return path.resolve(__dirname, '..', '..');
63
95
  }
64
96
 
97
+ /**
98
+ * Load per-repo utteranceCheck policy from context/settings/flow-agents-settings.json.
99
+ * Returns the utteranceCheck object (may be empty/undefined) or null if not found.
100
+ */
101
+ function loadRepoConfig(repoRoot) {
102
+ const settingsPath = path.join(repoRoot, SETTINGS_REL_PATH);
103
+ try {
104
+ if (!fs.existsSync(settingsPath)) return null;
105
+ const raw = fs.readFileSync(settingsPath, 'utf8');
106
+ const parsed = JSON.parse(raw);
107
+ return parsed && typeof parsed.utteranceCheck === 'object' ? parsed.utteranceCheck : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Resolve effective hook policy, merging repo config with env var overrides.
115
+ * Priority (highest first): env var overrides > repo config > built-in defaults.
116
+ */
117
+ function resolvePolicy(repoRoot) {
118
+ const repoConfig = loadRepoConfig(repoRoot) || {};
119
+
120
+ // Env var override: FLOW_AGENTS_UTTERANCE_CHECK_ENABLED forces on/off.
121
+ const envEnabled = process.env.FLOW_AGENTS_UTTERANCE_CHECK_ENABLED;
122
+ let enabled;
123
+ if (envEnabled === 'true') {
124
+ enabled = true;
125
+ } else if (envEnabled === 'false') {
126
+ enabled = false;
127
+ } else {
128
+ // Primary switch is the repo config; default is disabled.
129
+ enabled = repoConfig.enabled === true;
130
+ }
131
+
132
+ if (!enabled) return { enabled: false };
133
+
134
+ // mode: repo config → default "report"
135
+ let mode = repoConfig.mode === 'strict' ? 'strict' : 'report';
136
+ // Env var override for strict
137
+ if (String(process.env.FLOW_AGENTS_UTTERANCE_CHECK_STRICT || '').toLowerCase() === 'true') {
138
+ mode = 'strict';
139
+ }
140
+
141
+ // extractor: repo config → default "reference"
142
+ let extractor = repoConfig.extractor === 'anthropic' ? 'anthropic' : 'reference';
143
+ // Env var override for extractor
144
+ const envExtractor = process.env.FLOW_AGENTS_UTTERANCE_CHECK_EXTRACTOR;
145
+ if (envExtractor === 'anthropic') extractor = 'anthropic';
146
+ else if (envExtractor === 'reference') extractor = 'reference';
147
+
148
+ // bundlePath: env var override > repo config
149
+ let bundlePath = process.env.FLOW_AGENTS_UTTERANCE_CHECK_BUNDLE_PATH || repoConfig.bundlePath || '';
150
+ // Resolve repo-relative bundle paths
151
+ if (bundlePath && !path.isAbsolute(bundlePath)) {
152
+ bundlePath = path.join(repoRoot, bundlePath);
153
+ }
154
+
155
+ // agentId: env var override > repo config
156
+ const agentId =
157
+ process.env.FLOW_AGENTS_UTTERANCE_CHECK_AGENT_ID ||
158
+ repoConfig.agentId ||
159
+ 'flow-agents-hook';
160
+
161
+ // model: repo config only (no env var for now)
162
+ const model = repoConfig.model || '';
163
+
164
+ return { enabled, mode, extractor, bundlePath, agentId, model };
165
+ }
166
+
65
167
  /**
66
168
  * Extract agent utterance text from the hook event input.
67
169
  * - PostToolUse: tool_response or tool_output field
@@ -101,12 +203,15 @@ function safeOneLineExcerpt(text, max = 120) {
101
203
  // ---------------------------------------------------------------------------
102
204
 
103
205
  function run(rawInput) {
104
- const enabled = String(process.env.FLOW_AGENTS_UTTERANCE_CHECK_ENABLED || '').toLowerCase() === 'true';
105
- if (!enabled) return rawInput;
106
-
107
206
  let input;
108
207
  try { input = JSON.parse(rawInput || '{}'); } catch { return rawInput; }
109
208
 
209
+ // Resolve repo root from hook input (Claude Code passes cwd in event).
210
+ const repoRoot = findRepoRoot(input.cwd || process.cwd());
211
+ const policy = resolvePolicy(repoRoot);
212
+
213
+ if (!policy.enabled) return rawInput;
214
+
110
215
  const utteranceText = extractUtteranceText(input);
111
216
  if (!utteranceText) return rawInput;
112
217
 
@@ -127,19 +232,16 @@ function run(rawInput) {
127
232
 
128
233
  const cliArgs = ['utterance-check', 'check', '--utterance', utterance];
129
234
 
130
- const bundlePath = process.env.FLOW_AGENTS_UTTERANCE_CHECK_BUNDLE_PATH;
131
- if (bundlePath) cliArgs.push('--bundle-path', bundlePath);
132
-
133
- const agentId = process.env.FLOW_AGENTS_UTTERANCE_CHECK_AGENT_ID || 'flow-agents-hook';
134
- cliArgs.push('--agent-id', agentId);
135
-
136
- const strict = String(process.env.FLOW_AGENTS_UTTERANCE_CHECK_STRICT || '').toLowerCase() === 'true';
137
- if (strict) cliArgs.push('--strict');
235
+ if (policy.bundlePath) cliArgs.push('--bundle-path', policy.bundlePath);
236
+ cliArgs.push('--agent-id', policy.agentId);
237
+ if (policy.extractor === 'anthropic') cliArgs.push('--extractor', 'anthropic');
238
+ if (policy.model) cliArgs.push('--model', policy.model);
239
+ if (policy.mode === 'strict') cliArgs.push('--strict');
138
240
 
139
241
  const result = spawnSync(process.execPath, [cliPath, ...cliArgs], {
140
242
  encoding: 'utf8',
141
243
  timeout: CLI_TIMEOUT_MS,
142
- cwd: packageRoot,
244
+ cwd: repoRoot,
143
245
  env: { ...process.env },
144
246
  });
145
247
 
@@ -189,7 +291,7 @@ function run(rawInput) {
189
291
  const event = input.hook_event_name || '';
190
292
  const guidance = '\n\n---\n' + lines.join('\n') + '\n---';
191
293
 
192
- if (strict && result.status === 2) {
294
+ if (policy.mode === 'strict' && result.status === 2) {
193
295
  return {
194
296
  stdout: rawInput,
195
297
  stderr: lines.join('\n'),
@@ -222,4 +324,4 @@ if (require.main === module) {
222
324
  });
223
325
  }
224
326
 
225
- module.exports = { run, extractUtteranceText, findPackageRoot };
327
+ module.exports = { run, extractUtteranceText, findPackageRoot, findRepoRoot, loadRepoConfig, resolvePolicy };