@bobobowis/jev-kit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -2
  3. package/dist/capabilities.d.ts +33 -0
  4. package/dist/capabilities.js +81 -0
  5. package/dist/cli/ask.d.ts +9 -0
  6. package/dist/cli/ask.js +210 -0
  7. package/dist/cli/common.d.ts +38 -0
  8. package/dist/cli/common.js +40 -0
  9. package/dist/cli/hook.d.ts +3 -0
  10. package/dist/cli/hook.js +82 -0
  11. package/dist/cli/index.d.ts +2 -0
  12. package/dist/cli/index.js +21 -0
  13. package/dist/cli/main.d.ts +8 -0
  14. package/dist/cli/main.js +41 -0
  15. package/dist/cli/mcp.d.ts +3 -0
  16. package/dist/cli/mcp.js +47 -0
  17. package/dist/cli/setup.d.ts +3 -0
  18. package/dist/cli/setup.js +79 -0
  19. package/dist/compact/compact-on-demand.d.ts +22 -0
  20. package/dist/compact/compact-on-demand.js +29 -0
  21. package/dist/compact/evaluate.d.ts +36 -0
  22. package/dist/compact/evaluate.js +31 -0
  23. package/dist/compact/index.d.ts +7 -0
  24. package/dist/compact/index.js +7 -0
  25. package/dist/compact/pick-cut-point.d.ts +18 -0
  26. package/dist/compact/pick-cut-point.js +29 -0
  27. package/dist/compact/should-compact.d.ts +57 -0
  28. package/dist/compact/should-compact.js +87 -0
  29. package/dist/core/client.d.ts +93 -0
  30. package/dist/core/client.js +295 -0
  31. package/dist/core/helpers.d.ts +14 -0
  32. package/dist/core/helpers.js +9 -0
  33. package/dist/core/mock.d.ts +20 -0
  34. package/dist/core/mock.js +132 -0
  35. package/dist/core/types.d.ts +102 -0
  36. package/dist/core/types.js +82 -0
  37. package/dist/events/index.d.ts +44 -0
  38. package/dist/events/index.js +77 -0
  39. package/dist/files/ask-file-bool.d.ts +12 -0
  40. package/dist/files/ask-file-bool.js +15 -0
  41. package/dist/files/ask-file-choice.d.ts +10 -0
  42. package/dist/files/ask-file-choice.js +17 -0
  43. package/dist/files/ask-file-score.d.ts +12 -0
  44. package/dist/files/ask-file-score.js +15 -0
  45. package/dist/files/ask-files.d.ts +23 -0
  46. package/dist/files/ask-files.js +56 -0
  47. package/dist/files/ask.d.ts +12 -0
  48. package/dist/files/ask.js +28 -0
  49. package/dist/files/assemble.d.ts +52 -0
  50. package/dist/files/assemble.js +129 -0
  51. package/dist/files/decide.d.ts +10 -0
  52. package/dist/files/decide.js +1 -0
  53. package/dist/files/index.d.ts +14 -0
  54. package/dist/files/index.js +14 -0
  55. package/dist/files/pick-first.d.ts +16 -0
  56. package/dist/files/pick-first.js +24 -0
  57. package/dist/files/prune.d.ts +13 -0
  58. package/dist/files/prune.js +87 -0
  59. package/dist/files/read-state.d.ts +17 -0
  60. package/dist/files/read-state.js +47 -0
  61. package/dist/guards/bash-gate.d.ts +29 -0
  62. package/dist/guards/bash-gate.js +35 -0
  63. package/dist/guards/index.d.ts +6 -0
  64. package/dist/guards/index.js +6 -0
  65. package/dist/guards/result-screen.d.ts +20 -0
  66. package/dist/guards/result-screen.js +31 -0
  67. package/dist/guards/write-gate.d.ts +18 -0
  68. package/dist/guards/write-gate.js +44 -0
  69. package/dist/hooks/handle.d.ts +62 -0
  70. package/dist/hooks/handle.js +48 -0
  71. package/dist/hooks/harness.d.ts +40 -0
  72. package/dist/hooks/harness.js +116 -0
  73. package/dist/hooks/index.d.ts +7 -0
  74. package/dist/hooks/index.js +7 -0
  75. package/dist/hooks/state.d.ts +8 -0
  76. package/dist/hooks/state.js +25 -0
  77. package/dist/hooks/transcript.d.ts +8 -0
  78. package/dist/hooks/transcript.js +52 -0
  79. package/dist/index.d.ts +8 -0
  80. package/dist/index.js +8 -0
  81. package/dist/ledger.d.ts +22 -0
  82. package/dist/ledger.js +26 -0
  83. package/dist/mcp/server.d.ts +28 -0
  84. package/dist/mcp/server.js +124 -0
  85. package/dist/policy/index.d.ts +17 -0
  86. package/dist/policy/index.js +30 -0
  87. package/dist/setup/index.d.ts +67 -0
  88. package/dist/setup/index.js +209 -0
  89. package/dist/tools/ask-jev.d.ts +7 -0
  90. package/dist/tools/ask-jev.js +42 -0
  91. package/dist/tools/compact-tool.d.ts +2 -0
  92. package/dist/tools/compact-tool.js +33 -0
  93. package/dist/tools/description.d.ts +5 -0
  94. package/dist/tools/description.js +20 -0
  95. package/dist/tools/file-tools.d.ts +7 -0
  96. package/dist/tools/file-tools.js +127 -0
  97. package/dist/tools/index.d.ts +10 -0
  98. package/dist/tools/index.js +10 -0
  99. package/dist/tools/run-command.d.ts +7 -0
  100. package/dist/tools/run-command.js +27 -0
  101. package/dist/tools/sets.d.ts +9 -0
  102. package/dist/tools/sets.js +32 -0
  103. package/dist/tools/types.d.ts +51 -0
  104. package/dist/tools/types.js +4 -0
  105. package/package.json +96 -3
@@ -0,0 +1,67 @@
1
+ import { type Guard } from "../hooks/handle.ts";
2
+ export type SetupHarness = "claude" | "codex";
3
+ export interface SetupOptions {
4
+ harness: SetupHarness;
5
+ /** The project directory. */
6
+ dir: string;
7
+ guards?: Guard[];
8
+ tools?: string[];
9
+ /** Run the Level 7 advisor hooks. */
10
+ compact?: boolean;
11
+ /** A lab level: 6 means the guards, 7 the advisor and should_i_compact, 8 to 10 their tools. */
12
+ level?: number;
13
+ /** How the harness runs this package's bin. Defaults to node and its absolute path. */
14
+ command?: string[];
15
+ /** Extra `jev hook` flags, for example --lines or --window. */
16
+ hookArgs?: string[];
17
+ /** Hook timeout in seconds, as the harness counts it. */
18
+ timeoutSec?: number;
19
+ }
20
+ export interface FileChange {
21
+ path: string;
22
+ before: string | null;
23
+ after: string;
24
+ changed: boolean;
25
+ }
26
+ export declare class SetupError extends Error {
27
+ }
28
+ /**
29
+ * The bin this module ships with: dist/cli/index.js next to dist/setup/. Run through npx or
30
+ * pnpm dlx, it is `npx -y` on this exact version instead, which outlives the cache.
31
+ */
32
+ export declare const defaultCommand: (bin?: string) => string[];
33
+ export declare const isJevHookCommand: (command: unknown) => boolean;
34
+ export interface Resolved {
35
+ guards: Guard[];
36
+ tools: string[];
37
+ compact: boolean;
38
+ }
39
+ export declare function resolveSelection(o: Pick<SetupOptions, "guards" | "tools" | "compact" | "level">): Resolved;
40
+ interface HookGroup {
41
+ matcher?: string;
42
+ hooks: {
43
+ type: "command";
44
+ command: string;
45
+ timeout?: number;
46
+ }[];
47
+ }
48
+ /** The hook entries, by event, that a selection needs. Same shape for Claude Code and Codex. */
49
+ export declare function hookGroups(harness: SetupHarness, sel: Resolved, command: string[], extra?: string[], timeoutSec?: number): Record<string, HookGroup[]>;
50
+ /** Drop every hook this tool wrote before, then add the new groups. Unrelated hooks stay in place. */
51
+ export declare function mergeHooks(existing: Record<string, HookGroup[]> | undefined, ours: Record<string, HookGroup[]>): Record<string, HookGroup[]>;
52
+ /** Claude Code: `.mcp.json` for the tools, `.claude/settings.json` for the hooks. */
53
+ export declare function planClaude(o: SetupOptions): FileChange[];
54
+ /** What the MCP server needs from the agent's environment. */
55
+ export declare const MCP_ENV_VARS: string[];
56
+ /** Our block of `.codex/config.toml`: the hooks as arrays of tables, the server as one table. */
57
+ export declare function codexBlock(sel: Resolved, command: string[], extra?: string[], timeoutSec?: number): string;
58
+ /**
59
+ * Codex: `.codex/config.toml`, loaded once the project is trusted. There is no TOML parser here, so
60
+ * the file is not rewritten: our entries live in one marked block at the end, replaced on every run.
61
+ */
62
+ export declare function planCodex(o: SetupOptions): FileChange[];
63
+ /** One line for people: what the selection turns on. */
64
+ export declare function describeSelection(o: Pick<SetupOptions, "guards" | "tools" | "compact" | "level">): string;
65
+ export declare function plan(o: SetupOptions): FileChange[];
66
+ export declare function apply(changes: FileChange[]): void;
67
+ export {};
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Wire Jev into a project's agent config: the MCP server for the tools, hook entries for the guards
3
+ * and the compaction advisor. Merges into what is there. Entries it wrote before are recognized by
4
+ * their marker (the `jev` server name, hook commands running `hook --harness <name>`) and replaced;
5
+ * everything else is left as it was. A file that is not valid JSON is refused, never overwritten.
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { ALL_GUARDS } from "../hooks/handle.js";
11
+ import { LEVEL_TOOLS, selectTools } from "../tools/sets.js";
12
+ export class SetupError extends Error {
13
+ }
14
+ /** npx and pnpm dlx unpack into a cache they may clear, so a path into one would stop working. */
15
+ const EPHEMERAL = /[\\/](_npx|dlx)[\\/]/;
16
+ /**
17
+ * The bin this module ships with: dist/cli/index.js next to dist/setup/. Run through npx or
18
+ * pnpm dlx, it is `npx -y` on this exact version instead, which outlives the cache.
19
+ */
20
+ export const defaultCommand = (bin = fileURLToPath(new URL(`../cli/index.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`, import.meta.url))) => {
21
+ if (!EPHEMERAL.test(bin))
22
+ return [process.execPath, bin];
23
+ const pkg = JSON.parse(readFileSync(resolve(dirname(bin), "../../package.json"), "utf8"));
24
+ return ["npx", "-y", `${pkg.name}@${pkg.version}`];
25
+ };
26
+ const quote = (s) => (/^[A-Za-z0-9_./:@=,+-]+$/.test(s) ? s : JSON.stringify(s));
27
+ const MARKER = /\shook --harness (claude|codex)(\s|$)/;
28
+ export const isJevHookCommand = (command) => typeof command === "string" && MARKER.test(command);
29
+ export function resolveSelection(o) {
30
+ const guards = new Set(o.guards ?? []);
31
+ const tools = new Set(o.tools ?? []);
32
+ let compact = o.compact ?? false;
33
+ if (o.level !== undefined) {
34
+ if (o.level === 6)
35
+ ALL_GUARDS.forEach((g) => guards.add(g));
36
+ else if (LEVEL_TOOLS[o.level]) {
37
+ LEVEL_TOOLS[o.level].forEach((t) => tools.add(t));
38
+ if (o.level === 7)
39
+ compact = true;
40
+ }
41
+ else
42
+ throw new SetupError(`level ${o.level} has nothing to set up; levels 6 to 10 do`);
43
+ }
44
+ for (const g of guards)
45
+ if (!ALL_GUARDS.includes(g))
46
+ throw new SetupError(`unknown guard ${g}; known: ${ALL_GUARDS.join(", ")}`);
47
+ try {
48
+ selectTools({ names: [...tools] });
49
+ }
50
+ catch (err) {
51
+ throw new SetupError(err.message);
52
+ }
53
+ if (!guards.size && !tools.size && !compact)
54
+ throw new SetupError("nothing to set up: pass --guards, --tools, --compact, or --level");
55
+ return { guards: [...guards], tools: [...tools], compact };
56
+ }
57
+ /** The hook entries, by event, that a selection needs. Same shape for Claude Code and Codex. */
58
+ export function hookGroups(harness, sel, command, extra = [], timeoutSec = 10) {
59
+ const run = (event, flags) => ({ type: "command", command: [...command, "hook", "--harness", harness, "--event", event, ...flags, ...extra].map(quote).join(" "), timeout: timeoutSec });
60
+ const out = {};
61
+ const guardFlag = (gs) => ["--guards", gs.join(",")];
62
+ const pre = [];
63
+ if (sel.guards.includes("bash"))
64
+ pre.push("Bash");
65
+ if (sel.guards.includes("write"))
66
+ pre.push(...(harness === "codex" ? ["apply_patch"] : ["Write", "Edit", "MultiEdit", "NotebookEdit"]));
67
+ if (pre.length)
68
+ out.PreToolUse = [{ matcher: pre.join("|"), hooks: [run("PreToolUse", guardFlag(sel.guards.filter((g) => g !== "result")))] }];
69
+ if (sel.guards.includes("result"))
70
+ out.PostToolUse = [{ matcher: harness === "codex" ? "Bash" : "Read|Bash", hooks: [run("PostToolUse", guardFlag(["result"]))] }];
71
+ if (sel.compact) {
72
+ out.UserPromptSubmit = [{ hooks: [run("UserPromptSubmit", ["--compact"])] }];
73
+ out.Stop = [{ hooks: [run("Stop", ["--compact"])] }];
74
+ out.PreCompact = [{ hooks: [run("PreCompact", [])] }];
75
+ }
76
+ return out;
77
+ }
78
+ /** Drop every hook this tool wrote before, then add the new groups. Unrelated hooks stay in place. */
79
+ export function mergeHooks(existing, ours) {
80
+ const merged = {};
81
+ for (const [event, groups] of Object.entries(existing ?? {})) {
82
+ const kept = (Array.isArray(groups) ? groups : [])
83
+ .map((g) => ({ ...g, hooks: (g?.hooks ?? []).filter((h) => !isJevHookCommand(h?.command)) }))
84
+ .filter((g) => g.hooks.length);
85
+ if (kept.length)
86
+ merged[event] = kept;
87
+ }
88
+ for (const [event, groups] of Object.entries(ours))
89
+ merged[event] = [...(merged[event] ?? []), ...groups];
90
+ return merged;
91
+ }
92
+ function readJson(path) {
93
+ if (!existsSync(path))
94
+ return { text: null, value: {} };
95
+ const text = readFileSync(path, "utf8");
96
+ if (!text.trim())
97
+ return { text, value: {} };
98
+ try {
99
+ const value = JSON.parse(text);
100
+ if (!value || typeof value !== "object" || Array.isArray(value))
101
+ throw new Error("not a JSON object");
102
+ return { text, value };
103
+ }
104
+ catch (err) {
105
+ throw new SetupError(`${path} is not valid JSON (${err.message}); fix or move it, then run jev setup again. Nothing was written.`);
106
+ }
107
+ }
108
+ const change = (path, before, value) => {
109
+ const after = JSON.stringify(value, null, 2) + "\n";
110
+ return { path, before, after, changed: before === null ? true : JSON.stringify(JSON.parse(before)) !== JSON.stringify(value) };
111
+ };
112
+ /** Claude Code: `.mcp.json` for the tools, `.claude/settings.json` for the hooks. */
113
+ export function planClaude(o) {
114
+ const sel = resolveSelection(o);
115
+ const command = o.command ?? defaultCommand();
116
+ const dir = resolve(o.dir);
117
+ const out = [];
118
+ const mcpPath = join(dir, ".mcp.json");
119
+ const mcp = readJson(mcpPath);
120
+ const servers = { ...(mcp.value.mcpServers ?? {}) };
121
+ if (sel.tools.length)
122
+ servers.jev = { command: command[0], args: [...command.slice(1), "mcp", "--tools", sel.tools.join(",")] };
123
+ else
124
+ delete servers.jev;
125
+ if (sel.tools.length || mcp.text !== null)
126
+ out.push(change(mcpPath, mcp.text, { ...mcp.value, mcpServers: servers }));
127
+ const settingsPath = join(dir, ".claude", "settings.json");
128
+ const settings = readJson(settingsPath);
129
+ const hooks = mergeHooks(settings.value.hooks, hookGroups("claude", sel, command, o.hookArgs, o.timeoutSec));
130
+ const next = { ...settings.value };
131
+ if (Object.keys(hooks).length)
132
+ next.hooks = hooks;
133
+ else
134
+ delete next.hooks;
135
+ if (Object.keys(hooks).length || settings.text !== null)
136
+ out.push(change(settingsPath, settings.text, next));
137
+ return out;
138
+ }
139
+ const BLOCK_START = "# >>> jev setup: managed by `jev setup`; this block is replaced on every run";
140
+ const BLOCK_END = "# <<< jev setup";
141
+ /** What the MCP server needs from the agent's environment. */
142
+ export const MCP_ENV_VARS = ["TYPESAFE_API_KEY", "OPENROUTER_API_KEY", "JEV_EVENT_DIR", "JEV_STATE_DIR"];
143
+ const tomlString = (s) => JSON.stringify(s); // JSON escapes are valid TOML basic-string escapes
144
+ const tomlArray = (xs) => `[${xs.map(tomlString).join(", ")}]`;
145
+ /** Our block of `.codex/config.toml`: the hooks as arrays of tables, the server as one table. */
146
+ export function codexBlock(sel, command, extra = [], timeoutSec = 10) {
147
+ const lines = [BLOCK_START];
148
+ for (const [event, groups] of Object.entries(hookGroups("codex", sel, command, extra, timeoutSec))) {
149
+ for (const g of groups) {
150
+ lines.push("", `[[hooks.${event}]]`);
151
+ if (g.matcher)
152
+ lines.push(`matcher = ${tomlString(g.matcher)}`);
153
+ for (const h of g.hooks)
154
+ lines.push(`[[hooks.${event}.hooks]]`, `type = "command"`, `command = ${tomlString(h.command)}`, `timeout = ${h.timeout ?? timeoutSec}`);
155
+ }
156
+ }
157
+ if (sel.tools.length) {
158
+ lines.push("", "[mcp_servers.jev]", `command = ${tomlString(command[0])}`, `args = ${tomlArray([...command.slice(1), "mcp", "--tools", sel.tools.join(",")])}`, `default_tools_approval_mode = "approve"`,
159
+ // Codex starts MCP servers with a clean environment; hooks inherit it, servers do not.
160
+ `env_vars = ${tomlArray(MCP_ENV_VARS)}`);
161
+ }
162
+ lines.push(BLOCK_END);
163
+ return lines.join("\n") + "\n";
164
+ }
165
+ /**
166
+ * Codex: `.codex/config.toml`, loaded once the project is trusted. There is no TOML parser here, so
167
+ * the file is not rewritten: our entries live in one marked block at the end, replaced on every run.
168
+ */
169
+ export function planCodex(o) {
170
+ const sel = resolveSelection(o);
171
+ const command = o.command ?? defaultCommand();
172
+ const path = join(resolve(o.dir), ".codex", "config.toml");
173
+ const before = existsSync(path) ? readFileSync(path, "utf8") : null;
174
+ const text = before ?? "";
175
+ const start = text.indexOf(BLOCK_START);
176
+ const end = text.indexOf(BLOCK_END);
177
+ if ((start === -1) !== (end === -1) || end < start) {
178
+ throw new SetupError(`${path} has a broken jev setup block (a start or end marker is missing); fix or remove it, then run jev setup again. Nothing was written.`);
179
+ }
180
+ const outside = start === -1 ? text : text.slice(0, start) + text.slice(end + BLOCK_END.length);
181
+ if (/^\s*\[\s*mcp_servers\.jev\s*\]/m.test(outside)) {
182
+ throw new SetupError(`${path} already defines [mcp_servers.jev] outside the jev setup block; remove it or rename it, then run jev setup again. Nothing was written.`);
183
+ }
184
+ const block = codexBlock(sel, command, o.hookArgs, o.timeoutSec);
185
+ const rest = outside.replace(/\s+$/, "");
186
+ const after = (rest ? rest + "\n\n" : "") + block;
187
+ return [{ path, before, after, changed: before !== after }];
188
+ }
189
+ /** One line for people: what the selection turns on. */
190
+ export function describeSelection(o) {
191
+ const sel = resolveSelection(o);
192
+ const parts = [
193
+ sel.guards.length ? `guards ${sel.guards.join(",")}` : "",
194
+ sel.compact ? "compaction advisor" : "",
195
+ sel.tools.length ? `tools ${sel.tools.join(",")}` : "",
196
+ ].filter(Boolean);
197
+ return parts.join("; ");
198
+ }
199
+ export function plan(o) {
200
+ return o.harness === "claude" ? planClaude(o) : planCodex(o);
201
+ }
202
+ export function apply(changes) {
203
+ for (const c of changes) {
204
+ if (!c.changed)
205
+ continue;
206
+ mkdirSync(dirname(c.path), { recursive: true });
207
+ writeFileSync(c.path, c.after);
208
+ }
209
+ }
@@ -0,0 +1,7 @@
1
+ import { type RunCommand } from "../files/assemble.ts";
2
+ import { type JevTool } from "./types.ts";
3
+ /** The usage guidance: a system prompt line under pi, server instructions under MCP. */
4
+ export declare const ASK_JEV_NUDGE: string;
5
+ /** `run` is the unguarded runner the gate wraps; tests pass a fake. */
6
+ export declare function makeAskJev(run?: RunCommand): JevTool;
7
+ export declare const askJev: JevTool;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Level 10: ask_jev on any situation. The agent passes its own state, paths for code to read,
3
+ * a command for code to run, and its question block. Code assembles one state and makes one call.
4
+ * A command runs through the bash gate first. Over the budget, the error names the parts and a split.
5
+ */
6
+ import { parseQuestions } from "../files/ask-files.js";
7
+ import { AskStateError, assembleState } from "../files/assemble.js";
8
+ import { ASK_JEV_DESCRIPTION } from "./description.js";
9
+ import { gatedRunner } from "./run-command.js";
10
+ import { fail, object, ok, str } from "./types.js";
11
+ /** The usage guidance: a system prompt line under pi, server instructions under MCP. */
12
+ export const ASK_JEV_NUDGE = "You have ask_jev. When you need a classification, a risk score, or a yes or no with a confidence, " +
13
+ "prefer it over reasoning it out yourself. Give it paths or a command instead of pasting content. " +
14
+ "It answers in 300 ms and costs almost nothing.";
15
+ /** `run` is the unguarded runner the gate wraps; tests pass a fake. */
16
+ export function makeAskJev(run) {
17
+ return {
18
+ name: "ask_jev",
19
+ label: "Ask Jev",
20
+ description: ASK_JEV_DESCRIPTION,
21
+ parameters: object({
22
+ questions_json: str("The question block, a JSON object keyed by question id"),
23
+ state: str("Your own state: plain text, or a JSON object as a string. Short. Not for pasting files or output."),
24
+ paths: { type: "array", items: { type: "string" }, description: "Files or globs for code to read into files[\"path\"]. Up to 20 files." },
25
+ command: str("A command for code to run in the repo; its result goes into output. Runs through the bash gate."),
26
+ }, ["questions_json"]),
27
+ async execute(p, ctx) {
28
+ try {
29
+ const questions = parseQuestions(p.questions_json);
30
+ const { state, summary } = await assembleState({ state: p.state, paths: p.paths, command: p.command }, ctx.cwd, gatedRunner(ctx, run));
31
+ const result = await ctx.decide("ask_jev", state, questions, { summary });
32
+ return ok({ answers: result.answers, state_summary: summary, usage: result.usage, model: result.model });
33
+ }
34
+ catch (err) {
35
+ const text = err instanceof AskStateError ? err.message : `ask_jev error: ${err?.message ?? err}`;
36
+ ctx.report("error", { hook: "ask_jev", message: text });
37
+ return fail(text);
38
+ }
39
+ },
40
+ };
41
+ }
42
+ export const askJev = makeAskJev();
@@ -0,0 +1,2 @@
1
+ import { type JevTool } from "./types.ts";
2
+ export declare const shouldICompact: JevTool;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Level 7, option B: should_i_compact on demand. The same four questions and tiers as the turn-end
3
+ * hook, but the agent asks. The harness supplies the session view; without one the tool says so.
4
+ */
5
+ import { compactVerdict } from "../compact/compact-on-demand.js";
6
+ import { evaluateCompaction } from "../compact/evaluate.js";
7
+ import { fail, ok } from "./types.js";
8
+ const NO_SIGNALS = {
9
+ switched_gears: { type: "noul", noul: 0 }, at_boundary: { type: "noul", noul: 0 },
10
+ needs_history: { type: "score", score: 2 }, mid_operation: { type: "noul", noul: 0 },
11
+ };
12
+ export const shouldICompact = {
13
+ name: "should_i_compact",
14
+ label: "Should I compact",
15
+ description: "Ask whether now is a good moment to compact the conversation. Returns should_compact, a tier " +
16
+ "(silent, notice, recommend, request), the reason, and the current context percent. Cheap, call it freely.",
17
+ parameters: { type: "object", properties: {} },
18
+ async execute(_p, ctx) {
19
+ try {
20
+ const source = ctx.compaction;
21
+ const input = source ? await source.input() : null;
22
+ if (!source || !input) {
23
+ return fail("should_i_compact error: no session history is available to this tool. Enable the Level 7 hooks (jev setup --level 7) so the session is recorded.");
24
+ }
25
+ const r = await evaluateCompaction(input, "should_i_compact", ctx.decide, ctx.report, source.action);
26
+ source.onEvaluation?.(r);
27
+ return ok(compactVerdict(r.decision, r.answers ?? NO_SIGNALS, r.usage, input.lines, source.action));
28
+ }
29
+ catch (err) {
30
+ return fail(`should_i_compact error: ${err?.message ?? err}`);
31
+ }
32
+ },
33
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Level 10, option B: the tool description is the teaching.
3
+ * The prompt says what to ask. The description says how: what Jev is, the three question types, when a typed answer beats reasoning, and when it does not.
4
+ */
5
+ export declare const ASK_JEV_DESCRIPTION: string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Level 10, option B: the tool description is the teaching.
3
+ * The prompt says what to ask. The description says how: what Jev is, the three question types, when a typed answer beats reasoning, and when it does not.
4
+ */
5
+ export const ASK_JEV_DESCRIPTION = [
6
+ "Ask Jev, a fast decision model, typed questions about one situation: files, a command's output, your own state, or any mix. It answers in about 300 ms for a fraction of a cent, and each answer is a number you can branch on, not prose. Use it whenever a judgment call would otherwise cost you a long think.",
7
+ "",
8
+ "Do not paste content you already have; that costs output tokens. Pass paths and code reads the files into files[\"path\"]. Pass command and code runs it in the repo and puts the result into output {command, exit_code, stdout, stderr}. Use state for what only you can say: a customer report, your plan, a line of context. Plain text or a JSON object as a string; your field names are kept as is. You can combine all three, and you never receive the files or the output, only the answers.",
9
+ "",
10
+ "One call judges one situation: up to 20 files and about 60k tokens in total. Over that the call is refused with a message naming the parts and a split that fits; make two calls with the same questions_json. For many files judged separately use ask_jev_files instead.",
11
+ "",
12
+ "questions_json: a JSON object keyed by question id. Three types:",
13
+ ' noul {"type":"noul","instructions":"Is `output` a real failure rather than a flaky one?","criteria":{"true":"...","false":"..."}} -> { noul: 0..1 }',
14
+ ' choice {"type":"choice","instructions":"What kind of failure is `output`?","criteria":{"bug_in_code":"...","wrong_test":"...","environment":"...","other":"..."}} -> { choice, confidence, probabilities }',
15
+ ' score {"type":"score","instructions":"How risky is `diff`?","criteria":["Isolated, tested","Some callers","Security sensitive, no tests"]} -> { score, confidence, legend }',
16
+ "",
17
+ "Write questions against files[\"path\"], output, or your own field names. Good uses: run the tests through command and classify the failure before choosing a fix, put git diff through command and score its risk before committing, pass the customer's words as state with the relevant paths and decide bug or expected, decide whether a request is clear enough to plan.",
18
+ "Ask every question you might need in one call; they share the state. Always give a choice an `other` option. Describe situations, not degrees.",
19
+ "Not for: exact lookups, counting, math, or anything a grep answers. Not a substitute for reading code you need to edit.",
20
+ ].join("\n");
@@ -0,0 +1,7 @@
1
+ import { type JevTool } from "./types.ts";
2
+ export declare const QUESTION_SCHEMA: string;
3
+ export declare const askJevFileBool: JevTool;
4
+ export declare const askJevFileChoice: JevTool;
5
+ export declare const askJevFileScore: JevTool;
6
+ export declare const askJevFiles: JevTool;
7
+ export declare const pickFirstFileTool: JevTool;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Levels 8 and 9: judgments about files the agent never loads.
3
+ *
4
+ * ask_jev_file_bool(path, question, yes?, no?)
5
+ * ask_jev_file_choice(path, question, options)
6
+ * ask_jev_file_score(path, question, levels)
7
+ * ask_jev_files(paths_or_globs, questions_json, recursive?) one call per file, in parallel
8
+ * pick_first_file(question, candidates) a second pass: which to open first
9
+ */
10
+ import { askFileBool } from "../files/ask-file-bool.js";
11
+ import { askFileChoice } from "../files/ask-file-choice.js";
12
+ import { askFileScore } from "../files/ask-file-score.js";
13
+ import { askFiles } from "../files/ask-files.js";
14
+ import { pickFirstFile } from "../files/pick-first.js";
15
+ import { FileStateError } from "../files/read-state.js";
16
+ import { fail, object, ok, str } from "./types.js";
17
+ const WHEN = "Use this for a judgment about what a file does or contains, without reading it into your context. " +
18
+ "Write the question against `content`, which is the file's text. Use the read tool instead when you need the code itself, to edit or quote it. " +
19
+ "Exact lookups, does this string appear, how many lines, belong to grep, not here.";
20
+ export const QUESTION_SCHEMA = 'questions_json is a JSON object keyed by question id. Three types. ' +
21
+ 'noul: {"type":"noul","instructions":"Does `content` ...?","criteria":{"true":"...","false":"..."}} returns a probability of yes. ' +
22
+ 'choice: {"type":"choice","instructions":"Which ... is `content`?","criteria":{"option_a":"when it applies","option_b":"...","other":"none of the above"}} returns one of your keys plus confidence, up to 255 options. ' +
23
+ 'score: {"type":"score","instructions":"How ... is `content`?","criteria":["lowest situation","...","highest situation"]} returns a position on your levels, two to ten of them. ' +
24
+ "Write every question against `content`, the file's text; `path` is also in the state. Ask every question you might need in one block, it is one call per file either way.";
25
+ const fileFail = (err) => fail(err instanceof FileStateError ? err.message : `error: ${err?.message ?? err}`);
26
+ const PATH = str("File path, relative to the repo");
27
+ export const askJevFileBool = {
28
+ name: "ask_jev_file_bool",
29
+ label: "Ask Jev about a file, yes or no",
30
+ description: `Yes or no about one file. Returns { path, answer, noul } where noul is the probability of yes, 0 to 1. ${WHEN}`,
31
+ parameters: object({
32
+ path: PATH,
33
+ question: str("A yes or no question about `content`, for example: Does `content` validate authentication tokens?"),
34
+ yes: str("What counts as yes"),
35
+ no: str("What counts as no"),
36
+ }, ["path", "question"]),
37
+ async execute(p, ctx) {
38
+ try {
39
+ return ok(await askFileBool(p.path, p.question, ctx.cwd, { yes: p.yes, no: p.no }, (s, q) => ctx.decide("ask_jev_file_bool", s, q, { path: p.path })));
40
+ }
41
+ catch (err) {
42
+ return fileFail(err);
43
+ }
44
+ },
45
+ };
46
+ export const askJevFileChoice = {
47
+ name: "ask_jev_file_choice",
48
+ label: "Ask Jev about a file, pick one",
49
+ description: `Pick one option about one file. Returns { path, choice, confidence, probabilities }. The choice is always one of your options; an "other" option is added if you leave none. ${WHEN}`,
50
+ parameters: object({
51
+ path: PATH,
52
+ question: str("The question, for example: Which layer is `content`?"),
53
+ options: { type: "object", additionalProperties: { type: "string" }, description: "Option name to a one line description of when it applies. Up to 255." },
54
+ }),
55
+ async execute(p, ctx) {
56
+ try {
57
+ return ok(await askFileChoice(p.path, p.question, p.options, ctx.cwd, (s, q) => ctx.decide("ask_jev_file_choice", s, q, { path: p.path })));
58
+ }
59
+ catch (err) {
60
+ return fileFail(err);
61
+ }
62
+ },
63
+ };
64
+ export const askJevFileScore = {
65
+ name: "ask_jev_file_score",
66
+ label: "Ask Jev about a file, on a scale",
67
+ description: `A position on a scale you define, about one file. Returns { path, score, top, nearest, confidence, legend }. Levels are ordered low to high, two to ten of them, each a described situation. ${WHEN}`,
68
+ parameters: object({
69
+ path: PATH,
70
+ question: str("The question, for example: How risky is a refactor of `content`?"),
71
+ levels: { type: "array", items: { type: "string" }, description: "Ordered low to high, each level a situation, for example: Isolated and well tested" },
72
+ }),
73
+ async execute(p, ctx) {
74
+ try {
75
+ return ok(await askFileScore(p.path, p.question, p.levels, ctx.cwd, (s, q) => ctx.decide("ask_jev_file_score", s, q, { path: p.path })));
76
+ }
77
+ catch (err) {
78
+ return fileFail(err);
79
+ }
80
+ },
81
+ };
82
+ export const askJevFiles = {
83
+ name: "ask_jev_files",
84
+ label: "Ask Jev about many files",
85
+ description: "Ask the same typed questions of many files at once without reading any of them. Code expands globs and directories, " +
86
+ "drops node_modules, .git, binaries, and files over the budget, caps the list at 255, then makes one Jev call per file in parallel. " +
87
+ "Returns { results: [{ path, answers }], skipped: [{ path, reason }], calls }. " + QUESTION_SCHEMA +
88
+ " Use read when you need a file's code; use grep for exact strings.",
89
+ parameters: object({
90
+ paths_or_globs: { type: "array", items: { type: "string" }, description: 'Files, directories, or globs, relative to the repo, for example ["src/**/*.ts"] or ["src/http"]' },
91
+ questions_json: str("The question block as a JSON string"),
92
+ recursive: { type: "boolean", description: "For directories: include every file below them. Default false." },
93
+ }, ["paths_or_globs", "questions_json"]),
94
+ async execute(p, ctx) {
95
+ try {
96
+ return ok(await askFiles(p.paths_or_globs, p.questions_json, ctx.cwd, {
97
+ recursive: p.recursive ?? false,
98
+ decide: (s, q) => ctx.decide("ask_jev_files", s, q, { path: s.path }),
99
+ }));
100
+ }
101
+ catch (err) {
102
+ return fail(`error: ${err?.message ?? err}`);
103
+ }
104
+ },
105
+ };
106
+ export const pickFirstFileTool = {
107
+ name: "pick_first_file",
108
+ label: "Pick the file to open first",
109
+ description: "After ask_jev_files, choose which of a list of files to open first for a goal. One Choice keyed by path, so the pick is always a real file. " +
110
+ "Returns { path | null, confidence, probabilities }. Pass a short note per path if you have one, for example the answers you already got.",
111
+ parameters: object({
112
+ question: str("The goal, for example: Which file should I open first to fix the proration bug?"),
113
+ candidates: {
114
+ type: "array",
115
+ items: object({ path: str(), note: str() }, ["path"]),
116
+ description: "Paths, with an optional one line note each",
117
+ },
118
+ }),
119
+ async execute(p, ctx) {
120
+ try {
121
+ return ok(await pickFirstFile(p.question, p.candidates, (s, q) => ctx.decide("pick_first_file", s, q)));
122
+ }
123
+ catch (err) {
124
+ return fail(`error: ${err?.message ?? err}`);
125
+ }
126
+ },
127
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The Jev tools, one definition per tool for every harness, and the sets each surface exposes.
3
+ */
4
+ export * from "./types.ts";
5
+ export * from "./description.ts";
6
+ export * from "./file-tools.ts";
7
+ export * from "./ask-jev.ts";
8
+ export * from "./compact-tool.ts";
9
+ export * from "./run-command.ts";
10
+ export * from "./sets.ts";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The Jev tools, one definition per tool for every harness, and the sets each surface exposes.
3
+ */
4
+ export * from "./types.js";
5
+ export * from "./description.js";
6
+ export * from "./file-tools.js";
7
+ export * from "./ask-jev.js";
8
+ export * from "./compact-tool.js";
9
+ export * from "./run-command.js";
10
+ export * from "./sets.js";
@@ -0,0 +1,7 @@
1
+ import { type CommandOutput, type RunCommand } from "../files/assemble.ts";
2
+ import type { ToolContext } from "./types.ts";
3
+ export declare const COMMAND_TIMEOUT_MS = 60000;
4
+ export declare const MAX_OUTPUT_CHARS = 200000;
5
+ export declare function runCommand(command: string, cwd: string): Promise<CommandOutput>;
6
+ /** A runner that refuses what the bash gate blocks, reporting the gate's verdict either way. */
7
+ export declare function gatedRunner(ctx: ToolContext, run?: RunCommand): RunCommand;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Run a command for a state. The output is captured, never streamed to the agent, and the
3
+ * command passes the bash gate first: a state should only ever need reads.
4
+ */
5
+ import { exec } from "node:child_process";
6
+ import { BLOCK_NOTICE, gateBashCommand } from "../guards/bash-gate.js";
7
+ import { AskStateError } from "../files/assemble.js";
8
+ export const COMMAND_TIMEOUT_MS = 60_000;
9
+ export const MAX_OUTPUT_CHARS = 200_000;
10
+ export function runCommand(command, cwd) {
11
+ return new Promise((resolve) => {
12
+ exec(command, { cwd, timeout: COMMAND_TIMEOUT_MS, maxBuffer: MAX_OUTPUT_CHARS * 4, env: { ...process.env, CI: "1" } }, (err, stdout, stderr) => {
13
+ const code = err && typeof err.code === "number" ? err.code : err ? null : 0;
14
+ resolve({ command, exit_code: code, stdout: String(stdout).slice(0, MAX_OUTPUT_CHARS), stderr: String(stderr).slice(0, MAX_OUTPUT_CHARS) });
15
+ });
16
+ });
17
+ }
18
+ /** A runner that refuses what the bash gate blocks, reporting the gate's verdict either way. */
19
+ export function gatedRunner(ctx, run = runCommand) {
20
+ return async (command, cwd) => {
21
+ const gate = await gateBashCommand(command, cwd, (s, q) => ctx.decide("ask_jev command gate", s, q));
22
+ ctx.report("hook", { hook: "ask_jev command", tool: "bash", command, block: gate.block, reason: gate.reason });
23
+ if (gate.block)
24
+ throw new AskStateError(`ask_jev: the command was refused by the bash gate: ${gate.reason}. ${BLOCK_NOTICE} ask_jev commands should only read.`);
25
+ return run(command, cwd);
26
+ };
27
+ }
@@ -0,0 +1,9 @@
1
+ import type { JevTool } from "./types.ts";
2
+ export declare const ALL_TOOLS: JevTool[];
3
+ export declare const LEVEL_TOOLS: Record<number, string[]>;
4
+ export declare const toolByName: (name: string) => JevTool | undefined;
5
+ /** By level, by names, or all of them. Unknown names and levels are errors, not silent drops. */
6
+ export declare function selectTools(opts?: {
7
+ level?: number;
8
+ names?: string[];
9
+ }): JevTool[];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Which tools a surface exposes. The package speaks tool names; level numbers are the lab's alias.
3
+ */
4
+ import { askJev } from "./ask-jev.js";
5
+ import { shouldICompact } from "./compact-tool.js";
6
+ import { askJevFileBool, askJevFileChoice, askJevFileScore, askJevFiles, pickFirstFileTool } from "./file-tools.js";
7
+ export const ALL_TOOLS = [shouldICompact, askJevFileBool, askJevFileChoice, askJevFileScore, askJevFiles, pickFirstFileTool, askJev];
8
+ export const LEVEL_TOOLS = {
9
+ 7: ["should_i_compact"],
10
+ 8: ["ask_jev_file_bool", "ask_jev_file_choice", "ask_jev_file_score"],
11
+ 9: ["ask_jev_files", "pick_first_file"],
12
+ 10: ["ask_jev"],
13
+ };
14
+ export const toolByName = (name) => ALL_TOOLS.find((t) => t.name === name);
15
+ /** By level, by names, or all of them. Unknown names and levels are errors, not silent drops. */
16
+ export function selectTools(opts = {}) {
17
+ if (opts.level !== undefined && opts.names?.length)
18
+ throw new Error("pass a level or tool names, not both");
19
+ if (opts.level !== undefined) {
20
+ const names = LEVEL_TOOLS[opts.level];
21
+ if (!names)
22
+ throw new Error(`no Jev tools for level ${opts.level}; tools exist for levels ${Object.keys(LEVEL_TOOLS).join(", ")}`);
23
+ return names.map((n) => toolByName(n));
24
+ }
25
+ if (opts.names?.length) {
26
+ const unknown = opts.names.filter((n) => !toolByName(n));
27
+ if (unknown.length)
28
+ throw new Error(`unknown tool ${unknown.join(", ")}; known: ${ALL_TOOLS.map((t) => t.name).join(", ")}`);
29
+ return opts.names.map((n) => toolByName(n));
30
+ }
31
+ return ALL_TOOLS;
32
+ }