@bobobowis/jev-kit 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +311 -2
- package/dist/capabilities.d.ts +33 -0
- package/dist/capabilities.js +81 -0
- package/dist/cli/ask.d.ts +9 -0
- package/dist/cli/ask.js +210 -0
- package/dist/cli/common.d.ts +38 -0
- package/dist/cli/common.js +40 -0
- package/dist/cli/hook.d.ts +3 -0
- package/dist/cli/hook.js +85 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +21 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +41 -0
- package/dist/cli/mcp.d.ts +3 -0
- package/dist/cli/mcp.js +47 -0
- package/dist/cli/setup.d.ts +3 -0
- package/dist/cli/setup.js +84 -0
- package/dist/compact/compact-on-demand.d.ts +22 -0
- package/dist/compact/compact-on-demand.js +29 -0
- package/dist/compact/evaluate.d.ts +36 -0
- package/dist/compact/evaluate.js +31 -0
- package/dist/compact/index.d.ts +7 -0
- package/dist/compact/index.js +7 -0
- package/dist/compact/pick-cut-point.d.ts +18 -0
- package/dist/compact/pick-cut-point.js +29 -0
- package/dist/compact/should-compact.d.ts +57 -0
- package/dist/compact/should-compact.js +87 -0
- package/dist/core/client.d.ts +93 -0
- package/dist/core/client.js +295 -0
- package/dist/core/helpers.d.ts +14 -0
- package/dist/core/helpers.js +9 -0
- package/dist/core/mock.d.ts +20 -0
- package/dist/core/mock.js +132 -0
- package/dist/core/types.d.ts +102 -0
- package/dist/core/types.js +82 -0
- package/dist/events/index.d.ts +44 -0
- package/dist/events/index.js +77 -0
- package/dist/files/ask-file-bool.d.ts +12 -0
- package/dist/files/ask-file-bool.js +15 -0
- package/dist/files/ask-file-choice.d.ts +10 -0
- package/dist/files/ask-file-choice.js +17 -0
- package/dist/files/ask-file-score.d.ts +12 -0
- package/dist/files/ask-file-score.js +15 -0
- package/dist/files/ask-files.d.ts +23 -0
- package/dist/files/ask-files.js +56 -0
- package/dist/files/ask.d.ts +12 -0
- package/dist/files/ask.js +28 -0
- package/dist/files/assemble.d.ts +52 -0
- package/dist/files/assemble.js +129 -0
- package/dist/files/decide.d.ts +10 -0
- package/dist/files/decide.js +1 -0
- package/dist/files/index.d.ts +14 -0
- package/dist/files/index.js +14 -0
- package/dist/files/pick-first.d.ts +16 -0
- package/dist/files/pick-first.js +24 -0
- package/dist/files/prune.d.ts +13 -0
- package/dist/files/prune.js +87 -0
- package/dist/files/read-state.d.ts +17 -0
- package/dist/files/read-state.js +47 -0
- package/dist/guards/bash-gate.d.ts +65 -0
- package/dist/guards/bash-gate.js +78 -0
- package/dist/guards/index.d.ts +7 -0
- package/dist/guards/index.js +7 -0
- package/dist/guards/mcp-gate.d.ts +23 -0
- package/dist/guards/mcp-gate.js +52 -0
- package/dist/guards/result-screen.d.ts +20 -0
- package/dist/guards/result-screen.js +31 -0
- package/dist/guards/write-gate.d.ts +18 -0
- package/dist/guards/write-gate.js +44 -0
- package/dist/hooks/handle.d.ts +76 -0
- package/dist/hooks/handle.js +61 -0
- package/dist/hooks/harness.d.ts +45 -0
- package/dist/hooks/harness.js +124 -0
- package/dist/hooks/index.d.ts +7 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/state.d.ts +8 -0
- package/dist/hooks/state.js +25 -0
- package/dist/hooks/transcript.d.ts +8 -0
- package/dist/hooks/transcript.js +52 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/ledger.d.ts +22 -0
- package/dist/ledger.js +26 -0
- package/dist/mcp/server.d.ts +28 -0
- package/dist/mcp/server.js +124 -0
- package/dist/policy/index.d.ts +17 -0
- package/dist/policy/index.js +30 -0
- package/dist/setup/index.d.ts +76 -0
- package/dist/setup/index.js +221 -0
- package/dist/tools/ask-jev.d.ts +7 -0
- package/dist/tools/ask-jev.js +42 -0
- package/dist/tools/compact-tool.d.ts +2 -0
- package/dist/tools/compact-tool.js +33 -0
- package/dist/tools/description.d.ts +5 -0
- package/dist/tools/description.js +20 -0
- package/dist/tools/file-tools.d.ts +7 -0
- package/dist/tools/file-tools.js +127 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.js +10 -0
- package/dist/tools/run-command.d.ts +7 -0
- package/dist/tools/run-command.js +27 -0
- package/dist/tools/sets.d.ts +9 -0
- package/dist/tools/sets.js +32 -0
- package/dist/tools/types.d.ts +51 -0
- package/dist/tools/types.js +4 -0
- package/package.json +96 -3
package/dist/cli/ask.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The general CLI, for scripts, CI, and git hooks:
|
|
3
|
+
* jev ask a question block about a state, files, and a command's output; JSON on stdout
|
|
4
|
+
* jev files the same question block about each file, in parallel
|
|
5
|
+
* jev gate one yes or no question; the answer is the exit code
|
|
6
|
+
* jev guard the Level 6 bash or write gate on a command or a file
|
|
7
|
+
* Results go to stdout, diagnostics to stderr. Events only go to JEV_EVENT_DIR, when it is set.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { noul } from "../core/helpers.js";
|
|
13
|
+
import { QuestionValidationError } from "../core/types.js";
|
|
14
|
+
import { AskStateError, assembleState } from "../files/assemble.js";
|
|
15
|
+
import { gateBashCommand } from "../guards/bash-gate.js";
|
|
16
|
+
import { gateWriteCall } from "../guards/write-gate.js";
|
|
17
|
+
import { askJev } from "../tools/ask-jev.js";
|
|
18
|
+
import { askJevFiles } from "../tools/file-tools.js";
|
|
19
|
+
import { gatedRunner } from "../tools/run-command.js";
|
|
20
|
+
import { deciderFor, readAll } from "./common.js";
|
|
21
|
+
const STATE_OPTIONS = {
|
|
22
|
+
state: { type: "string" }, "state-file": { type: "string" }, paths: { type: "string", multiple: true },
|
|
23
|
+
command: { type: "string" }, cwd: { type: "string" }, mock: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
24
|
+
};
|
|
25
|
+
const STATE_HELP = ` --state <text|json> your own state, plain text or a JSON object
|
|
26
|
+
--state-file <file> the same, read from a file
|
|
27
|
+
--paths <glob> files for code to read into files["path"]; repeat for more (up to 20 files)
|
|
28
|
+
--command <cmd> a command for code to run; its result goes into output (passes the bash gate)
|
|
29
|
+
--cwd <dir> where paths resolve and the command runs (default: here)
|
|
30
|
+
--mock the offline mock instead of a Jev key`;
|
|
31
|
+
export const ASK_HELP = `Usage: jev ask --questions <json|@file> [state options]
|
|
32
|
+
|
|
33
|
+
Prints { answers, state_summary, usage, model } as JSON. Exit 0, 1 when the call is refused or fails, 2 for bad options.
|
|
34
|
+
|
|
35
|
+
--questions <json|@file> the question block, keyed by question id
|
|
36
|
+
${STATE_HELP}
|
|
37
|
+
`;
|
|
38
|
+
export const FILES_HELP = `Usage: jev files <glob|dir|file>... --questions <json|@file> [--recursive] [--cwd dir] [--mock]
|
|
39
|
+
|
|
40
|
+
Asks the question block once per file, in parallel. Prints { results, skipped, calls } as JSON.
|
|
41
|
+
`;
|
|
42
|
+
export const GATE_HELP = `Usage: jev gate "<yes or no question>" [state options] [--threshold 0.5] [--yes <text>] [--no <text>] [--json]
|
|
43
|
+
|
|
44
|
+
Exit 0 when the probability of yes is at or above the threshold, 1 below it, 2 for bad options or a
|
|
45
|
+
state that cannot be sent, 3 when Jev could not answer. The script chooses whether 3 fails open or closed.
|
|
46
|
+
|
|
47
|
+
--threshold <0..1> default 0.5
|
|
48
|
+
--yes <text> what counts as yes
|
|
49
|
+
--no <text> what counts as no
|
|
50
|
+
--json also print { noul, threshold, yes } on stdout
|
|
51
|
+
${STATE_HELP}
|
|
52
|
+
`;
|
|
53
|
+
export const GUARD_HELP = `Usage: jev guard bash "<command>" | jev guard write <path> < content [--cwd dir] [--json] [--mock]
|
|
54
|
+
|
|
55
|
+
The Level 6 gates. Prints allow or block with the reason. Exit 0 allow, 1 block, 2 bad options, 3 Jev error.
|
|
56
|
+
`;
|
|
57
|
+
function parse(argv, options, io, help, name, positionals = false) {
|
|
58
|
+
try {
|
|
59
|
+
return parseArgs({ args: argv, options, strict: true, allowPositionals: positionals });
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
io.stderr.write(`jev ${name}: ${err.message}\n\n${help}`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const readQuestions = (raw) => (raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw);
|
|
67
|
+
function context(cwd, mock, io) {
|
|
68
|
+
const { reporter, decide } = deciderFor("cli", { mock, quiet: true, stderr: io.stderr, client: io.client });
|
|
69
|
+
return { cwd, harness: "cli", decide, report: (k, p) => reporter.report(k, p) };
|
|
70
|
+
}
|
|
71
|
+
function stateText(v) {
|
|
72
|
+
if (v["state-file"])
|
|
73
|
+
return readFileSync(v["state-file"], "utf8");
|
|
74
|
+
return v.state;
|
|
75
|
+
}
|
|
76
|
+
export async function ask(argv, io) {
|
|
77
|
+
const parsed = parse(argv, { ...STATE_OPTIONS, questions: { type: "string" } }, io, ASK_HELP, "ask");
|
|
78
|
+
if (!parsed)
|
|
79
|
+
return 2;
|
|
80
|
+
const v = parsed.values;
|
|
81
|
+
if (v.help) {
|
|
82
|
+
io.stdout.write(ASK_HELP);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
if (!v.questions) {
|
|
86
|
+
io.stderr.write("jev ask: --questions is required\n");
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
let questions;
|
|
90
|
+
try {
|
|
91
|
+
questions = readQuestions(v.questions);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
io.stderr.write(`jev ask: cannot read ${v.questions}: ${err.message}\n`);
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
const ctx = context(resolve(v.cwd ?? process.cwd()), v.mock, io);
|
|
98
|
+
const r = await askJev.execute({ questions_json: questions, state: stateText(v), paths: v.paths, command: v.command }, ctx);
|
|
99
|
+
if (r.isError) {
|
|
100
|
+
io.stderr.write(r.text + "\n");
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
io.stdout.write(JSON.stringify(r.payload) + "\n");
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
export async function files(argv, io) {
|
|
107
|
+
const parsed = parse(argv, { questions: { type: "string" }, recursive: { type: "boolean" }, cwd: { type: "string" }, mock: { type: "boolean" }, help: { type: "boolean", short: "h" } }, io, FILES_HELP, "files", true);
|
|
108
|
+
if (!parsed)
|
|
109
|
+
return 2;
|
|
110
|
+
const v = parsed.values;
|
|
111
|
+
if (v.help) {
|
|
112
|
+
io.stdout.write(FILES_HELP);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (!v.questions || !parsed.positionals.length) {
|
|
116
|
+
io.stderr.write(`jev files: pass one or more globs and --questions\n\n${FILES_HELP}`);
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
let questions;
|
|
120
|
+
try {
|
|
121
|
+
questions = readQuestions(v.questions);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
io.stderr.write(`jev files: cannot read ${v.questions}: ${err.message}\n`);
|
|
125
|
+
return 2;
|
|
126
|
+
}
|
|
127
|
+
const ctx = context(resolve(v.cwd ?? process.cwd()), v.mock, io);
|
|
128
|
+
const r = await askJevFiles.execute({ paths_or_globs: parsed.positionals, questions_json: questions, recursive: v.recursive ?? false }, ctx);
|
|
129
|
+
if (r.isError) {
|
|
130
|
+
io.stderr.write(r.text + "\n");
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
io.stdout.write(JSON.stringify(r.payload) + "\n");
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
export async function gate(argv, io) {
|
|
137
|
+
const parsed = parse(argv, { ...STATE_OPTIONS, threshold: { type: "string" }, yes: { type: "string" }, no: { type: "string" }, json: { type: "boolean" } }, io, GATE_HELP, "gate", true);
|
|
138
|
+
if (!parsed)
|
|
139
|
+
return 2;
|
|
140
|
+
const v = parsed.values;
|
|
141
|
+
if (v.help) {
|
|
142
|
+
io.stdout.write(GATE_HELP);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
const question = parsed.positionals.join(" ").trim();
|
|
146
|
+
if (!question) {
|
|
147
|
+
io.stderr.write(`jev gate: the question is required\n\n${GATE_HELP}`);
|
|
148
|
+
return 2;
|
|
149
|
+
}
|
|
150
|
+
const threshold = v.threshold === undefined ? 0.5 : Number(v.threshold);
|
|
151
|
+
if (!(threshold >= 0 && threshold <= 1)) {
|
|
152
|
+
io.stderr.write("jev gate: --threshold must be between 0 and 1\n");
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
const cwd = resolve(v.cwd ?? process.cwd());
|
|
156
|
+
const ctx = context(cwd, v.mock, io);
|
|
157
|
+
let assembled;
|
|
158
|
+
try {
|
|
159
|
+
assembled = await assembleState({ state: stateText(v), paths: v.paths, command: v.command }, cwd, gatedRunner(ctx));
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
io.stderr.write(`jev gate: ${err instanceof AskStateError ? err.message : `cannot build the state: ${err?.message ?? err}`}\n`);
|
|
163
|
+
return err instanceof AskStateError ? 2 : 3;
|
|
164
|
+
}
|
|
165
|
+
const criteria = v.yes || v.no ? { true: v.yes, false: v.no } : undefined;
|
|
166
|
+
let answer;
|
|
167
|
+
try {
|
|
168
|
+
const { answers } = await ctx.decide("gate", assembled.state, { gate: noul(question, criteria) });
|
|
169
|
+
answer = answers.gate;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
io.stderr.write(`jev gate: ${err?.message ?? err}\n`);
|
|
173
|
+
return err instanceof QuestionValidationError ? 2 : 3;
|
|
174
|
+
}
|
|
175
|
+
const yes = answer.noul >= threshold;
|
|
176
|
+
if (v.json)
|
|
177
|
+
io.stdout.write(JSON.stringify({ noul: answer.noul, threshold, yes }) + "\n");
|
|
178
|
+
return yes ? 0 : 1;
|
|
179
|
+
}
|
|
180
|
+
export async function guard(argv, io) {
|
|
181
|
+
const parsed = parse(argv, { cwd: { type: "string" }, json: { type: "boolean" }, mock: { type: "boolean" }, help: { type: "boolean", short: "h" } }, io, GUARD_HELP, "guard", true);
|
|
182
|
+
if (!parsed)
|
|
183
|
+
return 2;
|
|
184
|
+
const v = parsed.values;
|
|
185
|
+
if (v.help) {
|
|
186
|
+
io.stdout.write(GUARD_HELP);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
const [kind, ...rest] = parsed.positionals;
|
|
190
|
+
const target = rest.join(" ").trim();
|
|
191
|
+
if ((kind !== "bash" && kind !== "write") || !target) {
|
|
192
|
+
io.stderr.write(`jev guard: pass bash "<command>" or write <path>\n\n${GUARD_HELP}`);
|
|
193
|
+
return 2;
|
|
194
|
+
}
|
|
195
|
+
const cwd = resolve(v.cwd ?? process.cwd());
|
|
196
|
+
const ctx = context(cwd, v.mock, io);
|
|
197
|
+
let d;
|
|
198
|
+
try {
|
|
199
|
+
d = kind === "bash"
|
|
200
|
+
? await gateBashCommand(target, cwd, (s, q) => ctx.decide("guard bash", s, q))
|
|
201
|
+
: await gateWriteCall(target, await readAll(io.stdin), cwd, (s, q) => ctx.decide("guard write", s, q));
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
io.stderr.write(`jev guard: ${err?.message ?? err}\n`);
|
|
205
|
+
return 3;
|
|
206
|
+
}
|
|
207
|
+
ctx.report("hook", { hook: `guard ${kind}`, [kind === "bash" ? "command" : "path"]: target, block: d.block, reason: d.reason });
|
|
208
|
+
io.stdout.write(v.json ? JSON.stringify(d) + "\n" : `${d.block ? "block" : "allow"}: ${d.reason}\n`);
|
|
209
|
+
return d.block ? 1 : 0;
|
|
210
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every subcommand shares: the client, the reporter, list flags, stdin, and a deadline.
|
|
3
|
+
*/
|
|
4
|
+
import { JevClient } from "../core/client.ts";
|
|
5
|
+
import { type Decider, type Reporter } from "../events/index.ts";
|
|
6
|
+
export interface Io {
|
|
7
|
+
stdout: {
|
|
8
|
+
write(s: string): unknown;
|
|
9
|
+
};
|
|
10
|
+
stderr: {
|
|
11
|
+
write(s: string): unknown;
|
|
12
|
+
};
|
|
13
|
+
stdin: AsyncIterable<string | Buffer>;
|
|
14
|
+
/** Tests inject a client; commands otherwise build one from --mock and the keys. */
|
|
15
|
+
client?: () => Pick<JevClient, "systemOne">;
|
|
16
|
+
}
|
|
17
|
+
export declare const processIo: () => Io;
|
|
18
|
+
/**
|
|
19
|
+
* `--mock` selects the offline mock explicitly; otherwise the client picks TypeSafe or OpenRouter
|
|
20
|
+
* from the keys. A deadline gets its own client, so a call cut off by it is aborted, not left running.
|
|
21
|
+
*/
|
|
22
|
+
export declare function clientFor(mock: boolean | undefined, timeoutMs?: number): () => Pick<JevClient, "systemOne">;
|
|
23
|
+
export declare function deciderFor(harness: string, opts: {
|
|
24
|
+
mock?: boolean;
|
|
25
|
+
quiet?: boolean;
|
|
26
|
+
stderr?: Io["stderr"];
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
client?: Io["client"];
|
|
29
|
+
}): {
|
|
30
|
+
reporter: Reporter;
|
|
31
|
+
decide: Decider;
|
|
32
|
+
};
|
|
33
|
+
export declare const list: (raw: string | undefined) => string[];
|
|
34
|
+
export declare function readAll(stdin: Io["stdin"]): Promise<string>;
|
|
35
|
+
/** Resolve with the work, or with `fallback` once `ms` pass. The work keeps running but nobody waits. */
|
|
36
|
+
export declare function withDeadline<T>(work: Promise<T>, ms: number, fallback: () => T): Promise<T>;
|
|
37
|
+
export declare class UsageError extends Error {
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every subcommand shares: the client, the reporter, list flags, stdin, and a deadline.
|
|
3
|
+
*/
|
|
4
|
+
import { JevClient, sharedJev } from "../core/client.js";
|
|
5
|
+
import { createDecider, createReporter } from "../events/index.js";
|
|
6
|
+
export const processIo = () => ({ stdout: process.stdout, stderr: process.stderr, stdin: process.stdin });
|
|
7
|
+
/**
|
|
8
|
+
* `--mock` selects the offline mock explicitly; otherwise the client picks TypeSafe or OpenRouter
|
|
9
|
+
* from the keys. A deadline gets its own client, so a call cut off by it is aborted, not left running.
|
|
10
|
+
*/
|
|
11
|
+
export function clientFor(mock, timeoutMs) {
|
|
12
|
+
if (!mock && !timeoutMs)
|
|
13
|
+
return sharedJev;
|
|
14
|
+
let client;
|
|
15
|
+
return () => (client ??= new JevClient(mock ? { provider: "mock" } : { timeoutMs }));
|
|
16
|
+
}
|
|
17
|
+
export function deciderFor(harness, opts) {
|
|
18
|
+
const reporter = createReporter({ harness, quiet: opts.quiet, stderr: opts.stderr });
|
|
19
|
+
return { reporter, decide: createDecider(reporter, opts.client ?? clientFor(opts.mock, opts.timeoutMs)) };
|
|
20
|
+
}
|
|
21
|
+
export const list = (raw) => (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
22
|
+
export async function readAll(stdin) {
|
|
23
|
+
let text = "";
|
|
24
|
+
for await (const chunk of stdin)
|
|
25
|
+
text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
26
|
+
return text;
|
|
27
|
+
}
|
|
28
|
+
/** Resolve with the work, or with `fallback` once `ms` pass. The work keeps running but nobody waits. */
|
|
29
|
+
export async function withDeadline(work, ms, fallback) {
|
|
30
|
+
let timer;
|
|
31
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(fallback()), ms); });
|
|
32
|
+
try {
|
|
33
|
+
return await Promise.race([work, late]);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class UsageError extends Error {
|
|
40
|
+
}
|
package/dist/cli/hook.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jev hook: run as a Claude Code or Codex hook. Reads the hook payload on stdin, writes the
|
|
3
|
+
* harness's response on stdout. Fails open: a Jev error, a timeout, or a bad payload allows the
|
|
4
|
+
* action. Never exits 2, which both harnesses read as "block".
|
|
5
|
+
*/
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { ALL_GUARDS, LEVEL6_GUARDS, runHarnessHook, HOOK_LINES } from "../hooks/index.js";
|
|
8
|
+
import { deciderFor, list, readAll, withDeadline } from "./common.js";
|
|
9
|
+
export const HOOK_HELP = `Usage: jev hook --harness claude|codex --event <HookEvent> [options]
|
|
10
|
+
|
|
11
|
+
Reads the hook payload on stdin and answers on stdout. Exit 0 always, except 1 for bad options.
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--harness <name> claude or codex
|
|
15
|
+
--event <name> PreToolUse, PostToolUse, UserPromptSubmit, Stop, PreCompact
|
|
16
|
+
--guards <list> bash,write,result,mcp (default: bash,write,result)
|
|
17
|
+
--ask the bash and mcp gates ask you when Jev is unsure or leans destructive (Claude Code)
|
|
18
|
+
--allow-read-only the bash and mcp gates approve what Jev is sure only reads (Claude Code)
|
|
19
|
+
--compact run the Level 7 advisor on UserPromptSubmit and Stop
|
|
20
|
+
--lines <n,n,n> notice, recommend, request lines in context tokens (default ${HOOK_LINES.notice},${HOOK_LINES.recommend},${HOOK_LINES.request})
|
|
21
|
+
--window <tokens> the model's context window, for percentages
|
|
22
|
+
--timeout-ms <ms> give up and allow after this long (default 8000)
|
|
23
|
+
--mock use the offline mock instead of a Jev key
|
|
24
|
+
`;
|
|
25
|
+
export async function hook(argv, io) {
|
|
26
|
+
let values;
|
|
27
|
+
try {
|
|
28
|
+
({ values } = parseArgs({
|
|
29
|
+
args: argv, strict: true,
|
|
30
|
+
options: {
|
|
31
|
+
harness: { type: "string" }, event: { type: "string" }, guards: { type: "string" }, compact: { type: "boolean" },
|
|
32
|
+
ask: { type: "boolean" }, "allow-read-only": { type: "boolean" },
|
|
33
|
+
lines: { type: "string" }, window: { type: "string" }, "timeout-ms": { type: "string" }, mock: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
io.stderr.write(`jev hook: ${err.message}\n\n${HOOK_HELP}`);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
if (values.help) {
|
|
42
|
+
io.stdout.write(HOOK_HELP);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
const harness = values.harness;
|
|
46
|
+
if (harness !== "claude" && harness !== "codex") {
|
|
47
|
+
io.stderr.write(`jev hook: --harness must be claude or codex\n`);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
if (!values.event) {
|
|
51
|
+
io.stderr.write(`jev hook: --event is required\n`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
const guards = values.guards ? list(values.guards) : LEVEL6_GUARDS;
|
|
55
|
+
const unknown = guards.filter((g) => !ALL_GUARDS.includes(g));
|
|
56
|
+
if (unknown.length) {
|
|
57
|
+
io.stderr.write(`jev hook: unknown guard ${unknown.join(", ")}; known: ${ALL_GUARDS.join(", ")}\n`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
let lines = HOOK_LINES;
|
|
61
|
+
if (values.lines) {
|
|
62
|
+
const [notice, recommend, request] = list(values.lines).map(Number);
|
|
63
|
+
if (![notice, recommend, request].every((n) => Number.isFinite(n) && n > 0) || !(notice < recommend && recommend < request)) {
|
|
64
|
+
io.stderr.write(`jev hook: --lines needs three increasing token counts, for example 50000,100000,150000\n`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
lines = { notice, recommend, request };
|
|
68
|
+
}
|
|
69
|
+
const window = values.window ? Number(values.window) : null;
|
|
70
|
+
const timeoutMs = values["timeout-ms"] ? Number(values["timeout-ms"]) : 8000;
|
|
71
|
+
const { reporter, decide } = deciderFor(harness, { mock: values.mock, stderr: io.stderr, timeoutMs, client: io.client });
|
|
72
|
+
let payload;
|
|
73
|
+
try {
|
|
74
|
+
payload = JSON.parse(await readAll(io.stdin));
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
reporter.report("error", { hook: values.event, message: `unreadable hook payload: ${err?.message ?? err}` });
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
const response = await withDeadline(runHarnessHook(values.event, payload, { harness, guards, ladder: { ask: values.ask, allowReadOnly: values["allow-read-only"] }, compact: values.compact, lines, window, decide, report: (k, p) => reporter.report(k, p) })
|
|
81
|
+
.catch((err) => { reporter.report("error", { hook: values.event, message: err?.message ?? String(err) }); return null; }), timeoutMs, () => { reporter.report("error", { hook: values.event, message: `timed out after ${timeoutMs} ms; allowed` }); return null; });
|
|
82
|
+
if (response)
|
|
83
|
+
io.stdout.write(JSON.stringify(response));
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `jev` bin. Everything lives in main.ts, so tests can call it without a process.
|
|
4
|
+
*/
|
|
5
|
+
import { main } from "./main.js";
|
|
6
|
+
import { processIo } from "./common.js";
|
|
7
|
+
// A reader that stops early (`jev files ... | head -1`) closes the pipe: that ends the output, not an error.
|
|
8
|
+
process.stdout.on("error", (err) => {
|
|
9
|
+
if (err.code === "EPIPE")
|
|
10
|
+
process.exit(process.exitCode ?? 0);
|
|
11
|
+
throw err;
|
|
12
|
+
});
|
|
13
|
+
const argv = process.argv.slice(2);
|
|
14
|
+
main(argv, processIo()).then((code) => {
|
|
15
|
+
// A hook must exit as soon as it has answered: a Jev call abandoned at the deadline would
|
|
16
|
+
// otherwise hold the process, and the harness waits for it. Exit once stdout has flushed.
|
|
17
|
+
if (argv[0] === "hook")
|
|
18
|
+
process.stdout.write("", () => process.exit(code));
|
|
19
|
+
else
|
|
20
|
+
process.exitCode = code;
|
|
21
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `jev` command: one bin, one subcommand per delivery surface.
|
|
3
|
+
*/
|
|
4
|
+
import { HOOK_HELP } from "./hook.ts";
|
|
5
|
+
import type { Io } from "./common.ts";
|
|
6
|
+
export { HOOK_HELP };
|
|
7
|
+
export declare function helpText(): string;
|
|
8
|
+
export declare function main(argv: string[], io: Io): Promise<number>;
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `jev` command: one bin, one subcommand per delivery surface.
|
|
3
|
+
*/
|
|
4
|
+
import { hook, HOOK_HELP } from "./hook.js";
|
|
5
|
+
import { setup } from "./setup.js";
|
|
6
|
+
import { mcp } from "./mcp.js";
|
|
7
|
+
import { ask, files, gate, guard } from "./ask.js";
|
|
8
|
+
const SUBCOMMANDS = [
|
|
9
|
+
["ask", "Ask a question block about a state, files, or a command's output; JSON on stdout", ask],
|
|
10
|
+
["files", "Ask the same question block about each file, in parallel", files],
|
|
11
|
+
["gate", "One yes or no question; exit 0 yes, 1 no, 2 usage, 3 Jev error", gate],
|
|
12
|
+
["guard", "Check a shell command or a file write with the Level 6 gates; exit 0 allow, 1 block", guard],
|
|
13
|
+
["setup", "Write or merge the Jev hooks and MCP server into a project's Claude Code or Codex config", setup],
|
|
14
|
+
["hook", "Run as a Claude Code or Codex hook: guards and the compaction advisor", hook],
|
|
15
|
+
["mcp", "Serve the Jev tools over stdio MCP", mcp],
|
|
16
|
+
];
|
|
17
|
+
export { HOOK_HELP };
|
|
18
|
+
export function helpText() {
|
|
19
|
+
const width = Math.max(...SUBCOMMANDS.map(([n]) => n.length));
|
|
20
|
+
return [
|
|
21
|
+
"Usage: jev <command> [options]",
|
|
22
|
+
"",
|
|
23
|
+
"Commands:",
|
|
24
|
+
...SUBCOMMANDS.map(([n, s]) => ` ${n.padEnd(width)} ${s}`),
|
|
25
|
+
"",
|
|
26
|
+
"Run `jev <command> --help` for a command's options.",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
export async function main(argv, io) {
|
|
30
|
+
const [command, ...rest] = argv;
|
|
31
|
+
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
32
|
+
io.stdout.write(helpText() + "\n");
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
const entry = SUBCOMMANDS.find(([n]) => n === command);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
io.stderr.write(`jev: unknown command "${command}".\n\n${helpText()}\n`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
return entry[2](rest, io);
|
|
41
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type Io } from "./common.ts";
|
|
2
|
+
export declare const MCP_HELP = "Usage: jev mcp [--level 7..10 | --tools a,b] [--cwd dir] [--mock]\n\nServes the Jev tools over stdio MCP. Paths resolve against --cwd (default: where it was started).\nWith neither --level nor --tools it serves all of them.\n";
|
|
3
|
+
export declare function mcp(argv: string[], io: Io): Promise<number>;
|
package/dist/cli/mcp.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jev mcp: serve the Jev tools over stdio MCP, by level or by tool name.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { serve } from "../mcp/server.js";
|
|
8
|
+
import { selectTools } from "../tools/sets.js";
|
|
9
|
+
import { deciderFor, list } from "./common.js";
|
|
10
|
+
export const MCP_HELP = `Usage: jev mcp [--level 7..10 | --tools a,b] [--cwd dir] [--mock]
|
|
11
|
+
|
|
12
|
+
Serves the Jev tools over stdio MCP. Paths resolve against --cwd (default: where it was started).
|
|
13
|
+
With neither --level nor --tools it serves all of them.
|
|
14
|
+
`;
|
|
15
|
+
const version = () => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return "0.0.0";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export async function mcp(argv, io) {
|
|
24
|
+
let values;
|
|
25
|
+
try {
|
|
26
|
+
({ values } = parseArgs({ args: argv, strict: true, options: { level: { type: "string" }, tools: { type: "string" }, cwd: { type: "string" }, mock: { type: "boolean" }, help: { type: "boolean", short: "h" } } }));
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
io.stderr.write(`jev mcp: ${err.message}\n\n${MCP_HELP}`);
|
|
30
|
+
return 2;
|
|
31
|
+
}
|
|
32
|
+
if (values.help) {
|
|
33
|
+
io.stdout.write(MCP_HELP);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
let tools;
|
|
37
|
+
try {
|
|
38
|
+
tools = selectTools({ level: values.level !== undefined ? Number(values.level) : undefined, names: values.tools ? list(values.tools) : undefined });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
io.stderr.write(`jev mcp: ${err.message}\n`);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
const { reporter, decide } = deciderFor("mcp", { mock: values.mock, stderr: io.stderr, client: io.client });
|
|
45
|
+
await serve({ tools, cwd: resolve(values.cwd ?? process.cwd()), decide, report: (k, p) => reporter.report(k, p), version: version() });
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type Io } from "./common.ts";
|
|
2
|
+
export declare const SETUP_HELP = "Usage: jev setup --harness claude|codex [options]\n\nWrites or merges the Jev entries into the project's agent config, leaving everything else alone.\nClaude Code: .mcp.json and .claude/settings.json. Codex: a marked block in .codex/config.toml.\nEach run sets the whole selection: running it again replaces only what it set up before.\n\nOptions:\n --harness <name> claude or codex\n --guards <list> bash,write,result: the Level 6 hooks; mcp: the MCP gate on other servers' tools (Claude Code)\n --ask the bash and mcp gates ask you when Jev is unsure or leans destructive (Claude Code)\n --allow-read-only the bash and mcp gates approve what Jev is sure only reads (Claude Code)\n --tools <list> Jev tools to serve over MCP, for example ask_jev,ask_jev_files\n --compact the Level 7 advisor hooks\n --level <n> a lab level: 6 guards, 7 advisor, 8 to 10 their tools\n --out <dir> the project directory (default: the current one)\n --command <cmd> how to run jev in the hooks and the MCP entry (default: node and this package's bin)\n --hook-args <args> extra jev hook flags, for example \"--lines 60000,120000,180000\"\n --dry-run print what would change, write nothing\n";
|
|
3
|
+
export declare function setup(argv: string[], io: Io): Promise<number>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jev setup: wire the Jev hooks and MCP server into a project's Claude Code or Codex config.
|
|
3
|
+
*/
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { relative } from "node:path";
|
|
6
|
+
import { toPosix } from "../files/read-state.js";
|
|
7
|
+
import { apply, describeSelection, plan, SetupError } from "../setup/index.js";
|
|
8
|
+
import { list } from "./common.js";
|
|
9
|
+
export const SETUP_HELP = `Usage: jev setup --harness claude|codex [options]
|
|
10
|
+
|
|
11
|
+
Writes or merges the Jev entries into the project's agent config, leaving everything else alone.
|
|
12
|
+
Claude Code: .mcp.json and .claude/settings.json. Codex: a marked block in .codex/config.toml.
|
|
13
|
+
Each run sets the whole selection: running it again replaces only what it set up before.
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--harness <name> claude or codex
|
|
17
|
+
--guards <list> bash,write,result: the Level 6 hooks; mcp: the MCP gate on other servers' tools (Claude Code)
|
|
18
|
+
--ask the bash and mcp gates ask you when Jev is unsure or leans destructive (Claude Code)
|
|
19
|
+
--allow-read-only the bash and mcp gates approve what Jev is sure only reads (Claude Code)
|
|
20
|
+
--tools <list> Jev tools to serve over MCP, for example ask_jev,ask_jev_files
|
|
21
|
+
--compact the Level 7 advisor hooks
|
|
22
|
+
--level <n> a lab level: 6 guards, 7 advisor, 8 to 10 their tools
|
|
23
|
+
--out <dir> the project directory (default: the current one)
|
|
24
|
+
--command <cmd> how to run jev in the hooks and the MCP entry (default: node and this package's bin)
|
|
25
|
+
--hook-args <args> extra jev hook flags, for example "--lines 60000,120000,180000"
|
|
26
|
+
--dry-run print what would change, write nothing
|
|
27
|
+
`;
|
|
28
|
+
export async function setup(argv, io) {
|
|
29
|
+
let values;
|
|
30
|
+
try {
|
|
31
|
+
({ values } = parseArgs({
|
|
32
|
+
args: argv, strict: true,
|
|
33
|
+
options: {
|
|
34
|
+
harness: { type: "string" }, guards: { type: "string" }, tools: { type: "string" }, compact: { type: "boolean" },
|
|
35
|
+
ask: { type: "boolean" }, "allow-read-only": { type: "boolean" },
|
|
36
|
+
level: { type: "string" }, out: { type: "string" }, command: { type: "string" }, "hook-args": { type: "string" },
|
|
37
|
+
"dry-run": { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
io.stderr.write(`jev setup: ${err.message}\n\n${SETUP_HELP}`);
|
|
43
|
+
return 2;
|
|
44
|
+
}
|
|
45
|
+
if (values.help) {
|
|
46
|
+
io.stdout.write(SETUP_HELP);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const harness = values.harness;
|
|
50
|
+
if (harness !== "claude" && harness !== "codex") {
|
|
51
|
+
io.stderr.write("jev setup: --harness must be claude or codex\n");
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
const dir = values.out ?? process.cwd();
|
|
55
|
+
try {
|
|
56
|
+
const selection = {
|
|
57
|
+
guards: values.guards ? list(values.guards) : undefined,
|
|
58
|
+
tools: values.tools ? list(values.tools) : undefined,
|
|
59
|
+
compact: values.compact,
|
|
60
|
+
ask: values.ask,
|
|
61
|
+
allowReadOnly: values["allow-read-only"],
|
|
62
|
+
level: values.level !== undefined ? Number(values.level) : undefined,
|
|
63
|
+
};
|
|
64
|
+
const changes = plan({
|
|
65
|
+
harness, dir, ...selection,
|
|
66
|
+
command: values.command ? values.command.split(/\s+/).filter(Boolean) : undefined,
|
|
67
|
+
hookArgs: values["hook-args"] ? values["hook-args"].split(/\s+/).filter(Boolean) : undefined,
|
|
68
|
+
});
|
|
69
|
+
const changed = changes.filter((c) => c.changed);
|
|
70
|
+
if (values["dry-run"]) {
|
|
71
|
+
for (const c of changes)
|
|
72
|
+
io.stdout.write(`--- ${toPosix(relative(dir, c.path) || c.path)}${c.changed ? "" : " (unchanged)"}\n${c.after}`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
apply(changes);
|
|
76
|
+
io.stdout.write(changed.length ? `jev setup: wrote ${changed.map((c) => toPosix(relative(dir, c.path))).join(", ")}\n` : "jev setup: no changes\n");
|
|
77
|
+
io.stdout.write(`jev setup: on: ${describeSelection(selection)}. Running it again replaces this selection.\n`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
io.stderr.write(`jev setup: ${err instanceof SetupError ? err.message : err?.message ?? err}\n`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 7, option B: should_i_compact on demand.
|
|
3
|
+
* The same four questions and the same tiers, but the agent asks. The tool returns a typed verdict it can act on instead of a nudge it might miss.
|
|
4
|
+
*/
|
|
5
|
+
import type { CompactAction, CompactAnswers, CompactDecision, Lines, Usage } from "./should-compact.ts";
|
|
6
|
+
export interface CompactVerdict {
|
|
7
|
+
should_compact: boolean;
|
|
8
|
+
tier: CompactDecision["tier"];
|
|
9
|
+
reason: string;
|
|
10
|
+
context_tokens: number | null;
|
|
11
|
+
context_percent: number | null;
|
|
12
|
+
lines: Lines;
|
|
13
|
+
signals: {
|
|
14
|
+
switched_gears: number;
|
|
15
|
+
at_boundary: number;
|
|
16
|
+
needs_history: number;
|
|
17
|
+
mid_operation: number;
|
|
18
|
+
};
|
|
19
|
+
next_step: string;
|
|
20
|
+
}
|
|
21
|
+
/** The tool result the agent reads. Booleans and numbers first, prose last. */
|
|
22
|
+
export declare function compactVerdict(d: CompactDecision, a: CompactAnswers, usage: Usage | null, lines: Lines, action?: CompactAction): CompactVerdict;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The tool result the agent reads. Booleans and numbers first, prose last. */
|
|
2
|
+
export function compactVerdict(d, a, usage, lines, action = "compact_now") {
|
|
3
|
+
const should = d.tier === "recommend" || d.tier === "request";
|
|
4
|
+
return {
|
|
5
|
+
should_compact: should,
|
|
6
|
+
tier: d.tier,
|
|
7
|
+
reason: d.reason,
|
|
8
|
+
context_tokens: usage ? usage.tokens : null,
|
|
9
|
+
context_percent: usage && usage.pct !== null ? Number(usage.pct.toFixed(1)) : null,
|
|
10
|
+
lines,
|
|
11
|
+
signals: {
|
|
12
|
+
switched_gears: a.switched_gears.noul,
|
|
13
|
+
at_boundary: a.at_boundary.noul,
|
|
14
|
+
needs_history: a.needs_history.score,
|
|
15
|
+
mid_operation: a.mid_operation.noul,
|
|
16
|
+
},
|
|
17
|
+
next_step: action === "ask_user"
|
|
18
|
+
? should
|
|
19
|
+
? "Ask the user to run /compact with a one line note of what to keep, then continue the current request."
|
|
20
|
+
: d.tier === "notice"
|
|
21
|
+
? "Optional. Continue, or suggest /compact to the user at a clean point."
|
|
22
|
+
: "Continue. Nothing to do."
|
|
23
|
+
: should
|
|
24
|
+
? "Call compact_now with a short note to yourself, then continue the current request."
|
|
25
|
+
: d.tier === "notice"
|
|
26
|
+
? "Optional. Continue, or call compact_now if you are at a clean point."
|
|
27
|
+
: "Continue. Nothing to do.",
|
|
28
|
+
};
|
|
29
|
+
}
|