@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.
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/publish-npm.yml +1 -1
- package/.github/workflows/release-please.yml +31 -0
- package/.github/workflows/runtime-compat.yml +118 -0
- package/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +58 -19
- package/build/src/cli/init.js +215 -5
- package/build/src/cli/utterance-check.js +236 -0
- package/build/src/cli.js +3 -0
- 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 +6 -1
- package/context/scripts/telemetry/lib/config.sh +5 -1
- package/context/settings/flow-agents-settings.json +7 -0
- package/docs/agent-system-guidebook.md +4 -5
- package/docs/context-map.md +1 -0
- package/docs/index.md +46 -6
- 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 +3 -3
- package/docs/repository-structure.md +1 -1
- package/docs/skills-map.md +10 -4
- package/docs/spec/runtime-hook-surface.md +472 -0
- package/docs/survey-utterance-check.md +308 -0
- package/docs/vision.md +45 -0
- package/docs/workflow-usage-guide.md +1 -1
- 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 +518 -0
- 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 +5 -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 +327 -0
- package/scripts/telemetry/lib/config.sh +5 -1
- package/skills/idea-to-backlog/SKILL.md +1 -1
- package/src/cli/init.ts +219 -6
- package/src/cli/utterance-check.ts +324 -0
- package/src/cli.ts +3 -0
- 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 +6 -1
- package/build/src/cli/docs-preview.js +0 -39
- package/build/src/cli/export-bookmarks.js +0 -38
- package/build/src/cli/import-bookmarks.js +0 -50
- package/build/src/cli/instinct-cli.js +0 -93
|
@@ -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);
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Utterance Check Hook — ADR 0003 §9 / survey integration.
|
|
4
|
+
*
|
|
5
|
+
* Optionally inspects agent output text for evidence coverage using
|
|
6
|
+
* @kontourai/survey's surveyAgentUtterance. Injects badge guidance into the
|
|
7
|
+
* agent context when concerning statements (unsupported/disputed/rejected) are
|
|
8
|
+
* found.
|
|
9
|
+
*
|
|
10
|
+
* Activation is driven by per-repo policy in context/settings/flow-agents-settings.json:
|
|
11
|
+
*
|
|
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
|
+
* }
|
|
23
|
+
*
|
|
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
|
|
27
|
+
*
|
|
28
|
+
* Other env vars still accepted as overrides (take precedence over config):
|
|
29
|
+
* FLOW_AGENTS_UTTERANCE_CHECK_STRICT=true
|
|
30
|
+
* FLOW_AGENTS_UTTERANCE_CHECK_BUNDLE_PATH=/path/to/bundle.json
|
|
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).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const fs = require('fs');
|
|
40
|
+
const path = require('path');
|
|
41
|
+
const { spawnSync } = require('child_process');
|
|
42
|
+
|
|
43
|
+
const MAX_STDIN = 1024 * 1024;
|
|
44
|
+
const CLI_TIMEOUT_MS = 30000;
|
|
45
|
+
// Maximum utterance text to pass to the CLI to keep stdin under MAX_STDIN.
|
|
46
|
+
const MAX_UTTERANCE_CHARS = 8192;
|
|
47
|
+
|
|
48
|
+
const SETTINGS_REL_PATH = path.join('context', 'settings', 'flow-agents-settings.json');
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function parseJson(raw) {
|
|
55
|
+
try { return JSON.parse(raw || '{}'); } catch { return {}; }
|
|
56
|
+
}
|
|
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
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Walk up from startDir to find the flow-agents package root.
|
|
78
|
+
* Identified by having both package.json and build/src/cli.js present.
|
|
79
|
+
*/
|
|
80
|
+
function findPackageRoot(startDir) {
|
|
81
|
+
let dir = path.resolve(startDir || __dirname);
|
|
82
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
83
|
+
if (
|
|
84
|
+
fs.existsSync(path.join(dir, 'package.json')) &&
|
|
85
|
+
fs.existsSync(path.join(dir, 'build', 'src', 'cli.js'))
|
|
86
|
+
) {
|
|
87
|
+
return dir;
|
|
88
|
+
}
|
|
89
|
+
const parent = path.dirname(dir);
|
|
90
|
+
if (parent === dir) break;
|
|
91
|
+
dir = parent;
|
|
92
|
+
}
|
|
93
|
+
// Fallback: assume hooks dir is scripts/hooks/, so root is two levels up.
|
|
94
|
+
return path.resolve(__dirname, '..', '..');
|
|
95
|
+
}
|
|
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
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Extract agent utterance text from the hook event input.
|
|
169
|
+
* - PostToolUse: tool_response or tool_output field
|
|
170
|
+
* - Stop: stop_reason or agent_message field (varies by harness)
|
|
171
|
+
* - Fallback: returns null (hook passes through)
|
|
172
|
+
*/
|
|
173
|
+
function extractUtteranceText(input) {
|
|
174
|
+
if (!input || typeof input !== 'object') return null;
|
|
175
|
+
|
|
176
|
+
// PostToolUse: agent output is in tool_response or tool_output
|
|
177
|
+
const resp = input.tool_response || input.tool_output;
|
|
178
|
+
if (typeof resp === 'string' && resp.trim()) return resp.trim();
|
|
179
|
+
|
|
180
|
+
// Stop: some harnesses expose agent message content
|
|
181
|
+
const agentMsg = input.agent_message || input.message;
|
|
182
|
+
if (typeof agentMsg === 'string' && agentMsg.trim()) return agentMsg.trim();
|
|
183
|
+
|
|
184
|
+
// Stop with content array (Claude Code format)
|
|
185
|
+
if (Array.isArray(input.content)) {
|
|
186
|
+
const texts = input.content
|
|
187
|
+
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
|
|
188
|
+
.map(b => String(b.text).trim())
|
|
189
|
+
.filter(Boolean);
|
|
190
|
+
if (texts.length > 0) return texts.join('\n\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function safeOneLineExcerpt(text, max = 120) {
|
|
197
|
+
const s = String(text || '').replace(/\s+/g, ' ').trim();
|
|
198
|
+
return s.length > max ? `${s.slice(0, max - 3)}...` : s;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Hook runner
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
function run(rawInput) {
|
|
206
|
+
let input;
|
|
207
|
+
try { input = JSON.parse(rawInput || '{}'); } catch { return rawInput; }
|
|
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
|
+
|
|
215
|
+
const utteranceText = extractUtteranceText(input);
|
|
216
|
+
if (!utteranceText) return rawInput;
|
|
217
|
+
|
|
218
|
+
// Truncate very long utterances before passing to CLI.
|
|
219
|
+
const utterance = utteranceText.length > MAX_UTTERANCE_CHARS
|
|
220
|
+
? utteranceText.slice(0, MAX_UTTERANCE_CHARS)
|
|
221
|
+
: utteranceText;
|
|
222
|
+
|
|
223
|
+
const packageRoot = findPackageRoot(__dirname);
|
|
224
|
+
const cliPath = path.join(packageRoot, 'build', 'src', 'cli.js');
|
|
225
|
+
|
|
226
|
+
if (!fs.existsSync(cliPath)) {
|
|
227
|
+
process.stderr.write(
|
|
228
|
+
`[UtteranceCheck] CLI not found at ${cliPath}. Run npm run build in the flow-agents checkout.\n`
|
|
229
|
+
);
|
|
230
|
+
return rawInput;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const cliArgs = ['utterance-check', 'check', '--utterance', utterance];
|
|
234
|
+
|
|
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');
|
|
240
|
+
|
|
241
|
+
const result = spawnSync(process.execPath, [cliPath, ...cliArgs], {
|
|
242
|
+
encoding: 'utf8',
|
|
243
|
+
timeout: CLI_TIMEOUT_MS,
|
|
244
|
+
cwd: repoRoot,
|
|
245
|
+
env: { ...process.env },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (result.error || result.signal || result.status === null) {
|
|
249
|
+
const detail = result.error
|
|
250
|
+
? result.error.message
|
|
251
|
+
: result.signal
|
|
252
|
+
? `signal ${result.signal}`
|
|
253
|
+
: 'missing exit status';
|
|
254
|
+
process.stderr.write(`[UtteranceCheck] CLI execution failed (failing open): ${detail}\n`);
|
|
255
|
+
return rawInput;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Parse the JSON report from stdout.
|
|
259
|
+
let report = null;
|
|
260
|
+
try { report = JSON.parse(String(result.stdout || '').trim()); } catch { /* pass through */ }
|
|
261
|
+
|
|
262
|
+
if (result.stderr) {
|
|
263
|
+
process.stderr.write(String(result.stderr));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!report) return rawInput;
|
|
267
|
+
|
|
268
|
+
// If survey is not configured, pass through silently.
|
|
269
|
+
if (report.status === 'not_configured') return rawInput;
|
|
270
|
+
|
|
271
|
+
// Build guidance text from the badge report.
|
|
272
|
+
const statements = Array.isArray(report.statements) ? report.statements : [];
|
|
273
|
+
const concerning = statements.filter(s =>
|
|
274
|
+
s && (s.badge === 'unsupported' || s.badge === 'disputed' || s.badge === 'rejected')
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (concerning.length === 0) return rawInput;
|
|
278
|
+
|
|
279
|
+
const lines = [
|
|
280
|
+
`UTTERANCE CHECK: ${concerning.length} statement(s) in this response lack evidence coverage.`,
|
|
281
|
+
`Summary: ${report.summary || 'unknown'}`,
|
|
282
|
+
...concerning.slice(0, 4).map(s =>
|
|
283
|
+
` - [${s.badge}] "${safeOneLineExcerpt(s.excerpt)}"`
|
|
284
|
+
),
|
|
285
|
+
'Evidence note: unsupported = no matching claim in the trust bundle; disputed = conflicting evidence; rejected = claim was rejected.',
|
|
286
|
+
'Cite sources, note gaps, or run survey-utterance-check to record coverage.',
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
// For PostToolUse: append guidance to the raw input as additional context.
|
|
290
|
+
// For Stop: report to stderr (non-blocking warning) unless strict mode.
|
|
291
|
+
const event = input.hook_event_name || '';
|
|
292
|
+
const guidance = '\n\n---\n' + lines.join('\n') + '\n---';
|
|
293
|
+
|
|
294
|
+
if (policy.mode === 'strict' && result.status === 2) {
|
|
295
|
+
return {
|
|
296
|
+
stdout: rawInput,
|
|
297
|
+
stderr: lines.join('\n'),
|
|
298
|
+
exitCode: 2,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (event === 'PostToolUse') {
|
|
303
|
+
return rawInput + guidance;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
process.stderr.write(`[UtteranceCheck] ${lines.join('\n')}\n`);
|
|
307
|
+
return rawInput;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (require.main === module) {
|
|
311
|
+
let data = '';
|
|
312
|
+
process.stdin.setEncoding('utf8');
|
|
313
|
+
process.stdin.on('data', chunk => {
|
|
314
|
+
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
|
|
315
|
+
});
|
|
316
|
+
process.stdin.on('end', () => {
|
|
317
|
+
const output = run(data);
|
|
318
|
+
if (output && typeof output === 'object') {
|
|
319
|
+
if (output.stderr) process.stderr.write(output.stderr.endsWith('\n') ? output.stderr : `${output.stderr}\n`);
|
|
320
|
+
process.stdout.write(String(output.stdout ?? data));
|
|
321
|
+
process.exit(Number.isInteger(output.exitCode) ? output.exitCode : 0);
|
|
322
|
+
}
|
|
323
|
+
process.stdout.write(String(output));
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
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}"
|