auto-mode 0.1.0 → 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/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { a as loadPolicy, c as hookConfig, h as loadConfig, l as renderVerdict, m as configPath, n as classifyWithModel, o as SETTINGS_PATHS, s as SETUP_NOTES, t as classifyLocally, u as parsePayload } from "./classify-locally-BHl0xiqa.js";
2
+ import { a as loadPolicy, c as buildHookConfig, g as resolveConfigPath, l as renderVerdict, m as loadConfig, n as classifyWithModel, o as SETTINGS_PATHS, s as SETUP_NOTES, t as classifyLocally, u as parsePayload } from "./classify-locally-rTUwxBZi.js";
3
3
  import { parseArgs } from "node:util";
4
+ import { text } from "node:stream/consumers";
4
5
  //#region src/cli.ts
5
6
  const USAGE = `auto-mode — a permission classifier that runs as a hook
6
7
 
@@ -15,45 +16,36 @@ Options:
15
16
  --explain With run: also write the reasoning to stderr
16
17
  --local-only With run: skip the model tier
17
18
  `;
18
- async function readStdin() {
19
- const chunks = [];
20
- for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
21
- return Buffer.concat(chunks).toString("utf8");
19
+ function readStdin() {
20
+ return text(process.stdin);
22
21
  }
23
- /**
24
- * Decides one tool call. Writing nothing is a real answer: it means auto-mode
25
- * has no opinion, and the harness then does whatever it would have done alone.
26
- * That is the right output for an unknown harness, for an event that is not a
27
- * tool gate, and for anything the local tier declines to judge until the model
28
- * tier exists.
29
- */
30
22
  async function run(explain, localOnly) {
31
23
  const raw = await readStdin();
32
24
  let body;
33
25
  try {
34
26
  body = JSON.parse(raw);
35
27
  } catch {
36
- return note(explain, "stdin is not JSON, so no verdict");
28
+ return printNote(explain, "stdin is not JSON, so no verdict");
37
29
  }
38
30
  const payload = parsePayload(body);
39
- if (payload === null) return note(explain, "not a tool gate this hook judges, so no verdict");
31
+ if (payload === null) return printNote(explain, "not a tool gate this hook judges, so no verdict");
40
32
  const local = classifyLocally(payload);
41
33
  if (local.kind === "allow") {
42
34
  process.stdout.write(renderVerdict(payload.event, { kind: "allow" }));
43
- return note(explain, `allowed by ${local.exception} (${payload.harness}, local)`);
35
+ return printNote(explain, `allowed by ${local.exception} (${payload.harness}, local)`);
44
36
  }
45
- if (localOnly) return note(explain, `${payload.toolName} needs the model tier, which --local-only skipped`);
37
+ if (localOnly) return printNote(explain, `${payload.toolName} needs the model tier, which --local-only skipped`);
46
38
  const config = await loadConfig();
47
39
  const outcome = await classifyWithModel(payload, config);
48
40
  if (outcome.verdict !== null) process.stdout.write(renderVerdict(payload.event, outcome.verdict));
49
- return note(explain, outcome.note);
41
+ return printNote(explain, outcome.note);
50
42
  }
51
- function note(explain, text) {
52
- if (explain) process.stderr.write(`auto-mode: ${text}\n`);
43
+ function printNote(explain, message) {
44
+ if (explain) process.stderr.write(`auto-mode: ${message}\n`);
53
45
  return 0;
54
46
  }
55
47
  async function main(argv) {
56
- const { values, positionals } = parseArgs({
48
+ const args = parseArgs({
57
49
  args: [...argv],
58
50
  allowPositionals: true,
59
51
  options: {
@@ -67,29 +59,28 @@ async function main(argv) {
67
59
  }
68
60
  }
69
61
  });
70
- const command = positionals[0];
71
- if (values.help === true || command === void 0) {
62
+ const [command] = args.positionals;
63
+ if (args.values.help === true || command === void 0) {
72
64
  process.stdout.write(USAGE);
73
65
  return 0;
74
66
  }
75
- if (command === "run") return run(values.explain === true, values["local-only"] === true);
67
+ if (command === "run") return run(args.values.explain === true, args.values["local-only"] === true);
76
68
  if (command === "init") {
77
- const harness = positionals[1];
69
+ const [, harness] = args.positionals;
78
70
  if (harness !== "claude" && harness !== "codex" && harness !== "muse") {
79
71
  process.stderr.write("auto-mode init: name a harness — claude, codex or muse\n");
80
72
  return 2;
81
73
  }
82
- const target = harness;
83
- process.stdout.write(`# Add this to ${SETTINGS_PATHS[target]}\n`);
84
- process.stdout.write(`# Configuration lives at ${configPath()}\n`);
85
- process.stdout.write(`${hookConfig(target, `${process.execPath} ${process.argv[1] ?? "auto-mode"} run`)}\n`);
86
- for (const line of SETUP_NOTES[target]) process.stdout.write(`# ${line}\n`);
74
+ process.stdout.write(`# Add this to ${SETTINGS_PATHS[harness]}\n`);
75
+ process.stdout.write(`# Configuration lives at ${resolveConfigPath()}\n`);
76
+ process.stdout.write(`${buildHookConfig(harness, `${process.execPath} ${process.argv[1] ?? "auto-mode"} run`)}\n`);
77
+ for (const line of SETUP_NOTES[harness]) process.stdout.write(`# ${line}\n`);
87
78
  return 0;
88
79
  }
89
80
  if (command === "print-prompt") {
90
81
  const prompt = await loadPolicy({
91
- ...values.classifier === void 0 ? {} : { classifierPath: values.classifier },
92
- ...values.rules === void 0 ? {} : { rulesPath: values.rules }
82
+ classifierPath: args.values.classifier,
83
+ rulesPath: args.values.rules
93
84
  });
94
85
  process.stdout.write(`${prompt}\n`);
95
86
  return 0;
package/dist/index.d.ts CHANGED
@@ -1,63 +1,29 @@
1
1
  //#region src/config/config.d.ts
2
- /**
3
- * How to reach the judging model. Every supported provider speaks the Anthropic
4
- * Messages API, so one shape covers Claude, Muse, GLM and Kimi — only the base
5
- * URL, the model id and the key change.
6
- */
7
2
  interface ProviderConfig {
8
3
  readonly baseURL: string;
9
4
  readonly model: string;
10
- /** Environment variable holding the key. Tried first. */
11
- readonly apiKeyEnv?: string;
12
- /** Command that prints the key. Tried when the variable is unset or empty. */
13
- readonly apiKeyCommand?: string;
14
- /**
15
- * Whether this model reasons before answering. It drives three things at once
16
- * — how the prompt asks for the answer, how many tokens to allow, and how long
17
- * to wait — because a reasoning model needs more of all three.
18
- */
5
+ readonly apiKeyEnv?: string | undefined;
6
+ readonly apiKeyCommand?: string | undefined;
19
7
  readonly reasoning: boolean;
20
8
  readonly maxTokens: number;
21
9
  readonly timeoutMs: number;
22
10
  }
23
11
  interface Config {
24
12
  readonly provider: ProviderConfig;
25
- readonly classifierPath?: string;
26
- readonly rulesPath?: string;
27
- /** How many transcript entries to show the model, newest last. */
13
+ readonly classifierPath?: string | undefined;
14
+ readonly rulesPath?: string | undefined;
28
15
  readonly transcriptEntries: number;
29
- /**
30
- * What to do when the model cannot be reached or does not answer. `defer`
31
- * writes nothing, so the harness asks the operator as it would without this
32
- * hook. `deny` fails closed.
33
- */
34
16
  readonly onFailure: 'defer' | 'deny';
35
17
  }
36
- /**
37
- * Muse Spark is the default because it reasons, it was correct on every case we
38
- * measured, and a contributor-tier call costs about a fortieth of the
39
- * alternatives. Its token budget is not a preference: below roughly 2000 it
40
- * returns nothing at all.
41
- */
42
18
  export declare const DEFAULT_CONFIG: Config;
43
- /** Provider presets, so a config need only name one. */
44
19
  export declare const PRESETS: Readonly<Record<string, ProviderConfig>>;
45
- export declare function configPath(): string;
46
- /**
47
- * Reads the config, falling back to the shipped defaults. A missing file is
48
- * normal. A malformed one is not, and it throws rather than silently running a
49
- * policy the user did not write.
50
- */
20
+ export declare function resolveConfigPath(): string;
51
21
  export declare function loadConfig(path?: string): Promise<Config>;
52
- /** Reads the key from the environment, then from the helper command. */
53
22
  export declare function resolveApiKey(provider: ProviderConfig): Promise<string | null>;
54
23
  //#endregion
55
24
  //#region src/harness/types.d.ts
56
- /** A harness is the program that runs the agent and calls this hook. */
57
25
  type Harness = 'claude' | 'codex' | 'muse';
58
- /** The hook events auto-mode acts on. Others are read and ignored. */
59
26
  type HookEvent = 'PreToolUse' | 'PermissionRequest';
60
- /** One tool call, normalised across harnesses. */
61
27
  interface HookPayload {
62
28
  readonly harness: Harness;
63
29
  readonly event: HookEvent;
@@ -65,15 +31,9 @@ interface HookPayload {
65
31
  readonly cwd: string;
66
32
  readonly toolName: string;
67
33
  readonly toolInput: Readonly<Record<string, unknown>>;
68
- /** Where the harness keeps the conversation, when it says. */
69
- readonly transcriptPath?: string;
70
- /** The payload as it arrived, for anything this shape drops. */
34
+ readonly transcriptPath?: string | undefined;
71
35
  readonly raw: Readonly<Record<string, unknown>>;
72
36
  }
73
- /**
74
- * What auto-mode decides. `ask` hands the call back to the operator, which is
75
- * what the harness would have done without this hook.
76
- */
77
37
  type Verdict = {
78
38
  readonly kind: 'allow';
79
39
  } | {
@@ -85,64 +45,16 @@ type Verdict = {
85
45
  };
86
46
  //#endregion
87
47
  //#region src/harness/detect-harness.d.ts
88
- /**
89
- * Names the harness that sent a hook payload.
90
- *
91
- * The three shapes overlap, so order matters. Muse carries `model_provider`,
92
- * which neither of the others sends, so it is tested first — Muse also sends
93
- * `turn_id`, and testing that first would call every Muse payload Codex. Claude
94
- * is the only one that sends `prompt_id`. Codex is then what is left that
95
- * carries `turn_id`.
96
- *
97
- * Environment variables cannot help here: Muse runs hook commands with a
98
- * scrubbed environment, so a Muse hook sees none of them.
99
- */
100
48
  export declare function detectHarness(payload: Readonly<Record<string, unknown>>): Harness | null;
101
49
  //#endregion
102
50
  //#region src/harness/parse-payload.d.ts
103
- /**
104
- * Normalises one harness payload, or returns null when auto-mode has nothing to
105
- * say about it — an unknown harness, an event that is not a tool gate, or a
106
- * body that is not an object. A null is not a refusal: the caller stays out of
107
- * the way and the harness decides for itself.
108
- */
109
51
  export declare function parsePayload(body: unknown): HookPayload | null;
110
52
  //#endregion
111
53
  //#region src/harness/render-verdict.d.ts
112
- /**
113
- * Turns a verdict into the JSON the harness reads on stdout.
114
- *
115
- * All three harnesses take the same shape, because Codex and Muse both model
116
- * their hook contract on Claude's. `allow` and `ask` differ: `allow` runs the
117
- * tool with no prompt, while `ask` falls through to whatever the harness would
118
- * have done on its own, which is usually to ask the operator.
119
- */
120
54
  export declare function renderVerdict(event: HookEvent, verdict: Verdict): string;
121
55
  //#endregion
122
56
  //#region src/install/hook-config.d.ts
123
- /**
124
- * Prints the hook entry a harness needs, for the user to paste into their own
125
- * settings. auto-mode never writes those files itself: they are the user's
126
- * configuration, and an agent that can edit its own restraints has none.
127
- *
128
- * All three take a different entry shape, and each difference is load-bearing:
129
- *
130
- * - Claude matches tool names against a regular expression, so matching every
131
- * tool is `.*`. Two near-misses both leave the hook installed but never
132
- * firing: `*` is not a valid regular expression, and `""` matches only a tool
133
- * whose name is the empty string.
134
- * - Muse takes `""` to mean every tool, so the same value means opposite things
135
- * in the two harnesses.
136
- * - Codex registers a bare handler list with no matcher.
137
- * - Muse 1.3 rejects a handler carrying `timeout_ms`, reporting an unknown
138
- * field and skipping the whole handler silently, so the hook never runs. A
139
- * user-tier Muse entry takes `type` and `command` and nothing else.
140
- *
141
- * The timeout is 90 seconds, not the usual 30: a reasoning model takes 13-24
142
- * seconds on a hard case, and a harness timeout shorter than the model call
143
- * turns every hard case into a silent failure.
144
- */
145
- export declare function hookConfig(harness: Harness, command: string): string;
57
+ export declare function buildHookConfig(harness: Harness, command: string): string;
146
58
  export declare const SETTINGS_PATHS: Readonly<Record<Harness, string>>;
147
59
  //#endregion
148
60
  //#region src/model/classify-with-model.d.ts
@@ -150,44 +62,19 @@ interface ModelOutcome {
150
62
  readonly verdict: Verdict | null;
151
63
  readonly note: string;
152
64
  }
153
- /**
154
- * Asks the model. A null verdict means auto-mode has no opinion, and the caller
155
- * writes nothing so the harness decides for itself.
156
- */
157
65
  export declare function classifyWithModel(payload: HookPayload, config: Config): Promise<ModelOutcome>;
158
66
  //#endregion
159
67
  //#region src/model/parse-verdict.d.ts
160
- /**
161
- * Reads the classifier's answer.
162
- *
163
- * Every ambiguity resolves to allow, on the policy's own reasoning: a block
164
- * nobody can name is a false positive, and a false positive costs more than the
165
- * action it stopped. A model that says `yes` but names no rule has not made a
166
- * case, so there is nothing to show the user and nothing to appeal.
167
- */
168
68
  export declare function parseModelVerdict(text: string): Verdict;
169
69
  //#endregion
170
70
  //#region src/policy/load-policy.d.ts
171
71
  interface PolicyPaths {
172
- /** The framework: how to judge. Defaults to the shipped `policy/classifier.md`. */
173
- readonly classifierPath?: string;
174
- /** The rule list: what to judge against. Defaults to the shipped `policy/rules.md`. */
175
- readonly rulesPath?: string;
72
+ readonly classifierPath?: string | undefined;
73
+ readonly rulesPath?: string | undefined;
176
74
  }
177
- /**
178
- * Reads the two policy files and splices them into the one system prompt the
179
- * model receives. Nothing else is added, so `auto-mode print-prompt` shows
180
- * exactly what the classifier reads.
181
- */
182
75
  export declare function loadPolicy(paths?: PolicyPaths): Promise<string>;
183
76
  //#endregion
184
77
  //#region src/rules/classify-locally.d.ts
185
- /**
186
- * What the local tier decides. It allows or it declines to decide. It never
187
- * denies: a wrong local allow costs one unwatched action, while a wrong local
188
- * deny stops work the user asked for, and the rules that deny are prose that
189
- * needs a reader.
190
- */
191
78
  type LocalVerdict = {
192
79
  readonly kind: 'allow';
193
80
  readonly exception: string;
@@ -197,23 +84,10 @@ type LocalVerdict = {
197
84
  export declare function classifyLocally(payload: HookPayload): LocalVerdict;
198
85
  //#endregion
199
86
  //#region src/transcript/read-transcript.d.ts
200
- /** One line of the conversation, flattened to text the classifier can read. */
201
87
  interface TranscriptEntry {
202
88
  readonly role: string;
203
89
  readonly text: string;
204
90
  }
205
- /**
206
- * Reads the tail of a harness transcript.
207
- *
208
- * Only the tail, and always the same tail for a given session length, so the
209
- * prompt prefix grows by appending rather than sliding. A sliding window would
210
- * change the cached prefix on every call and pay full price each time.
211
- *
212
- * Every harness writes JSONL but none writes the same schema, so this reads
213
- * defensively and drops what it cannot understand. A transcript it cannot parse
214
- * yields no entries, and the classifier then judges the action with no history —
215
- * which is weaker, not wrong.
216
- */
217
91
  export declare function readTranscript(path: string | undefined, limit: number): Promise<readonly TranscriptEntry[]>;
218
92
  //#endregion
219
93
  export type { Config, Harness, HookEvent, HookPayload, LocalVerdict, ModelOutcome, PolicyPaths, ProviderConfig, TranscriptEntry, Verdict };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as loadPolicy, c as hookConfig, d as detectHarness, f as DEFAULT_CONFIG, g as resolveApiKey, h as loadConfig, i as readTranscript, l as renderVerdict, m as configPath, n as classifyWithModel, o as SETTINGS_PATHS, p as PRESETS, r as parseModelVerdict, t as classifyLocally, u as parsePayload } from "./classify-locally-BHl0xiqa.js";
2
- export { DEFAULT_CONFIG, PRESETS, SETTINGS_PATHS, classifyLocally, classifyWithModel, configPath, detectHarness, hookConfig, loadConfig, loadPolicy, parseModelVerdict, parsePayload, readTranscript, renderVerdict, resolveApiKey };
1
+ import { a as loadPolicy, c as buildHookConfig, d as detectHarness, f as DEFAULT_CONFIG, g as resolveConfigPath, h as resolveApiKey, i as readTranscript, l as renderVerdict, m as loadConfig, n as classifyWithModel, o as SETTINGS_PATHS, p as PRESETS, r as parseModelVerdict, t as classifyLocally, u as parsePayload } from "./classify-locally-rTUwxBZi.js";
2
+ export { DEFAULT_CONFIG, PRESETS, SETTINGS_PATHS, buildHookConfig, classifyLocally, classifyWithModel, detectHarness, loadConfig, loadPolicy, parseModelVerdict, parsePayload, readTranscript, renderVerdict, resolveApiKey, resolveConfigPath };
package/package.json CHANGED
@@ -1,23 +1,31 @@
1
1
  {
2
2
  "name": "auto-mode",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A permission classifier that runs as a hook, so agents that are not Claude can run unattended",
5
5
  "keywords": [
6
6
  "agent",
7
- "hook",
8
- "permissions",
9
7
  "claude-code",
10
8
  "codex",
11
- "muse"
9
+ "hook",
10
+ "muse",
11
+ "permissions"
12
12
  ],
13
+ "homepage": "https://github.com/zgeoff/auto-mode#readme",
14
+ "bugs": "https://github.com/zgeoff/auto-mode/issues",
13
15
  "license": "MIT",
14
- "type": "module",
15
- "engines": {
16
- "node": ">=24"
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/zgeoff/auto-mode.git"
17
19
  },
18
20
  "bin": {
19
21
  "auto-mode": "./dist/cli.js"
20
22
  },
23
+ "files": [
24
+ "dist",
25
+ "policy",
26
+ "LICENSE"
27
+ ],
28
+ "type": "module",
21
29
  "exports": {
22
30
  ".": {
23
31
  "types": "./dist/index.d.ts",
@@ -26,30 +34,47 @@
26
34
  "./policy/classifier.md": "./policy/classifier.md",
27
35
  "./policy/rules.md": "./policy/rules.md"
28
36
  },
29
- "files": [
30
- "dist",
31
- "policy",
32
- "LICENSE"
33
- ],
34
37
  "scripts": {
38
+ "audit": "bun audit",
35
39
  "build": "tsdown",
36
- "typecheck": "tsc --noEmit",
37
- "test": "vitest run --passWithNoTests",
40
+ "build:agents": "bash scripts/build-agents-md.sh agents/shared.md agents/project.md AGENTS.md",
41
+ "check": "bun run format:check && bun run lint && bun run typecheck && bun run deadcode && bun test && bun run build && bun run publint",
42
+ "deadcode": "knip",
43
+ "first-publish": "scripts/first-publish.sh",
44
+ "format": "oxfmt . && format-codemod --quiet .",
45
+ "format:check": "oxfmt --check . && format-codemod --check --quiet .",
46
+ "lint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
47
+ "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
48
+ "prepare": "command -v git > /dev/null 2>&1 && lefthook install --force || true",
38
49
  "publint": "publint",
39
- "check": "npm run typecheck && npm run test && npm run build && npm run publint",
40
- "first-publish": "scripts/first-publish.sh"
50
+ "test": "bun test",
51
+ "typecheck": "tsc -p tsconfig.json --noEmit"
41
52
  },
42
53
  "devDependencies": {
43
- "@types/node": "^24.13.5",
44
- "publint": "^0.3.24",
45
- "tsdown": "^0.23.0",
46
- "typescript": "^7.0.2",
47
- "vitest": "^5.0.1"
54
+ "@commitlint/cli": "21.2.2",
55
+ "@faker-js/faker": "10.6.0",
56
+ "@stylistic/eslint-plugin": "5.10.0",
57
+ "@tsconfig/strictest": "2.0.8",
58
+ "@types/bun": "1.3.14",
59
+ "@types/node": "26.5.1",
60
+ "@zgeoff/bun-test-extended": "0.1.0",
61
+ "@zgeoff/format-codemod": "0.1.0",
62
+ "@zgeoff/oxlint-config": "0.1.0",
63
+ "knip": "6.35.1",
64
+ "lefthook": "2.1.12",
65
+ "msw": "2.15.0",
66
+ "oxfmt": "0.67.0",
67
+ "oxlint": "1.82.0",
68
+ "oxlint-tsgolint": "7.0.2001",
69
+ "publint": "0.3.24",
70
+ "tiny-invariant": "1.3.3",
71
+ "ts-pattern": "5.9.0",
72
+ "tsdown": "0.23.0",
73
+ "typescript": "7.0.2",
74
+ "zod": "4.6.2"
48
75
  },
49
- "repository": {
50
- "type": "git",
51
- "url": "git+https://github.com/zgeoff/auto-mode.git"
76
+ "engines": {
77
+ "node": ">=24"
52
78
  },
53
- "homepage": "https://github.com/zgeoff/auto-mode#readme",
54
- "bugs": "https://github.com/zgeoff/auto-mode/issues"
79
+ "packageManager": "bun@1.3.10"
55
80
  }