@kontourai/flow-agents 0.1.2 → 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.
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/release-please.yml +31 -0
- package/.github/workflows/runtime-compat.yml +118 -0
- package/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +53 -10
- package/build/src/cli/init.js +215 -5
- package/build/src/cli/utterance-check.js +65 -1
- package/build/src/tools/build-universal-bundles.js +268 -0
- package/build/src/tools/filter-installed-packs.js +3 -0
- package/build/src/tools/validate-source-tree.js +5 -1
- package/context/scripts/telemetry/lib/config.sh +5 -1
- package/context/settings/flow-agents-settings.json +7 -0
- package/docs/context-map.md +1 -0
- package/docs/index.md +45 -4
- package/docs/integrations/conformance.md +246 -0
- package/docs/integrations/framework-adapter.md +275 -0
- package/docs/integrations/harness-install.md +213 -0
- package/docs/integrations/index.md +54 -0
- package/docs/north-star.md +2 -2
- package/docs/spec/runtime-hook-surface.md +472 -0
- package/docs/survey-utterance-check.md +211 -94
- package/docs/vision.md +45 -0
- package/evals/acceptance/run.sh +4 -2
- package/evals/acceptance/test_opencode_harness.sh +121 -0
- package/evals/acceptance/test_pi_harness.sh +98 -0
- package/evals/integration/test_bundle_install.sh +226 -1
- package/evals/integration/test_bundle_lifecycle.sh +641 -0
- package/evals/integration/test_utterance_check.sh +291 -44
- package/evals/run.sh +2 -0
- package/evals/static/test_universal_bundles.sh +137 -2
- package/integrations/strands/README.md +256 -0
- package/integrations/strands/example.py +74 -0
- package/integrations/strands/flow_agents_strands/__init__.py +27 -0
- package/integrations/strands/flow_agents_strands/hooks.py +194 -0
- package/integrations/strands/flow_agents_strands/policy.py +348 -0
- package/integrations/strands/flow_agents_strands/steering.py +172 -0
- package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
- package/integrations/strands/pyproject.toml +38 -0
- package/integrations/strands/tests/__init__.py +0 -0
- package/integrations/strands/tests/test_hooks.py +304 -0
- package/integrations/strands/tests/test_policy.py +315 -0
- package/integrations/strands/tests/test_telemetry.py +184 -0
- package/integrations/strands-ts/README.md +224 -0
- package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
- package/integrations/strands-ts/package.json +53 -0
- package/integrations/strands-ts/src/hooks.ts +208 -0
- package/integrations/strands-ts/src/index.ts +22 -0
- package/integrations/strands-ts/src/policy.ts +345 -0
- package/integrations/strands-ts/src/telemetry.ts +251 -0
- package/integrations/strands-ts/test/test-policy.ts +322 -0
- package/integrations/strands-ts/test/test-telemetry.ts +226 -0
- package/integrations/strands-ts/tsconfig.json +20 -0
- package/package.json +7 -2
- package/packaging/conformance/README.md +142 -0
- package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
- package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
- package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
- package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
- package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
- package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
- package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
- package/packaging/conformance/package.json +4 -0
- package/packaging/conformance/run-conformance.js +322 -0
- package/packaging/manifest.json +59 -0
- package/schemas/flow-agents-settings.schema.json +48 -0
- package/scripts/README.md +4 -0
- package/scripts/dogfood.js +16 -0
- package/scripts/hooks/opencode-hook-adapter.js +123 -0
- package/scripts/hooks/opencode-telemetry-hook.js +101 -0
- package/scripts/hooks/pi-hook-adapter.js +123 -0
- package/scripts/hooks/pi-telemetry-hook.js +105 -0
- package/scripts/hooks/run-hook.js +8 -0
- package/scripts/hooks/utterance-check.js +124 -22
- package/scripts/telemetry/lib/config.sh +5 -1
- package/src/cli/init.ts +219 -6
- package/src/cli/utterance-check.ts +71 -1
- package/src/tools/build-universal-bundles.ts +266 -0
- package/src/tools/filter-installed-packs.ts +3 -0
- package/src/tools/validate-source-tree.ts +5 -1
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
cliArgs.push('--
|
|
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:
|
|
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 };
|
|
@@ -6,7 +6,11 @@ TELEMETRY_CONFIG_FILE="${TELEMETRY_CONFIG_FILE:-${TELEMETRY_DIR}/telemetry.conf}
|
|
|
6
6
|
|
|
7
7
|
# Defaults
|
|
8
8
|
TELEMETRY_ENABLED="${TELEMETRY_ENABLED:-true}"
|
|
9
|
-
|
|
9
|
+
# TELEMETRY_DIR is <workspace>/scripts/telemetry, so the workspace root is
|
|
10
|
+
# two levels up. Three levels escaped into the workspace's PARENT directory
|
|
11
|
+
# (caught by live acceptance smoke 2026-06-11: events landed in /tmp/.telemetry
|
|
12
|
+
# instead of the installed workspace).
|
|
13
|
+
TELEMETRY_DATA_DIR="${TELEMETRY_DATA_DIR:-$(cd "${TELEMETRY_DIR}/../.." && pwd)/.telemetry}"
|
|
10
14
|
TELEMETRY_SESSION_DIR="${TELEMETRY_SESSION_DIR:-${TELEMETRY_DATA_DIR}/sessions}"
|
|
11
15
|
TELEMETRY_ENRICH_SYSTEM="${TELEMETRY_ENRICH_SYSTEM:-true}"
|
|
12
16
|
TELEMETRY_ENRICH_WORKSPACE="${TELEMETRY_ENRICH_WORKSPACE:-true}"
|