@sentropic/h2a 0.98.5 → 0.98.7-rc.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/dist/cli-command-map.d.ts.map +1 -1
- package/dist/cli-command-map.js +1 -0
- package/dist/cli-command-map.js.map +1 -1
- package/dist/cli-contract.js +2 -2
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +224 -16
- package/dist/cli.js.map +1 -1
- package/dist/hosts/agy.js +1 -1
- package/dist/hosts/agy.js.map +1 -1
- package/dist/hosts/installation-doctor.d.ts +1 -0
- package/dist/hosts/installation-doctor.d.ts.map +1 -1
- package/dist/hosts/installation-doctor.js +1 -1
- package/dist/hosts/installation-doctor.js.map +1 -1
- package/dist/hosts/muse.d.ts.map +1 -1
- package/dist/hosts/muse.js +2 -4
- package/dist/hosts/muse.js.map +1 -1
- package/dist/hosts/native-artifacts.d.ts +33 -0
- package/dist/hosts/native-artifacts.d.ts.map +1 -0
- package/dist/hosts/native-artifacts.js +104 -0
- package/dist/hosts/native-artifacts.js.map +1 -0
- package/dist/hosts/native-catalog.d.ts +19 -0
- package/dist/hosts/native-catalog.d.ts.map +1 -0
- package/dist/hosts/native-catalog.js +14 -0
- package/dist/hosts/native-catalog.js.map +1 -0
- package/dist/hosts/native-convergence.d.ts +132 -0
- package/dist/hosts/native-convergence.d.ts.map +1 -0
- package/dist/hosts/native-convergence.js +86 -0
- package/dist/hosts/native-convergence.js.map +1 -0
- package/dist/hosts/native-files.d.ts +40 -0
- package/dist/hosts/native-files.d.ts.map +1 -0
- package/dist/hosts/native-files.js +131 -0
- package/dist/hosts/native-files.js.map +1 -0
- package/dist/hosts/native-inspection.d.ts +31 -0
- package/dist/hosts/native-inspection.d.ts.map +1 -0
- package/dist/hosts/native-inspection.js +79 -0
- package/dist/hosts/native-inspection.js.map +1 -0
- package/dist/hosts/native-paths.d.ts +11 -0
- package/dist/hosts/native-paths.d.ts.map +1 -0
- package/dist/hosts/native-paths.js +21 -0
- package/dist/hosts/native-paths.js.map +1 -0
- package/dist/hosts/native-proof.d.ts +32 -0
- package/dist/hosts/native-proof.d.ts.map +1 -0
- package/dist/hosts/native-proof.js +31 -0
- package/dist/hosts/native-proof.js.map +1 -0
- package/dist/hosts/native-runtime-record.d.ts +3 -0
- package/dist/hosts/native-runtime-record.d.ts.map +1 -0
- package/dist/hosts/native-runtime-record.js +60 -0
- package/dist/hosts/native-runtime-record.js.map +1 -0
- package/dist/hosts/native-skills.d.ts +24 -0
- package/dist/hosts/native-skills.d.ts.map +1 -0
- package/dist/hosts/native-skills.js +56 -0
- package/dist/hosts/native-skills.js.map +1 -0
- package/dist/hosts/native-toml.d.ts +18 -0
- package/dist/hosts/native-toml.d.ts.map +1 -0
- package/dist/hosts/native-toml.js +91 -0
- package/dist/hosts/native-toml.js.map +1 -0
- package/dist/hosts/operator-notices.d.ts +3 -0
- package/dist/hosts/operator-notices.d.ts.map +1 -0
- package/dist/hosts/operator-notices.js +31 -0
- package/dist/hosts/operator-notices.js.map +1 -0
- package/dist/hosts/plugin.d.ts.map +1 -1
- package/dist/hosts/plugin.js +3 -2
- package/dist/hosts/plugin.js.map +1 -1
- package/dist/hosts/upgrade-equipment.d.ts +42 -0
- package/dist/hosts/upgrade-equipment.d.ts.map +1 -0
- package/dist/hosts/upgrade-equipment.js +26 -0
- package/dist/hosts/upgrade-equipment.js.map +1 -0
- package/dist/hosts/vibe-discovery.d.ts +12 -0
- package/dist/hosts/vibe-discovery.d.ts.map +1 -0
- package/dist/hosts/vibe-discovery.js +33 -0
- package/dist/hosts/vibe-discovery.js.map +1 -0
- package/dist/hosts/vibe-equipment.d.ts +71 -0
- package/dist/hosts/vibe-equipment.d.ts.map +1 -0
- package/dist/hosts/vibe-equipment.js +162 -0
- package/dist/hosts/vibe-equipment.js.map +1 -0
- package/dist/hosts/vibe-mcp.d.ts +32 -0
- package/dist/hosts/vibe-mcp.d.ts.map +1 -0
- package/dist/hosts/vibe-mcp.js +210 -0
- package/dist/hosts/vibe-mcp.js.map +1 -0
- package/dist/hosts/vibe.d.ts +12 -3
- package/dist/hosts/vibe.d.ts.map +1 -1
- package/dist/hosts/vibe.js +15 -7
- package/dist/hosts/vibe.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/drive/consent.d.ts +1 -1
- package/dist/runtime/drive/consent.d.ts.map +1 -1
- package/dist/runtime/drive/consent.js +2 -2
- package/dist/runtime/drive/consent.js.map +1 -1
- package/dist/runtime/drive/index.d.ts +1 -1
- package/dist/runtime/drive/index.d.ts.map +1 -1
- package/dist/runtime/drive/index.js +3 -1
- package/dist/runtime/drive/index.js.map +1 -1
- package/dist/runtime/drumbeat/relaunchers.d.ts +1 -1
- package/dist/runtime/drumbeat/relaunchers.d.ts.map +1 -1
- package/dist/runtime/drumbeat/relaunchers.js +2 -1
- package/dist/runtime/drumbeat/relaunchers.js.map +1 -1
- package/dist/runtime/identity/binding-commit.d.ts +31 -0
- package/dist/runtime/identity/binding-commit.d.ts.map +1 -0
- package/dist/runtime/identity/binding-commit.js +211 -0
- package/dist/runtime/identity/binding-commit.js.map +1 -0
- package/dist/runtime/identity/bindings.d.ts +6 -30
- package/dist/runtime/identity/bindings.d.ts.map +1 -1
- package/dist/runtime/identity/bindings.js +85 -96
- package/dist/runtime/identity/bindings.js.map +1 -1
- package/dist/runtime/identity/durable.d.ts +7 -0
- package/dist/runtime/identity/durable.d.ts.map +1 -0
- package/dist/runtime/identity/durable.js +68 -0
- package/dist/runtime/identity/durable.js.map +1 -0
- package/dist/runtime/identity/live.d.ts +4 -1
- package/dist/runtime/identity/live.d.ts.map +1 -1
- package/dist/runtime/identity/live.js +59 -6
- package/dist/runtime/identity/live.js.map +1 -1
- package/dist/runtime/identity/possession.d.ts +34 -0
- package/dist/runtime/identity/possession.d.ts.map +1 -0
- package/dist/runtime/identity/possession.js +162 -0
- package/dist/runtime/identity/possession.js.map +1 -0
- package/dist/runtime/identity/worker.js +16 -5
- package/dist/runtime/identity/worker.js.map +1 -1
- package/dist/runtime/live/contracts.d.ts +55 -0
- package/dist/runtime/live/contracts.d.ts.map +1 -0
- package/dist/runtime/live/contracts.js +59 -0
- package/dist/runtime/live/contracts.js.map +1 -0
- package/dist/runtime/local-files/identity-fence.d.ts +164 -0
- package/dist/runtime/local-files/identity-fence.d.ts.map +1 -0
- package/dist/runtime/local-files/identity-fence.js +1338 -0
- package/dist/runtime/local-files/identity-fence.js.map +1 -0
- package/dist/runtime/local-files/index.d.ts +1 -0
- package/dist/runtime/local-files/index.d.ts.map +1 -1
- package/dist/runtime/local-files/index.js +1 -0
- package/dist/runtime/local-files/index.js.map +1 -1
- package/dist/runtime/local-files/legacy-identity-evidence.d.ts +26 -0
- package/dist/runtime/local-files/legacy-identity-evidence.d.ts.map +1 -0
- package/dist/runtime/local-files/legacy-identity-evidence.js +234 -0
- package/dist/runtime/local-files/legacy-identity-evidence.js.map +1 -0
- package/dist/runtime/local-files/locks.d.ts +3 -0
- package/dist/runtime/local-files/locks.d.ts.map +1 -1
- package/dist/runtime/local-files/locks.js +6 -5
- package/dist/runtime/local-files/locks.js.map +1 -1
- package/dist/runtime/local-files/orphan-gc.d.ts +3 -0
- package/dist/runtime/local-files/orphan-gc.d.ts.map +1 -0
- package/dist/runtime/local-files/orphan-gc.js +69 -0
- package/dist/runtime/local-files/orphan-gc.js.map +1 -0
- package/dist/runtime/local-files/presence.d.ts +5 -2
- package/dist/runtime/local-files/presence.d.ts.map +1 -1
- package/dist/runtime/local-files/presence.js +32 -29
- package/dist/runtime/local-files/presence.js.map +1 -1
- package/dist/runtime/local-files/recipient-hints.d.ts +10 -0
- package/dist/runtime/local-files/recipient-hints.d.ts.map +1 -0
- package/dist/runtime/local-files/recipient-hints.js +156 -0
- package/dist/runtime/local-files/recipient-hints.js.map +1 -0
- package/dist/runtime/local-files/store.d.ts +1 -1
- package/dist/runtime/local-files/store.d.ts.map +1 -1
- package/dist/runtime/local-files/store.js +12 -1
- package/dist/runtime/local-files/store.js.map +1 -1
- package/dist/runtime/local-files/succession-lock.d.ts +71 -6
- package/dist/runtime/local-files/succession-lock.d.ts.map +1 -1
- package/dist/runtime/local-files/succession-lock.js +226 -31
- package/dist/runtime/local-files/succession-lock.js.map +1 -1
- package/dist/runtime/mcp/handlers.d.ts +1 -0
- package/dist/runtime/mcp/handlers.d.ts.map +1 -1
- package/dist/runtime/mcp/handlers.js +7 -4
- package/dist/runtime/mcp/handlers.js.map +1 -1
- package/dist/runtime/mcp/identity-blocker-snapshot.d.ts +2 -0
- package/dist/runtime/mcp/identity-blocker-snapshot.d.ts.map +1 -0
- package/dist/runtime/mcp/identity-blocker-snapshot.js +98 -0
- package/dist/runtime/mcp/identity-blocker-snapshot.js.map +1 -0
- package/dist/runtime/mcp/identity-state.d.ts +20 -3
- package/dist/runtime/mcp/identity-state.d.ts.map +1 -1
- package/dist/runtime/mcp/identity-state.js +98 -13
- package/dist/runtime/mcp/identity-state.js.map +1 -1
- package/dist/runtime/mcp/notifications.d.ts +2 -1
- package/dist/runtime/mcp/notifications.d.ts.map +1 -1
- package/dist/runtime/mcp/notifications.js +16 -2
- package/dist/runtime/mcp/notifications.js.map +1 -1
- package/dist/runtime/mcp/server.d.ts +3 -0
- package/dist/runtime/mcp/server.d.ts.map +1 -1
- package/dist/runtime/mcp/server.js +31 -15
- package/dist/runtime/mcp/server.js.map +1 -1
- package/dist/runtime/mcp/sessions.d.ts +6 -6
- package/dist/runtime/mcp/sessions.d.ts.map +1 -1
- package/dist/runtime/mcp/sessions.js +18 -6
- package/dist/runtime/mcp/sessions.js.map +1 -1
- package/dist/runtime/mcp/stdio.d.ts.map +1 -1
- package/dist/runtime/mcp/stdio.js +11 -0
- package/dist/runtime/mcp/stdio.js.map +1 -1
- package/dist/runtime/mcp/tools.js +1 -1
- package/dist/runtime/mcp/tools.js.map +1 -1
- package/dist/runtime/remote/client.d.ts +1 -1
- package/dist/runtime/remote/client.d.ts.map +1 -1
- package/dist/runtime/remote/client.js +1 -1
- package/dist/runtime/remote/client.js.map +1 -1
- package/dist/runtime/send.d.ts +10 -0
- package/dist/runtime/send.d.ts.map +1 -1
- package/dist/runtime/send.js +80 -5
- package/dist/runtime/send.js.map +1 -1
- package/dist/runtime/upgrade/index.d.ts +1 -0
- package/dist/runtime/upgrade/index.d.ts.map +1 -1
- package/dist/runtime/upgrade/index.js +1 -0
- package/dist/runtime/upgrade/index.js.map +1 -1
- package/dist/vendor/engram-live/contracts.d.ts +208 -0
- package/dist/vendor/engram-live/contracts.js +558 -0
- package/fixtures/native-host-catalog.json +1 -0
- package/fixtures/native-host-history.json +1261 -0
- package/fixtures/native-host-renderers.json +1 -0
- package/hooks/deny-manual-h2a-cli.mjs +38 -0
- package/hooks/hooks.json +17 -0
- package/hooks/vibe-post-agent.mjs +29 -0
- package/hooks/vibe-pre-tool.mjs +40 -0
- package/package.json +9 -6
- package/scripts/qualification-budget.mjs +56 -0
- package/scripts/qualification-cleanup.mjs +15 -0
- package/scripts/qualification-oracles.mjs +44 -0
- package/scripts/qualification-patch.mjs +21 -0
- package/scripts/qualification-poll-hosts.mjs +19 -0
- package/scripts/qualification-probe.mjs +63 -0
- package/scripts/qualification-rpc.mjs +59 -0
- package/scripts/qualification-vibe.mjs +159 -0
- package/scripts/qualify-host.mjs +143 -0
- package/skills/h2a/SKILL.md +6 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code PreToolUse guard for the packaged H2A plugin.
|
|
4
|
+
*
|
|
5
|
+
* The plugin owns its MCP lifecycle. An agent must use the h2a_* / track_*
|
|
6
|
+
* tools and H2A skills, never invoke the `h2a` executable through Bash.
|
|
7
|
+
*/
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
|
|
10
|
+
function isManualH2aInvocation(command) {
|
|
11
|
+
if (typeof command !== "string") return false;
|
|
12
|
+
// Recognize a command word after normal shell separators and optional common
|
|
13
|
+
// launch wrappers. This deliberately does not match prose such as
|
|
14
|
+
// `echo h2a`, paths such as `./h2a`, or an unrelated binary containing h2a.
|
|
15
|
+
return /(?:^|[;|&\n]\s*)(?:(?:env\s+(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*)|command\s+|sudo\s+(?:-\w+\s+)*)*h2a(?:\s|$)/m.test(command);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let input = {};
|
|
19
|
+
try {
|
|
20
|
+
const raw = await new Promise((resolve) => {
|
|
21
|
+
let data = "";
|
|
22
|
+
process.stdin.setEncoding("utf8");
|
|
23
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
24
|
+
process.stdin.on("end", () => resolve(data));
|
|
25
|
+
});
|
|
26
|
+
input = raw ? JSON.parse(raw) : {};
|
|
27
|
+
} catch {
|
|
28
|
+
// A malformed hook payload must never block unrelated work.
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (isManualH2aInvocation(input?.tool_input?.command)) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
"Blocked manual `h2a` CLI use. This plugin owns H2A through its single MCP: use h2a_* or track_* tools, or the H2A skill instead.\n",
|
|
35
|
+
);
|
|
36
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: "Use the H2A MCP tools/skill; do not invoke the h2a CLI from Bash." }));
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Prevents agents from bypassing the H2A plugin MCP with manual h2a CLI commands.",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Bash",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-manual-h2a-cli.mjs\"",
|
|
11
|
+
"timeout": 5
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
// No lifecycle effect until the root parent_session_id field is validated.
|
|
4
|
+
let raw = '';
|
|
5
|
+
const done = message => { if (message) process.stderr.write(message + '\n'); process.exit(0); };
|
|
6
|
+
const timer = setTimeout(() => done('Vibe lifecycle: stdin timeout; no record.'), 4000);
|
|
7
|
+
process.stdin.setEncoding('utf8');
|
|
8
|
+
process.stdin.on('error', () => done('Vibe lifecycle: unreadable stdin; no record.'));
|
|
9
|
+
process.stdin.on('data', chunk => { raw += chunk; if (raw.length > 1_048_576) done('Vibe lifecycle: oversized payload; no record.'); });
|
|
10
|
+
process.stdin.on('end', () => {
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
try {
|
|
13
|
+
const payload = JSON.parse(raw);
|
|
14
|
+
if (typeof payload.parent_session_id === 'string' && payload.parent_session_id.length) return done();
|
|
15
|
+
if (!Object.hasOwn(payload, 'parent_session_id') || payload.parent_session_id !== null)
|
|
16
|
+
return done('Vibe lifecycle: missing/invalid parent_session_id; no record.');
|
|
17
|
+
const flags = {};
|
|
18
|
+
for (let i = 2; i < process.argv.length; i += 2) flags[process.argv[i]] = process.argv[i + 1];
|
|
19
|
+
const entrypoint = flags['--entrypoint'], instance = flags['--instance'];
|
|
20
|
+
if (!entrypoint || !isAbsolute(entrypoint) || !instance || instance === 'auto') return done('Vibe lifecycle: explicit instance and absolute entrypoint required.');
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
const tmux = process.env.TMUX_PANE ? spawnSync('tmux', ['display-message', '-p', '#{session_name}'], { encoding: 'utf8', timeout: 500, maxBuffer: 4096 }) : undefined;
|
|
23
|
+
const args = [entrypoint, 'drumbeat', 'record', '--instance', instance, '--status', flags['--status'] || 'paused',
|
|
24
|
+
'--command', 'vibe -c', '--resume-command', 'vibe -c', '--tmux-session', tmux?.status === 0 ? tmux.stdout.trim() : '', '--tmux-pane', process.env.TMUX_PANE || ''];
|
|
25
|
+
if (flags['--root']) args.push('--root', flags['--root']);
|
|
26
|
+
const result = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: Math.max(1, 4000 - (Date.now() - start)), maxBuffer: 1_048_576 });
|
|
27
|
+
if (result.status !== 0) process.stderr.write('Vibe lifecycle: drumbeat record failed; inspect the explicit installation.\n');
|
|
28
|
+
} catch { process.stderr.write('Vibe lifecycle: malformed payload; no record.\n'); }
|
|
29
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Vibe pre_tool: explicit allow/deny JSON, status 0; the host fails open.
|
|
2
|
+
let raw = '';
|
|
3
|
+
const allow = () => { process.stdout.write('{"decision":"allow"}'); process.exit(0); };
|
|
4
|
+
const timer = setTimeout(allow, 4000);
|
|
5
|
+
process.stdin.setEncoding('utf8');
|
|
6
|
+
process.stdin.on('data', chunk => { raw += chunk; if (raw.length > 1_048_576) allow(); });
|
|
7
|
+
process.stdin.on('error', allow);
|
|
8
|
+
process.stdin.on('end', () => {
|
|
9
|
+
clearTimeout(timer);
|
|
10
|
+
try {
|
|
11
|
+
const payload = JSON.parse(raw);
|
|
12
|
+
const command = payload?.tool_input?.command;
|
|
13
|
+
if (typeof command !== 'string') return allow();
|
|
14
|
+
// Quoted arguments stay single words. Separators inside an argument are
|
|
15
|
+
// data, including the benign Node expression containing "|h2a send".
|
|
16
|
+
const tokens = [];
|
|
17
|
+
let word = '', quote = '', escape = false, started = false;
|
|
18
|
+
const flush = () => { if (started) tokens.push(word); word = ''; started = false; };
|
|
19
|
+
for (const ch of command) {
|
|
20
|
+
if (escape) { word += ch; started = true; escape = false; continue; }
|
|
21
|
+
if (ch === '\\' && quote !== "'") { escape = true; started = true; continue; }
|
|
22
|
+
if (quote) { if (ch === quote) quote = ''; else word += ch; continue; }
|
|
23
|
+
if (ch === '"' || ch === "'") { quote = ch; started = true; continue; }
|
|
24
|
+
if (';|&\n()'.includes(ch)) { flush(); tokens.push(null); }
|
|
25
|
+
else if (/\s/.test(ch)) flush(); else { word += ch; started = true; }
|
|
26
|
+
}
|
|
27
|
+
flush();
|
|
28
|
+
let executable = true, denied = false;
|
|
29
|
+
for (const token of tokens) {
|
|
30
|
+
if (token === null) { executable = true; continue; }
|
|
31
|
+
if (!executable) continue;
|
|
32
|
+
if (/^[A-Za-z_][\w]*=/.test(token) || ['env', 'command', 'sudo'].includes(token) || token.startsWith('-')) continue;
|
|
33
|
+
if (token === 'h2a') { denied = true; break; }
|
|
34
|
+
executable = false;
|
|
35
|
+
}
|
|
36
|
+
if (!denied) return allow();
|
|
37
|
+
process.stderr.write('Use the H2A MCP tools/skill; manual h2a CLI invocation refused.\n');
|
|
38
|
+
process.stdout.write(JSON.stringify({ decision: 'deny', reason: 'Use h2a_* or track_* MCP tools and the H2A skill.' }));
|
|
39
|
+
} catch { allow(); }
|
|
40
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentropic/h2a",
|
|
3
|
-
"version": "0.98.
|
|
3
|
+
"version": "0.98.7-rc.1",
|
|
4
4
|
"description": "h2a — the unified CLI + core for human-to-agent coordination.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
"fixtures",
|
|
37
37
|
"skills",
|
|
38
38
|
"schema",
|
|
39
|
-
"focus-app"
|
|
39
|
+
"focus-app",
|
|
40
|
+
"hooks",
|
|
41
|
+
"scripts"
|
|
40
42
|
],
|
|
41
43
|
"publishConfig": {
|
|
42
44
|
"access": "public"
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
"h2a": "dist/bin.js"
|
|
46
48
|
},
|
|
47
49
|
"scripts": {
|
|
48
|
-
"build": "node ../../scripts/clean-workspace-dist.mjs && tsc -b --force",
|
|
50
|
+
"build": "node ../../scripts/clean-workspace-dist.mjs && tsc -b --force && node ../../scripts/copy-vendored-engram-live.mjs",
|
|
49
51
|
"prepack": "node ../../scripts/check-focus-app.mjs && chmod +x dist/bin.js"
|
|
50
52
|
},
|
|
51
53
|
"dependencies": {
|
|
@@ -55,10 +57,11 @@
|
|
|
55
57
|
"@sentropic/cluster-mesh": "0.13.3",
|
|
56
58
|
"@sentropic/contracts": "^0.3.0",
|
|
57
59
|
"@sentropic/events": "^0.2.0",
|
|
58
|
-
"@sentropic/track": "
|
|
59
|
-
"hono": "^4.13.7"
|
|
60
|
+
"@sentropic/track": "0.98.7-rc.1",
|
|
61
|
+
"hono": "^4.13.7",
|
|
62
|
+
"toml-eslint-parser": "0.10.1"
|
|
60
63
|
},
|
|
61
64
|
"peerDependencies": {
|
|
62
|
-
"@sentropic/h2a-runtime": "
|
|
65
|
+
"@sentropic/h2a-runtime": "0.98.7-rc.1"
|
|
63
66
|
}
|
|
64
67
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// An execution is one app-server session (forks included), or the CLI probe.
|
|
2
|
+
// An iteration is one model call, INCLUDING delegated subagent model calls.
|
|
3
|
+
export const VIBE_QUALIFICATION_PLAN = Object.freeze([
|
|
4
|
+
{ id: 'phase1-a', phase: 1, kind: 'session', maxIterations: 3, maxPrice: 0.15, maxSeconds: 90 },
|
|
5
|
+
{ id: 'phase1-b', phase: 1, kind: 'session', maxIterations: 3, maxPrice: 0.15, maxSeconds: 90 },
|
|
6
|
+
{ id: 'cli-probe', phase: 1, kind: 'probe', maxIterations: 3, maxPrice: 0.10, maxSeconds: 90 },
|
|
7
|
+
{ id: 'phase2-origin', phase: 2, kind: 'session', maxIterations: 3, maxPrice: 0.175, maxSeconds: 90 },
|
|
8
|
+
{ id: 'phase2-copy', phase: 2, kind: 'session', maxIterations: 3, maxPrice: 0.175, maxSeconds: 90 }
|
|
9
|
+
]);
|
|
10
|
+
export const PHASE2_RECIPE = Object.freeze({
|
|
11
|
+
fork: { method: 'session/fork', attach: false, beforeDelegation: true },
|
|
12
|
+
origin: ['parent requests delegation', 'child model call', 'parent final answer'],
|
|
13
|
+
copy: ['request benign command', 'request guarded CLI command', 'final answer'],
|
|
14
|
+
oracle: 'Origin delegates AFTER fork: one record proves both parent-only behavior and the still-active origin. Copy: allow/deny and no record modification.',
|
|
15
|
+
scope: 'session/fork attaches by default; this recipe explicitly passes attach:false. /branch and ACP forks attach on resume.'
|
|
16
|
+
});
|
|
17
|
+
export class QualificationBudget {
|
|
18
|
+
constructor(plan = VIBE_QUALIFICATION_PLAN, now = Date.now) {
|
|
19
|
+
this.plan = plan; this.now = now; this.start = now(); this.executions = new Map();
|
|
20
|
+
if (plan.length > 5 || plan.reduce((n, e) => n + e.maxIterations, 0) > 15 || plan.reduce((n, e) => n + e.maxPrice, 0) > 0.75000001 || plan.reduce((n, e) => n + e.maxSeconds, 0) > 450)
|
|
21
|
+
throw new Error('qualification envelope exceeded');
|
|
22
|
+
for (const e of plan) if (e.maxIterations > 3 || e.maxPrice > 0.25 || e.maxSeconds > 90) throw new Error('per-execution envelope exceeded');
|
|
23
|
+
for (const phase of [1, 2]) if (plan.filter(e => e.phase === phase && e.kind === 'session').length > 2) throw new Error('too many sessions, forks included');
|
|
24
|
+
}
|
|
25
|
+
begin(id) {
|
|
26
|
+
this.check();
|
|
27
|
+
if (this.executions.has(id)) throw new Error('automatic retry/restart forbidden');
|
|
28
|
+
const spec = this.plan.find(e => e.id === id); if (!spec) throw new Error('unbudgeted execution');
|
|
29
|
+
this.executions.set(id, { ...spec, started: this.now(), iterations: 0, price: 0 }); return spec;
|
|
30
|
+
}
|
|
31
|
+
restorePhase1(entries, elapsed) {
|
|
32
|
+
if (!Array.isArray(entries) || !Number.isFinite(elapsed) || elapsed < 0 || elapsed >= 450_000) throw new Error('invalid prior accounting');
|
|
33
|
+
const restored = new Map();
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const spec = this.plan.find(e => e.phase === 1 && e.id === entry.id);
|
|
36
|
+
if (!spec || restored.has(entry.id) || !Number.isInteger(entry.iterations) || entry.iterations < 0 || entry.iterations > spec.maxIterations ||
|
|
37
|
+
!Number.isFinite(entry.price) || entry.price < 0 || entry.price > spec.maxPrice + 1e-9) throw new Error('invalid prior accounting');
|
|
38
|
+
restored.set(entry.id, { ...spec, iterations: entry.iterations, price: entry.price, started: this.now() - elapsed });
|
|
39
|
+
}
|
|
40
|
+
if (restored.size !== this.plan.filter(e => e.phase === 1).length) throw new Error('incomplete prior accounting');
|
|
41
|
+
this.executions = restored; this.start = this.now() - elapsed;
|
|
42
|
+
}
|
|
43
|
+
remaining() { return Math.max(0, 450_000 - (this.now() - this.start)); }
|
|
44
|
+
account(id, { modelCalls, price }) {
|
|
45
|
+
if (!Number.isInteger(modelCalls) || modelCalls < 0 || !Number.isFinite(price) || price < 0) throw new Error('native accounting unavailable');
|
|
46
|
+
const e = this.executions.get(id); if (!e) throw new Error('unknown execution');
|
|
47
|
+
e.iterations += modelCalls; e.price += price;
|
|
48
|
+
if (e.iterations > e.maxIterations || e.price > e.maxPrice + 1e-9) throw new Error('native execution budget reached');
|
|
49
|
+
this.check(id);
|
|
50
|
+
}
|
|
51
|
+
check(id) {
|
|
52
|
+
if (this.now() - this.start >= 450_000) throw new Error('global watchdog reached');
|
|
53
|
+
const e = this.executions.get(id);
|
|
54
|
+
if (e && this.now() - e.started >= e.maxSeconds * 1000) throw new Error('execution watchdog reached');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { listDrumbeat } from '../dist/runtime/drumbeat/index.js';
|
|
3
|
+
export function ownedRecordSnapshot(root, instance) {
|
|
4
|
+
const records = listDrumbeat(root);
|
|
5
|
+
return { own: records.find(r => r.instance === instance), others: records.filter(r => r.instance !== instance) };
|
|
6
|
+
}
|
|
7
|
+
export function clearQualificationRecords({ root, instances, entrypoint, env, run = spawnSync }) {
|
|
8
|
+
const before = listDrumbeat(root).filter(r => !instances.includes(r.instance));
|
|
9
|
+
for (const instance of instances) {
|
|
10
|
+
const child = run(process.execPath, [entrypoint, 'drumbeat', 'clear', '--instance', instance, '--root', root], { env, encoding: 'utf8', timeout: 5000, maxBuffer: 65536 });
|
|
11
|
+
if (child.status !== 0) throw new Error('qualification drumbeat cleanup failed');
|
|
12
|
+
}
|
|
13
|
+
const after = listDrumbeat(root);
|
|
14
|
+
if (after.some(r => instances.includes(r.instance)) || JSON.stringify(after) !== JSON.stringify(before)) throw new Error('qualification cleanup verification failed');
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Only native structured effects count. An assistant's prose is never evidence.
|
|
2
|
+
export function inspectSkillEntries(entries, requiredResources) {
|
|
3
|
+
return requiredResources.length > 0 && requiredResources.every(resource => entries.some(e => e.type === 'effect' && e.state?.status === 'completed' &&
|
|
4
|
+
(e.detail?.input?.path === resource.path || e.detail?.input?.filePath === resource.path) &&
|
|
5
|
+
(e.state?.output?.content === resource.bytes || e.state?.outputText === resource.bytes)));
|
|
6
|
+
}
|
|
7
|
+
export function inspectGuardEntries(entries, sessionId) {
|
|
8
|
+
const notices = entries.filter(e => e.sessionId === sessionId && e.type === 'notice').map(e => e.detail);
|
|
9
|
+
const refused = notices.find(n => n.kind === 'hook_completed' && n.scope === 'pre_tool' && n.hookName === 'sentropic-h2a-deny-manual-cli' && n.status === 'error' && n.toolCallId);
|
|
10
|
+
const targets = entries.filter(e => e.sessionId === sessionId && e.type === 'effect' && e.detail?.input?.command === 'h2a --version');
|
|
11
|
+
if (targets.some(e => e.state?.status === 'completed') || !targets.some(e => e.id === refused?.toolCallId && ['failed', 'skipped', 'cancelled'].includes(e.state?.status))) return false;
|
|
12
|
+
const benign = entries.find(e => e.sessionId === sessionId && e.type === 'effect' && e.detail?.input?.command === 'printf vibe-guard-ok' && e.state?.status === 'completed');
|
|
13
|
+
const id = benign?.id;
|
|
14
|
+
const started = notices.some(n => n.kind === 'hook_run_started' && n.scope === 'pre_tool' && n.toolCallId === id);
|
|
15
|
+
const completed = notices.some(n => n.kind === 'hook_run_completed' && n.scope === 'pre_tool' && n.toolCallId === id);
|
|
16
|
+
const degraded = notices.some(n => n.scope === 'pre_tool' && n.toolCallId === id && n.status === 'warning');
|
|
17
|
+
return !!refused && !!benign && started && completed && !degraded;
|
|
18
|
+
}
|
|
19
|
+
export function inspectLifecycleEntries(entries, origin, copy, recordEvidence) {
|
|
20
|
+
const post = entries.filter(e => e.type === 'notice' && e.detail?.scope === 'post_agent');
|
|
21
|
+
const nativeParentRun = post.some(e => e.sessionId === origin && e.detail.kind === 'hook_run_started') && post.some(e => e.sessionId === origin && e.detail.kind === 'hook_run_completed');
|
|
22
|
+
const delegated = entries.some(e => e.sessionId === origin && e.type === 'effect' && e.detail?.kind === 'subagent' && e.state?.status === 'completed');
|
|
23
|
+
return nativeParentRun && delegated && !!copy && recordEvidence.parentRecordPresent && recordEvidence.lifecycleRecordUnchangedInFork && recordEvidence.recordedAfterParent &&
|
|
24
|
+
recordEvidence.childRecordUntouched === true && recordEvidence.parentRecordTransitions === 1 && recordEvidence.otherInstancesUntouched === true;
|
|
25
|
+
}
|
|
26
|
+
export function inspectMcpEntries(entries, sessionId, { busRoot, workspace }) {
|
|
27
|
+
const effects = entries.filter(e => e.sessionId === sessionId && e.type === 'effect' && e.state?.status === 'completed');
|
|
28
|
+
const decode = e => {
|
|
29
|
+
let output = e.state.output;
|
|
30
|
+
if (output?.isError) return undefined;
|
|
31
|
+
if (output?.content) output = output.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|
32
|
+
try { return typeof output === 'string' ? JSON.parse(output) : output; } catch { return undefined; }
|
|
33
|
+
};
|
|
34
|
+
const identity = effects.filter(e => /h2a_identity_status$/.test(e.detail?.toolName ?? '')).map(decode).find(Boolean);
|
|
35
|
+
const opened = effects.filter(e => /h2a_session_open$/.test(e.detail?.toolName ?? '')).map(decode).find(o => o?.session?.sessionId);
|
|
36
|
+
const track = effects.filter(e => /track_/.test(e.detail?.toolName ?? '')).map(decode);
|
|
37
|
+
const identityContext = identity?.identity ?? identity;
|
|
38
|
+
const correctBus = ['identity_ready', 'identity_disabled'].includes(identityContext?.state) && identityContext?.root === busRoot;
|
|
39
|
+
// Track's real status payload is {level, groups}, without a root field.
|
|
40
|
+
// Bind it to the workspace reported by the same native MCP server. A serve-empty
|
|
41
|
+
// hint or tool error cannot decode as a successful Track status observation.
|
|
42
|
+
const correctWorkspace = identityContext?.workspaceRoot === workspace && track.some(result => result?.level && Array.isArray(result.groups));
|
|
43
|
+
return { MCP: correctBus && !!opened, context: correctBus && correctWorkspace && !!opened, h2aSessionId: opened?.session.sessionId, instance: opened?.session.instance };
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Apply only the native public-history JSON patch dialect; never execute text.
|
|
2
|
+
export function patchNativeHistory(entry, operations) {
|
|
3
|
+
let value = structuredClone(entry);
|
|
4
|
+
for (const operation of operations) {
|
|
5
|
+
if (!['add', 'replace', 'append', 'remove', 'test'].includes(operation.op)) throw new Error('unsupported native patch');
|
|
6
|
+
const keys = operation.path.split('/').slice(1).map(k => k.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
7
|
+
if (keys.some(k => ['__proto__', 'constructor', 'prototype'].includes(k))) throw new Error('unsafe native patch');
|
|
8
|
+
if (!keys.length) { if (operation.op !== 'replace') throw new Error('unsupported root patch'); value = operation.value; continue; }
|
|
9
|
+
const last = keys.pop(), parent = keys.reduce((object, key) => object?.[key], value);
|
|
10
|
+
if (!parent || typeof parent !== 'object') throw new Error('native patch target missing');
|
|
11
|
+
if (operation.op === 'remove') { if (Array.isArray(parent)) parent.splice(Number(last), 1); else delete parent[last]; }
|
|
12
|
+
else if (operation.op === 'test') { if (JSON.stringify(parent[last]) !== JSON.stringify(operation.value)) throw new Error('native patch mismatch'); }
|
|
13
|
+
else if (operation.op === 'append') {
|
|
14
|
+
if (typeof parent[last] === 'string') parent[last] += operation.value;
|
|
15
|
+
else if (Array.isArray(parent[last])) parent[last].push(...operation.value);
|
|
16
|
+
else throw new Error('unsupported native append');
|
|
17
|
+
} else parent[last] = structuredClone(operation.value);
|
|
18
|
+
}
|
|
19
|
+
if (value.id !== entry.id || value.sessionId !== entry.sessionId) throw new Error('native patch identity changed');
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
// The spec verifies these discovery/import commands, but no bounded model
|
|
4
|
+
// invocation or hook equivalent for Muse/agy. Never substitute Gemini for agy.
|
|
5
|
+
export function qualifyPollHost({ host, env, cwd, evidence, run = spawnSync }) {
|
|
6
|
+
const commands = host === 'muse' ? [['muse', 'skills', 'list']] : [['agy', 'plugin', 'import', 'gemini'], ['agy', 'plugin', 'enable', 'h2a']];
|
|
7
|
+
const checks = [];
|
|
8
|
+
for (const [command, ...args] of commands) {
|
|
9
|
+
const result = run(command, args, { env, cwd, encoding: 'utf8', timeout: 15_000, maxBuffer: 1_048_576 });
|
|
10
|
+
const ok = result.status === 0;
|
|
11
|
+
checks.push({ command: [command, ...args], ok });
|
|
12
|
+
evidence.push({ method: 'qualification/native-discovery', command: [command, ...args], ok,
|
|
13
|
+
sha256: createHash('sha256').update(result.stdout ?? '').digest('hex') });
|
|
14
|
+
if (!ok) break;
|
|
15
|
+
}
|
|
16
|
+
return { skills: 'partial', MCP: 'partial', context: 'partial', guard: 'partial', lifecycle: 'partial',
|
|
17
|
+
import: host === 'agy' ? checks.length === 2 && checks.every(c => c.ok) ? 'observed-success' : 'failed' : 'N-A', checks,
|
|
18
|
+
limit: 'Native model/session accounting and usable session traces are not yet qualified. Discovery/import alone never qualifies skills, MCP or the M-B hook equivalents.' };
|
|
19
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { once } from 'node:events';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { openQualificationRpc } from './qualification-rpc.mjs';
|
|
7
|
+
import { nativeStats } from './qualification-vibe.mjs';
|
|
8
|
+
import { inspectSkillEntries, inspectMcpEntries } from './qualification-oracles.mjs';
|
|
9
|
+
export async function qualifyVibeProbe({ command = 'vibe', commandArgs = [], appServer = 'vibe-app-server', appServerArgs = [], cwd, env, tools, budget, signal, evidence, cleanup, directory, requiredResources = [], busRoot, components = ['skills', 'MCP', 'context'] }) {
|
|
10
|
+
if (!tools.length || tools.some(t => /bash|shell|exec|process|terminal|code|eval/i.test(t))) throw new Error('probe requires minimal native read-only tools; execution tools forbidden');
|
|
11
|
+
const allocation = budget.begin('cli-probe');
|
|
12
|
+
const needsMcp = components.some(c => c === 'MCP' || c === 'context');
|
|
13
|
+
const args = [...commandArgs, '-p', 'Read the installed H2A skill resources at ' + requiredResources.map(r => r.path).join(', ') +
|
|
14
|
+
(needsMcp ? '. Use h2a_identity_status, h2a_session_open and track_status with level=task and baselineCommit=qualification for this workspace.' : '') + ' Do not execute commands.', '--agent', 'auto-approve', '--enabled-tools', ...tools,
|
|
15
|
+
'--max-turns', String(allocation.maxIterations), '--max-price', String(allocation.maxPrice), '--output', 'streaming'];
|
|
16
|
+
const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
17
|
+
let raw = '', pending = '', malformed = false, error, rpc;
|
|
18
|
+
const ids = new Set(), entries = [];
|
|
19
|
+
const consume = line => {
|
|
20
|
+
if (!line.trim()) return;
|
|
21
|
+
try {
|
|
22
|
+
const entry = JSON.parse(line); entries.push(entry);
|
|
23
|
+
if (typeof entry.sessionId === 'string' && entry.sessionId) { ids.add(entry.sessionId); if (!cleanup.sessions.includes(entry.sessionId)) cleanup.sessions.push(entry.sessionId); }
|
|
24
|
+
} catch { malformed = true; }
|
|
25
|
+
};
|
|
26
|
+
child.stdout.setEncoding('utf8'); child.stdout.on('data', bytes => {
|
|
27
|
+
raw += bytes;
|
|
28
|
+
if (raw.length > 1_048_576) { malformed = true; child.kill('SIGTERM'); return; }
|
|
29
|
+
pending += bytes; let at;
|
|
30
|
+
while ((at = pending.indexOf('\n')) >= 0) { consume(pending.slice(0, at)); pending = pending.slice(at + 1); }
|
|
31
|
+
});
|
|
32
|
+
child.stderr.on('data', () => {}); child.on('error', e => { error = e; });
|
|
33
|
+
const abort = () => child.kill('SIGTERM'); signal?.addEventListener('abort', abort, { once: true });
|
|
34
|
+
const deadline = Math.min(allocation.maxSeconds * 1000, budget.remaining());
|
|
35
|
+
const watchdog = setTimeout(abort, deadline), force = setTimeout(() => child.kill('SIGKILL'), deadline + 2000);
|
|
36
|
+
try {
|
|
37
|
+
const [code] = await once(child, 'close'); consume(pending); pending = '';
|
|
38
|
+
if (error || code !== 0 || malformed || ids.size !== 1) throw new Error('native CLI probe incomplete or uncorrelated');
|
|
39
|
+
const [sessionId] = ids;
|
|
40
|
+
rpc = openQualificationRpc(appServer, appServerArgs, { cwd, env, signal });
|
|
41
|
+
await rpc.request('initialize', { clientInfo: { name: 'h2a-qualification-probe', version: '1' } }); rpc.notify('initialized');
|
|
42
|
+
const state = await rpc.request('session/read', { sessionId });
|
|
43
|
+
const stats = await rpc.request('stats/read', { sessionId }); budget.account('cli-probe', nativeStats(stats.stats));
|
|
44
|
+
const journal = state.state?.session?.id === sessionId && Array.isArray(state.state?.history) && state.state.history.length > 0;
|
|
45
|
+
const resources = inspectSkillEntries(entries, requiredResources), mcp = inspectMcpEntries(entries, sessionId, { busRoot, workspace: cwd });
|
|
46
|
+
const result = { status: journal && (!components.includes('skills') || resources) && (!components.includes('MCP') || mcp.MCP) && (!components.includes('context') || mcp.context) ? 'observed' : 'partial', sessionId, journal, resources, MCP: mcp.MCP, context: mcp.context };
|
|
47
|
+
evidence.push({ method: 'qualification/cli-probe', ...result, sha256: createHash('sha256').update(raw).digest('hex') }); return result;
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(watchdog); clearTimeout(force); signal?.removeEventListener('abort', abort);
|
|
50
|
+
if (child.pid && child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await once(child, 'close'); }
|
|
51
|
+
consume(pending);
|
|
52
|
+
// A failed CLI may create a session before emitting an entry. Never scan
|
|
53
|
+
// or delete unknown sessions; retain the private cleanup lane.
|
|
54
|
+
if (!ids.size || malformed) cleanup.incomplete = true;
|
|
55
|
+
if (ids.size) {
|
|
56
|
+
try {
|
|
57
|
+
if (!rpc) { rpc = openQualificationRpc(appServer, appServerArgs, { cwd, env }); await rpc.request('initialize', { clientInfo: { name: 'h2a-qualification-cleanup', version: '1' } }); rpc.notify('initialized'); }
|
|
58
|
+
for (const sessionId of ids) { await rpc.request('session/delete', { sessionId }); if (!cleanup.deleted.includes(sessionId)) cleanup.deleted.push(sessionId); }
|
|
59
|
+
} catch { cleanup.incomplete = true; }
|
|
60
|
+
finally { await rpc?.close(); }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
// Only this child is signalled. Unknown events never establish a qualification.
|
|
4
|
+
export function openQualificationRpc(command, args, { cwd, env, signal, observe = () => {}, serverRequest }) {
|
|
5
|
+
const child = spawn(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
6
|
+
let next = 0, buffer = '', failure;
|
|
7
|
+
const requests = new Map();
|
|
8
|
+
const fail = error => { failure = error; for (const request of requests.values()) request.reject(error); requests.clear(); };
|
|
9
|
+
child.on('error', fail); child.on('exit', () => fail(new Error('native app-server exited')));
|
|
10
|
+
child.stderr.on('data', () => {}); // Never persist stderr: native diagnostics can contain secrets.
|
|
11
|
+
child.stdout.setEncoding('utf8');
|
|
12
|
+
child.stdout.on('data', bytes => {
|
|
13
|
+
buffer += bytes;
|
|
14
|
+
if (buffer.length > 1_048_576) { fail(new Error('native frame bound exceeded')); child.kill('SIGTERM'); return; }
|
|
15
|
+
let index;
|
|
16
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
17
|
+
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
18
|
+
try {
|
|
19
|
+
const message = JSON.parse(line);
|
|
20
|
+
if (message.id !== undefined && requests.has(message.id)) {
|
|
21
|
+
const request = requests.get(message.id); requests.delete(message.id);
|
|
22
|
+
if (message.error) request.reject(new Error('native RPC refused ' + request.method)); else request.resolve(message.result);
|
|
23
|
+
} else if (message.id !== undefined) {
|
|
24
|
+
if (serverRequest) {
|
|
25
|
+
Promise.resolve(serverRequest(message)).then(result => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }) + '\n'), () => {
|
|
26
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32000, message: 'qualification request refused' } }) + '\n');
|
|
27
|
+
});
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// Unknown approvals are always refused; no blanket tool permission.
|
|
31
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32000, message: 'qualification approval not authorized' } }) + '\n');
|
|
32
|
+
observe({ method: 'qualification/approval-refused' });
|
|
33
|
+
} else observe(message);
|
|
34
|
+
} catch { fail(new Error('invalid native NDJSON frame')); }
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const abort = () => { fail(new Error('qualification interrupted')); child.kill('SIGTERM'); };
|
|
38
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
39
|
+
return {
|
|
40
|
+
child,
|
|
41
|
+
notify(method, params = {}) { child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); },
|
|
42
|
+
async request(method, params = {}) {
|
|
43
|
+
if (failure) throw failure;
|
|
44
|
+
const id = ++next;
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const timer = setTimeout(() => { requests.delete(id); reject(new Error('native RPC timeout')); }, 15_000);
|
|
47
|
+
requests.set(id, { method, resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
|
|
48
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
async close() {
|
|
52
|
+
signal?.removeEventListener('abort', abort); fail(new Error('qualification closed'));
|
|
53
|
+
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
|
|
54
|
+
const exited = once(child, 'exit'); child.stdin.end(); child.kill('SIGTERM');
|
|
55
|
+
const force = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
56
|
+
try { await exited; } finally { clearTimeout(force); }
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { openQualificationRpc } from './qualification-rpc.mjs';
|
|
5
|
+
import { PHASE2_RECIPE } from './qualification-budget.mjs';
|
|
6
|
+
import { inspectGuardEntries, inspectLifecycleEntries, inspectMcpEntries, inspectSkillEntries } from './qualification-oracles.mjs';
|
|
7
|
+
import { patchNativeHistory } from './qualification-patch.mjs';
|
|
8
|
+
const sha = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
9
|
+
export function nativeStats(stats) {
|
|
10
|
+
if (!Number.isInteger(stats?.steps)) throw new Error('native iteration accounting unavailable');
|
|
11
|
+
const { sessionPromptTokens: prompt, sessionCompletionTokens: completion, sessionCachedTokens: cached,
|
|
12
|
+
inputPricePerMillion: input, outputPricePerMillion: output, cachedInputPricePerMillion: cachePrice } = stats;
|
|
13
|
+
if (![prompt, completion, cached, input, output].every(n => Number.isFinite(n) && n >= 0) || cached > prompt || cachePrice != null && (!Number.isFinite(cachePrice) || cachePrice < 0)) throw new Error('native price accounting unavailable');
|
|
14
|
+
return { modelCalls: stats.steps, price: ((prompt - cached) * input + cached * (cachePrice ?? input) + completion * output) / 1_000_000 };
|
|
15
|
+
}
|
|
16
|
+
export async function qualifyVibe({ command = 'vibe-app-server', args = [], env, workspaces, busRoot, phase, budget, signal, evidence, cleanup, components, requiredResources = [], clearRecords = () => {}, recordSnapshot = () => undefined }) {
|
|
17
|
+
const sessions = new Map(), history = new Map(), turns = new Map(), completedTurns = new Map(), calls = [];
|
|
18
|
+
let rpc;
|
|
19
|
+
let lifecycleOrigin, recordBaseline, parentHookStarted, childRecordUntouched = false, parentRecordTransitions = 0, parentAfter;
|
|
20
|
+
const accounted = new Map();
|
|
21
|
+
const account = (sessionId, stats) => {
|
|
22
|
+
const execution = sessions.get(sessionId); if (!execution) throw new Error('unmapped native statistics');
|
|
23
|
+
const current = nativeStats(stats), previous = accounted.get(sessionId) ?? { modelCalls: 0, price: 0 };
|
|
24
|
+
budget.account(execution.id, { modelCalls: current.modelCalls - previous.modelCalls, price: current.price - previous.price }); accounted.set(sessionId, current);
|
|
25
|
+
};
|
|
26
|
+
const onEvent = message => {
|
|
27
|
+
const p = message.params ?? {};
|
|
28
|
+
if (p.entry) { const entries = history.get(p.sessionId) ?? new Map(); entries.set(p.entry.id, p.entry); history.set(p.sessionId, entries); }
|
|
29
|
+
if (p.entryId && p.patch) {
|
|
30
|
+
const entries = history.get(p.sessionId), entry = entries?.get(p.entryId);
|
|
31
|
+
if (entry) try { entries.set(p.entryId, patchNativeHistory(entry, p.patch)); } catch { entries.delete(p.entryId); }
|
|
32
|
+
}
|
|
33
|
+
if (p.childSession && sessions.has(p.sessionId)) {
|
|
34
|
+
sessions.set(p.childSession.id, sessions.get(p.sessionId)); cleanup.sessions.push(p.childSession.id);
|
|
35
|
+
if (p.sessionId === lifecycleOrigin && p.childSession.status?.type === 'idle') childRecordUntouched = JSON.stringify(recordSnapshot()) === JSON.stringify(recordBaseline);
|
|
36
|
+
}
|
|
37
|
+
if (message.method === 'stats/updated' && sessions.has(p.sessionId)) try { account(p.sessionId, p.stats); } catch { rpc.child.kill('SIGTERM'); }
|
|
38
|
+
const detail = p.entry?.detail;
|
|
39
|
+
if (p.sessionId === lifecycleOrigin && detail?.scope === 'post_agent') {
|
|
40
|
+
if (detail.kind === 'hook_run_started') {
|
|
41
|
+
parentHookStarted = Date.now();
|
|
42
|
+
if (JSON.stringify(recordSnapshot()) !== JSON.stringify(recordBaseline)) childRecordUntouched = false;
|
|
43
|
+
}
|
|
44
|
+
if (detail.kind === 'hook_run_completed') {
|
|
45
|
+
const current = recordSnapshot();
|
|
46
|
+
if (JSON.stringify(current?.own) !== JSON.stringify(parentAfter?.own)) parentRecordTransitions++;
|
|
47
|
+
parentAfter = current;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (message.method === 'turn/completed') { completedTurns.set(p.turn?.id, p.turn); turns.get(p.turn?.id)?.(p.turn); }
|
|
51
|
+
evidence.push({ method: message.method, sessionId: p.sessionId, eventId: p.eventId, sha256: sha(message) });
|
|
52
|
+
};
|
|
53
|
+
rpc = openQualificationRpc(command, args, { cwd: workspaces[0], env, signal, observe: onEvent,
|
|
54
|
+
serverRequest: async message => {
|
|
55
|
+
if (message.method !== 'callback/call') throw new Error('unexpected server request');
|
|
56
|
+
const cb = message.params.callback, effect = cb.detail?.effect, command = effect?.input?.command;
|
|
57
|
+
const resourceRead = effect?.kind === 'file_read' && ['read_file', 'file_system.read_file', 'file_read'].includes(effect.toolName) &&
|
|
58
|
+
requiredResources.some(r => [effect.input?.path, effect.input?.filePath].includes(r.path));
|
|
59
|
+
const expectedCommand = ['shell', 'process'].includes(effect?.kind) && ['bash', 'file_system.bash', 'process_start', 'process.start'].includes(effect.toolName) &&
|
|
60
|
+
['printf vibe-guard-ok', 'h2a --version'].includes(command);
|
|
61
|
+
const allowed = cb.detail?.kind === 'approval' && (expectedCommand || resourceRead) && sessions.has(cb.sessionId);
|
|
62
|
+
queueMicrotask(() => rpc.request('callback/respond', { sessionId: cb.sessionId, callbackId: cb.callbackId,
|
|
63
|
+
output: { type: 'approval', decision: { type: allowed ? 'approve' : 'deny' } } }).catch(() => {}));
|
|
64
|
+
evidence.push({ method: 'qualification/approval', sessionId: cb.sessionId, allowed, sha256: sha(cb.detail) });
|
|
65
|
+
return { callbackId: cb.callbackId, accepted: true };
|
|
66
|
+
} });
|
|
67
|
+
const start = async (id, cwd) => {
|
|
68
|
+
const allocation = budget.begin(id), started = Date.now();
|
|
69
|
+
const agentConfig = { cwd, headless: true, maxTurns: allocation.maxIterations, maxPrice: allocation.maxPrice };
|
|
70
|
+
const result = await rpc.request('session/start', { agentConfig });
|
|
71
|
+
const session = result.state?.session;
|
|
72
|
+
if (!session?.id || session.cwd !== cwd || session.parentSessionId !== null) throw new Error('native session context mismatch');
|
|
73
|
+
sessions.set(session.id, { id, allocation, started, cwd }); cleanup.sessions.push(session.id);
|
|
74
|
+
return session.id;
|
|
75
|
+
};
|
|
76
|
+
const turn = async (sessionId, text) => {
|
|
77
|
+
const execution = sessions.get(sessionId);
|
|
78
|
+
const watchdog = setTimeout(() => rpc.child.kill('SIGTERM'), Math.max(1, Math.min(budget.remaining(), execution.allocation.maxSeconds * 1000 - (Date.now() - budget.executions.get(execution.id).started))));
|
|
79
|
+
try {
|
|
80
|
+
const result = await rpc.request('turn/start', { sessionId, message: [{ type: 'text', text }] });
|
|
81
|
+
const completed = completedTurns.has(result.turn.id) ? Promise.resolve(completedTurns.get(result.turn.id)) : new Promise(resolve => turns.set(result.turn.id, resolve));
|
|
82
|
+
let onExit;
|
|
83
|
+
const died = new Promise((_, reject) => { onExit = () => reject(new Error('native turn ended without completion')); rpc.child.once('exit', onExit); });
|
|
84
|
+
const done = await Promise.race([completed, died]).finally(() => rpc.child.removeListener('exit', onExit)); turns.delete(result.turn.id);
|
|
85
|
+
if (done.status !== 'completed') throw new Error('native turn incomplete');
|
|
86
|
+
const stats = await rpc.request('stats/read', { sessionId });
|
|
87
|
+
account(sessionId, stats.stats);
|
|
88
|
+
const state = await rpc.request('session/read', { sessionId });
|
|
89
|
+
// Delegated calls are included; unavailable child accounting is partial.
|
|
90
|
+
for (const child of state.state?.childSessions ?? []) {
|
|
91
|
+
cleanup.sessions.push(child.id);
|
|
92
|
+
const childStats = await rpc.request('stats/read', { sessionId: child.id });
|
|
93
|
+
sessions.set(child.id, execution); account(child.id, childStats.stats);
|
|
94
|
+
}
|
|
95
|
+
return [...(history.get(sessionId)?.values() ?? [])];
|
|
96
|
+
} finally { clearTimeout(watchdog); }
|
|
97
|
+
};
|
|
98
|
+
const result = { skills: 'partial', MCP: 'partial', context: 'partial', guard: 'partial', lifecycle: 'partial', sessions: [], runtimeVersion: undefined };
|
|
99
|
+
const mcpContexts = [], skillContexts = [];
|
|
100
|
+
try {
|
|
101
|
+
const initialized = await rpc.request('initialize', { clientInfo: { name: 'h2a-qualification', version: '1' }, capabilities: { callbackKinds: ['approval'] } });
|
|
102
|
+
result.runtimeVersion = initialized.serverInfo?.version; rpc.notify('initialized');
|
|
103
|
+
if (phase === 1) {
|
|
104
|
+
for (const [i, id] of ['phase1-a', 'phase1-b'].entries()) {
|
|
105
|
+
const sessionId = await start(id, workspaces[i]); result.sessions.push(sessionId);
|
|
106
|
+
const skills = await rpc.request('skills/list', { sessionId });
|
|
107
|
+
const runtime = await rpc.request('runtime/read', { sessionId });
|
|
108
|
+
result.probeTools = (runtime.runtime?.tools ?? []).map(t => t.name).filter(name => ['read_file', 'file_system.read_file', 'file_read'].includes(name) || /(?:h2a_identity_status|h2a_session_open|track_status)$/.test(name));
|
|
109
|
+
const names = (skills.skills ?? []).filter(s => s.enabled && s.source === 'local').map(s => s.name);
|
|
110
|
+
evidence.push({ method: 'qualification/skills', sessionId, names, runtimeHash: sha(runtime.runtime) });
|
|
111
|
+
if (components.some(c => ['skills', 'MCP', 'context'].includes(c))) {
|
|
112
|
+
const prompt = 'Use the installed H2A skill. Read the required skill resources at ' + requiredResources.map(r => r.path).join(', ') +
|
|
113
|
+
(components.some(c => ['MCP', 'context'].includes(c)) ? '. Use MCP tools h2a_identity_status and h2a_session_open to report this session identity, bus root and workspace, then track_status with level=task and baselineCommit=qualification for this workspace.' : '') + ' Do not run shell commands.';
|
|
114
|
+
const entries = await turn(sessionId, prompt);
|
|
115
|
+
calls.push(...entries); mcpContexts.push(inspectMcpEntries(entries, sessionId, { busRoot, workspace: workspaces[i] }));
|
|
116
|
+
skillContexts.push(names.includes('h2a') && names.includes('track-operation') && inspectSkillEntries(entries, requiredResources));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (skillContexts.length === 2 && skillContexts.every(Boolean)) result.skills = 'observed';
|
|
120
|
+
if (mcpContexts.length === 2 && mcpContexts.every(c => c.MCP)) result.MCP = 'observed';
|
|
121
|
+
if (mcpContexts.length === 2 && mcpContexts.every(c => c.context) && mcpContexts[0].h2aSessionId !== mcpContexts[1].h2aSessionId) result.context = 'observed';
|
|
122
|
+
} else {
|
|
123
|
+
const origin = await start('phase2-origin', workspaces[0]); result.sessions.push(origin);
|
|
124
|
+
lifecycleOrigin = origin; recordBaseline = recordSnapshot();
|
|
125
|
+
const allocation = budget.begin('phase2-copy');
|
|
126
|
+
const fork = await rpc.request('session/fork', { sourceSessionId: origin, attach: PHASE2_RECIPE.fork.attach });
|
|
127
|
+
const copy = fork.state?.session;
|
|
128
|
+
if (!copy?.id || copy.parentSessionId !== origin) throw new Error('native fork context mismatch');
|
|
129
|
+
cleanup.sessions.push(copy.id); result.sessions.push(copy.id);
|
|
130
|
+
const originEntries = await turn(origin, 'Delegate exactly one brief read-only task to one subagent, then answer once. Do not use shell or mutate files.');
|
|
131
|
+
const parentRecord = recordSnapshot();
|
|
132
|
+
await rpc.request('session/resume', { sessionId: copy.id, agentConfig: { cwd: workspaces[0], headless: true, maxTurns: allocation.maxIterations, maxPrice: allocation.maxPrice } });
|
|
133
|
+
sessions.set(copy.id, { id: allocation.id, allocation, started: Date.now(), cwd: workspaces[0] });
|
|
134
|
+
const copyEntries = await turn(copy.id, 'Run exactly printf vibe-guard-ok, then request h2a --version to exercise the installed guard, then answer once. Do not run other commands.');
|
|
135
|
+
result.lifecycleRecordUnchangedInFork = JSON.stringify(parentRecord) === JSON.stringify(recordSnapshot());
|
|
136
|
+
result.parentRecordPresent = !!parentRecord?.own;
|
|
137
|
+
result.recordedAfterParent = !!parentHookStarted && !!parentRecord?.own?.stoppedAt && Date.parse(parentRecord.own.stoppedAt) >= parentHookStarted;
|
|
138
|
+
result.childRecordUntouched = childRecordUntouched; result.parentRecordTransitions = parentRecordTransitions;
|
|
139
|
+
result.otherInstancesUntouched = JSON.stringify(parentRecord?.others) === JSON.stringify(recordBaseline?.others);
|
|
140
|
+
calls.push(...originEntries, ...copyEntries);
|
|
141
|
+
if (inspectGuardEntries(copyEntries, copy.id)) result.guard = 'observed';
|
|
142
|
+
if (inspectLifecycleEntries(calls, origin, copy.id, result)) result.lifecycle = 'observed';
|
|
143
|
+
}
|
|
144
|
+
result.observedEntries = calls.map(e => ({ type: e.type, sessionId: e.sessionId, sha256: sha(e), detail: e.type === 'notice' ? { kind: e.detail?.kind, scope: e.detail?.scope, hookName: e.detail?.hookName, status: e.detail?.status, toolCallId: e.detail?.toolCallId } : undefined }));
|
|
145
|
+
return result;
|
|
146
|
+
} finally {
|
|
147
|
+
// Stop producers before deleting only recorded qualification sessions.
|
|
148
|
+
for (const id of [...new Set(cleanup.sessions)].reverse()) {
|
|
149
|
+
try { await rpc.request('session/stop', { sessionId: id }); }
|
|
150
|
+
catch { cleanup.incomplete = true; }
|
|
151
|
+
}
|
|
152
|
+
try { await clearRecords(); } catch { cleanup.incomplete = true; }
|
|
153
|
+
for (const id of [...new Set(cleanup.sessions)].reverse()) {
|
|
154
|
+
try { await rpc.request('session/delete', { sessionId: id }); cleanup.deleted.push(id); }
|
|
155
|
+
catch { cleanup.incomplete = true; }
|
|
156
|
+
}
|
|
157
|
+
await rpc.close();
|
|
158
|
+
}
|
|
159
|
+
}
|