@tachikomagundam/abathur 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -139,8 +139,10 @@ abathur run --genome toy-smoke --dry-run # plan + requires[] probes, zero sp
139
139
  ### Inside opencode
140
140
 
141
141
  Abathur ships an official opencode plugin adapter. The harness remains the
142
- orchestrator, but once installed an opencode session can also drive the CLI
143
- directly:
142
+ orchestrator, but once set up an opencode session can also drive the CLI
143
+ directly. Two install routes:
144
+
145
+ **Route A — one command (tool + `/abathur` slash command).**
144
146
 
145
147
  ```bash
146
148
  npm i -g @tachikomagundam/abathur
@@ -148,18 +150,39 @@ abathur opencode install # copies plugin assets into ~/.config/opencode/
148
150
  # restart opencode
149
151
  ```
150
152
 
151
- After a restart the session gets two things:
153
+ **Route B config only (tool, no slash command).** Put the package name in
154
+ your opencode config and restart:
155
+
156
+ ```jsonc
157
+ { "plugin": ["@tachikomagundam/abathur"] }
158
+ ```
159
+
160
+ opencode then downloads the package from npm at startup into its own cache
161
+ (`~/.cache/opencode/packages/…`) and loads the package's `./server` export —
162
+ no file copying, no uninstall step (delete the line). Two honest limits: the
163
+ `abathur` **CLI** still must exist on `PATH` (or `ABATHUR_BIN`) because the
164
+ tool spawns it, so keep the `npm i -g` install; and slash commands are plain
165
+ markdown files that plugins cannot register upstream, so `/abathur` is Route A
166
+ only.
167
+
168
+ Either route gives the session:
152
169
 
153
170
  - the agent tool **`abathur`** — spawns the `abathur` CLI argv-only (never a
154
- shell), restricted to the nine top-level commands plus `--help`
155
- (`genome`, `run`, `status`, `promote`, `tombstone`, `bundle`, `graft`,
156
- `self-eval`, `kernel`), with a 120 s timeout and a 64 KB output cap. The
157
- binary resolves from `PATH` unless `ABATHUR_BIN` overrides it.
158
- - the user command **`/abathur <args…>`** a slash command that tells the
159
- agent to translate its arguments into a tool call and report the exit code.
160
-
161
- `abathur opencode status` shows each target's path, installed/packaged sha256,
162
- and state (up-to-date / outdated / foreign / absent).
171
+ shell), restricted to eight commands (`genome`, `run`, `status`, `bundle`,
172
+ `graft`, `self-eval`, `kernel`, `--help`), with a 120 s timeout and a 64 KB
173
+ output cap. The binary resolves from `PATH` unless `ABATHUR_BIN` overrides
174
+ it. Honest privilege note: the tool carries bash-equivalent privilege —
175
+ `run` and `genome` legitimately spawn mutator/engine binaries by design
176
+ so the allowlist limits typos and UX, not capability. `promote` and
177
+ `tombstone` are deliberately NOT reachable through the tool: they are
178
+ human gates and must be run in a terminal.
179
+ - the user command **`/abathur <args…>`** (Route A only) a slash command
180
+ that tells the agent to translate its arguments into a tool call and report
181
+ the exit code.
182
+
183
+ Route A ships its own manager commands: `abathur opencode status` shows each
184
+ target's path, installed/packaged sha256, and state (up-to-date / outdated /
185
+ foreign / absent).
163
186
  `abathur opencode uninstall` removes only files carrying the abathur marker; a
164
187
  foreign file at a target path makes both commands refuse with exit 2 and name
165
188
  the path — nothing is ever overwritten or deleted behind your back (there is
@@ -404,8 +427,10 @@ abathur run --genome toy-smoke --dry-run # 计划 + requires[] 探针,零
404
427
 
405
428
  ### 在 opencode 里调用
406
429
 
407
- Abathur 自带官方 opencode 插件适配器。工装仍是编排者,但安装之后,opencode
408
- 会话也可以直接驱动 CLI
430
+ Abathur 自带官方 opencode 插件适配器。工装仍是编排者,但配置之后,opencode
431
+ 会话也可以直接驱动 CLI。安装有两条路线:
432
+
433
+ **路线 A——一条命令(工具 + `/abathur` 斜杠命令)。**
409
434
 
410
435
  ```bash
411
436
  npm i -g @tachikomagundam/abathur
@@ -413,16 +438,34 @@ abathur opencode install # 把插件资产复制进 ~/.config/opencode/
413
438
  # 重启 opencode
414
439
  ```
415
440
 
416
- 重启后会话获得两样东西:
441
+ **路线 B——纯配置(只有工具,没有斜杠命令)。** 把包名写进 opencode 配置再
442
+ 重启:
417
443
 
418
- - 智能体工具 **`abathur`**——以纯 argv 方式 spawn `abathur` CLI(绝不经过
419
- shell),顶层命令限定为九个真命令加 `--help`(`genome`、`run`、`status`、
420
- `promote`、`tombstone`、`bundle`、`graft`、`self-eval`、`kernel`),120 秒
421
- 超时、64 KB 输出封顶。二进制从 `PATH` 解析,除非用 `ABATHUR_BIN` 覆盖。
422
- - 用户命令 **`/abathur <参数…>`**——斜杠命令,指示智能体把参数翻译成工具调用
423
- 并回报退出码。
444
+ ```jsonc
445
+ { "plugin": ["@tachikomagundam/abathur"] }
446
+ ```
447
+
448
+ opencode 会在启动时自行从 npm 把包下载到它自己的缓存
449
+ (`~/.cache/opencode/packages/…`)并加载该包的 `./server` 导出——不复制文件,
450
+ 也没有 uninstall 步骤(删掉那行即可)。两点诚实的限制:工具 spawn 的是
451
+ `abathur` **CLI**,所以 CLI 仍必须在 `PATH`(或 `ABATHUR_BIN`)上可达,
452
+ `npm i -g` 不能省;而斜杠命令在上游只是 markdown 文件、插件无法注册,
453
+ 所以 `/abathur` 只有路线 A 提供。
424
454
 
425
- `abathur opencode status` 逐目标打印路径、已安装/随包 sha256 与状态
455
+ 任一路线都会让会话获得:
456
+
457
+ - 智能体工具 **`abathur`**——以纯 argv 方式 spawn `abathur` CLI(绝不经过
458
+ shell),顶层命令限定为八个(`genome`、`run`、`status`、`bundle`、`graft`、
459
+ `self-eval`、`kernel`、`--help`),120 秒超时、64 KB 输出封顶。二进制从
460
+ `PATH` 解析,除非用 `ABATHUR_BIN` 覆盖。诚实的权限说明:该工具具备与
461
+ bash 等同的权限——`run` 与 `genome` 按设计就会 spawn 变异器/引擎二进制——
462
+ 所以允许清单限制的是笔误与体验,而非能力。`promote` 与 `tombstone` 刻意
463
+ 不可经由工具触达:它们是人类闸门,必须在终端里运行。
464
+ - 用户命令 **`/abathur <参数…>`**(仅路线 A)——斜杠命令,指示智能体把参数
465
+ 翻译成工具调用并回报退出码。
466
+
467
+ 路线 A 自带管理命令:`abathur opencode status` 逐目标打印路径、已安装/随包
468
+ sha256 与状态
426
469
  (up-to-date / outdated / foreign / absent)。
427
470
  `abathur opencode uninstall` 只删除带有 abathur 标记的文件;若目标路径上躺着
428
471
  别人的文件,两条命令都会 exit 2 并点名路径——绝不会背着你覆盖或删除任何东西
@@ -5,9 +5,11 @@
5
5
  // {plugins,commands}/. No network, no shell, no opencode process contact.
6
6
  // Identity rule: a target is ours only if its FIRST LINE carries our marker;
7
7
  // a foreign file at a target path is refused (exit 2), never overwritten,
8
- // never deleted — there is no --force anywhere.
8
+ // never deleted — there is no --force anywhere. Destination rule (0.2.1):
9
+ // a path that exists must be a plain regular file — directories and symlinks
10
+ // are refused via lstat, never followed, entered, or destroyed.
9
11
  import { createHash } from "node:crypto";
10
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
13
  import os from "node:os";
12
14
  import path from "node:path";
13
15
  import { fileURLToPath } from "node:url";
@@ -79,9 +81,24 @@ function refuseForeign(target) {
79
81
  return cannotAnswer(`opencode: refusing to touch ${target.destination} — the file exists but is not ours ` +
80
82
  `(its first line lacks the marker '${target.marker}')`, "resolve it manually: move the foreign file aside (or adopt the marker), then re-run 'abathur opencode install'");
81
83
  }
84
+ /** Destination shape guard shared by install/uninstall (lstat: links are inspected, not followed). */
85
+ function requirePlainDestination(target) {
86
+ let shape;
87
+ try {
88
+ shape = lstatSync(target.destination);
89
+ }
90
+ catch {
91
+ return; // absent (or a not-yet-existing parent): the write path creates it fresh
92
+ }
93
+ if (!shape.isFile() || shape.isSymbolicLink()) {
94
+ cannotAnswer(`opencode: refusing to touch ${target.destination} — the path exists but is not a regular file ` +
95
+ `(directories and symlinks are never followed, entered, or destroyed)`, "resolve it manually: move the entry aside (or replace the symlink with a real file), then re-run 'abathur opencode install'");
96
+ }
97
+ }
82
98
  /** Atomicity rule shared by install/uninstall: validate EVERY target before writing to ANY. */
83
99
  function requireNoForeign(targets) {
84
100
  for (const target of targets) {
101
+ requirePlainDestination(target);
85
102
  const current = readTarget(target);
86
103
  if (current !== null && !carriesMarker(current, target.marker))
87
104
  refuseForeign(target);
@@ -89,10 +106,15 @@ function requireNoForeign(targets) {
89
106
  }
90
107
  function opencodeInstall(env) {
91
108
  const targets = pluginTargets(env);
109
+ // Read BOTH packaged assets up front: a missing second asset must fail before anything is written.
110
+ const prepared = targets.map((target) => ({ target, packaged: readPackagedAsset(target) }));
92
111
  requireNoForeign(targets);
93
- for (const target of targets) {
94
- const packaged = readPackagedAsset(target);
112
+ for (const { target, packaged } of prepared) {
95
113
  const { state } = classify(target, packaged);
114
+ // TOCTOU: identity is re-checked right before the write — a foreign file planted
115
+ // since validation is refused, never overwritten.
116
+ if (state === "foreign")
117
+ refuseForeign(target);
96
118
  if (state === "up-to-date") {
97
119
  writeStdout(`${target.destination}: up to date`);
98
120
  continue;
@@ -7,7 +7,7 @@
7
7
  import assert from "node:assert/strict";
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { createHash } from "node:crypto";
10
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
10
+ import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
11
11
  import * as os from "node:os";
12
12
  import path from "node:path";
13
13
  import { test } from "node:test";
@@ -98,6 +98,38 @@ test("install: foreign target without our marker ⇒ exit 2 naming the path, NOT
98
98
  // Atomicity: the refusal must happen before ANY write, so the command target stays absent.
99
99
  await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
100
100
  });
101
+ test("install: a DIRECTORY at a target path is refused (exit 2, named) — never entered or destroyed", async (t) => {
102
+ const { home, env } = await makeEnv(t);
103
+ const { pluginTs, commandMd } = targetPaths(home);
104
+ await mkdir(pluginTs, { recursive: true });
105
+ const inner = path.join(pluginTs, "someone-elses-plugin.ts");
106
+ await writeFile(inner, "// lives inside, must survive\n", "utf8");
107
+ const run = abathur(env, "opencode", "install");
108
+ assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
109
+ const combined = `${run.stdout}${run.stderr}`;
110
+ assert.ok(combined.includes(pluginTs), `refusal must name the path: ${combined}`);
111
+ assert.ok((await readFile(inner, "utf8")).includes("must survive"), "directory contents untouched");
112
+ // Atomicity still holds: validation precedes every write, so the sibling stays absent.
113
+ await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
114
+ });
115
+ test("install: a SYMLINK at a target path is refused (exit 2) — inspected by lstat, never followed", async (t) => {
116
+ const { home, env } = await makeEnv(t);
117
+ const { pluginTs, commandMd } = targetPaths(home);
118
+ // The marker check alone would pass here: readFileSync follows the link, and the
119
+ // old write would then destroy the external file. lstat must refuse the link itself.
120
+ const real = path.join(home, "elsewhere", "real-file.ts");
121
+ await mkdir(path.dirname(real), { recursive: true });
122
+ const original = `${PLUGIN_MARKER} 0.0.1\n// real file outside the opencode tree\n`;
123
+ await writeFile(real, original, "utf8");
124
+ await mkdir(path.dirname(pluginTs), { recursive: true });
125
+ await symlink(real, pluginTs);
126
+ const run = abathur(env, "opencode", "install");
127
+ assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
128
+ assert.ok(`${run.stdout}${run.stderr}`.includes(pluginTs), `refusal must name the path: ${run.stdout}${run.stderr}`);
129
+ assert.equal(await readFile(real, "utf8"), original, "file behind the link must be untouched");
130
+ assert.equal((await lstat(pluginTs)).isSymbolicLink(), true, "the symlink itself must survive too");
131
+ await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
132
+ });
101
133
  test("install: our marker'd older file is overwritten with packaged bytes", async (t) => {
102
134
  const { home, env } = await makeEnv(t);
103
135
  const { pluginTs } = targetPaths(home);
@@ -197,20 +229,14 @@ test("plugin/abathur.ts: V1 shape — marker, default {id, server}, tool allowli
197
229
  assert.ok(bytes.includes('id: "abathur"'), "file plugins must export id (resolvePluginId)");
198
230
  assert.ok(/server:\s*async\s*\(\)/.test(bytes), "server must be an async function (readV1Plugin)");
199
231
  assert.ok(bytes.includes("tool.schema"), "args must be built via tool.schema (no local zod dep)");
200
- for (const command of [
201
- "genome",
202
- "run",
203
- "status",
204
- "promote",
205
- "tombstone",
206
- "bundle",
207
- "graft",
208
- "self-eval",
209
- "kernel",
210
- "--help",
211
- ]) {
212
- assert.ok(bytes.includes(`"${command}"`), `allowlist must contain ${command}`);
213
- }
232
+ // Exact-array pin (0.2.1): parse the ALLOWED_COMMANDS literal from source — membership
233
+ // checks alone would let promote/tombstone creep back in via order or extras.
234
+ const literal = bytes.match(/const ALLOWED_COMMANDS[^=]*=\s*\[([^\]]*)\]/);
235
+ assert.ok(literal !== null, "ALLOWED_COMMANDS array literal must exist");
236
+ const allowed = [...(literal[1] ?? "").matchAll(/"([^"]+)"/g)].map((found) => found[1] ?? "");
237
+ assert.deepEqual(allowed, ["genome", "run", "status", "bundle", "graft", "self-eval", "kernel", "--help"]);
238
+ assert.ok(!allowed.includes("promote"), "promote is terminal-only — must not be tool-reachable");
239
+ assert.ok(!allowed.includes("tombstone"), "tombstone is terminal-only — must not be tool-reachable");
214
240
  assert.ok(bytes.includes("execFile"), "must spawn via execFile argv-only");
215
241
  assert.ok(!/shell\s*:\s*true/.test(bytes), "shell:true is banned");
216
242
  assert.ok(bytes.includes("ABATHUR_BIN"), "bin overridable via ABATHUR_BIN");
@@ -229,6 +255,26 @@ test("shipped plugin assets carry zero machine literals (D7 discipline extended
229
255
  }
230
256
  }
231
257
  });
258
+ test("npm name route: exports['./server'] ships the marker'd plugin + runtime dep declared", async () => {
259
+ const pkg = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
260
+ // opencode picks the server entry via exports["./server"] (extractExportValue
261
+ // accepts a plain string; plugin/shared.ts resolvePackageEntrypoint).
262
+ const serverEntry = pkg.exports?.["./server"];
263
+ assert.equal(typeof serverEntry, "string", "./server export must be a plain string path");
264
+ const server = serverEntry;
265
+ const target = path.resolve(repoRoot, server);
266
+ const head = (await readFile(target, "utf8")).split("\n")[0] ?? "";
267
+ assert.ok(head.startsWith(PLUGIN_MARKER), `server entry must start with the plugin marker: ${head}`);
268
+ // The tarball must actually contain that file or the name route installs nothing to import.
269
+ assert.ok((pkg.files ?? []).some((entry) => server.startsWith(`./${entry}/`)), "server entry must live under a published files[] directory");
270
+ // Arborist installs the package's dependencies next to it in opencode's cache —
271
+ // that sibling copy is how the shipped import of @opencode-ai/plugin resolves (OMO mechanism).
272
+ const dep = pkg.dependencies?.["@opencode-ai/plugin"];
273
+ assert.equal(typeof dep, "string", "@opencode-ai/plugin must be a runtime dependency");
274
+ assert.ok((dep ?? "").length > 0, "dependency range must not be empty");
275
+ // Route B must serve the same bytes the route A installer copies: both resolve to plugin/abathur.ts.
276
+ assert.equal(serverEntry, "./plugin/abathur.ts", "server entry and installer asset must be one file");
277
+ });
232
278
  test("CLI help: opencode is a registered top-level command", async (t) => {
233
279
  const { env } = await makeEnv(t);
234
280
  const help = abathur(env, "--help");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tachikomagundam/abathur",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Evolution harness: observe failures, mutate, re-bench, select — human-gated promotion, offline lineage bundles.",
5
5
  "author": "TachikomaGundam",
6
6
  "type": "module",
@@ -20,6 +20,11 @@
20
20
  "abathur": "dist/cli.js"
21
21
  },
22
22
  "main": "dist/cli.js",
23
+ "exports": {
24
+ ".": "./dist/cli.js",
25
+ "./server": "./plugin/abathur.ts",
26
+ "./package.json": "./package.json"
27
+ },
23
28
  "files": [
24
29
  "dist",
25
30
  "config",
@@ -33,6 +38,7 @@
33
38
  "prepack": "npm run build"
34
39
  },
35
40
  "dependencies": {
41
+ "@opencode-ai/plugin": "^1.17.4",
36
42
  "zod": "^4.0.0"
37
43
  },
38
44
  "devDependencies": {
@@ -4,11 +4,12 @@ Drive the abathur evolution harness through the `abathur` tool on this machine.
4
4
  User request: $ARGUMENTS
5
5
 
6
6
  Interpret the request as one `abathur` CLI invocation: the first word is the
7
- top-level command (`genome`, `run`, `status`, `promote`, `tombstone`, `bundle`,
8
- `graft`, `self-eval`, `kernel`, or `--help`) and the rest are argv tokens. Call
9
- the `abathur` tool with `command` set to the first word and `extra` set to the
10
- remaining tokens, then report the CLI exit code (0 ok / 1 blocked decision /
11
- 2 cannot-answer) and the relevant lines of its output. If no request was given,
12
- call the tool with `command: "--help"` and summarize the command list. Never
13
- call `promote` or `tombstone` unless the user asked for exactly that they are
14
- human gates.
7
+ top-level command (`genome`, `run`, `status`, `bundle`, `graft`, `self-eval`,
8
+ `kernel`, or `--help`) and the rest are argv tokens. Call the `abathur` tool
9
+ with `command` set to the first word and `extra` set to the remaining tokens,
10
+ then report the CLI exit code (0 ok / 1 blocked decision / 2 cannot-answer)
11
+ and the relevant lines of its output. If no request was given, call the tool
12
+ with `command: "--help"` and summarize the command list. `promote` and
13
+ `tombstone` cannot be called through the tool at all the tool refuses them.
14
+ They are human gates that belong to a terminal: if the user asks for one,
15
+ tell them to run `abathur promote …` / `abathur tombstone …` there.
package/plugin/abathur.ts CHANGED
@@ -1,4 +1,4 @@
1
- // abathur-opencode-plugin v0.2.0
1
+ // abathur-opencode-plugin v0.2.2
2
2
  // Official opencode plugin adapter for the abathur evolution harness.
3
3
  // Registers ONE agent tool, `abathur`, that shells out to the abathur CLI —
4
4
  // argv-only (node:child_process execFile, never a shell), top-level commands
@@ -8,10 +8,15 @@
8
8
  // V1 plugin format (opencode >= 1.14): default export { id, server }, where
9
9
  // server(input, options) resolves to Hooks; Hooks.tool is a
10
10
  // { [name]: ToolDefinition } record (see @opencode-ai/plugin).
11
- // This file is shipped as-is (package.json "files") and copied into
12
- // ~/.config/opencode/plugins/ by `abathur opencode install`. It is NOT
13
- // compiled by abathur's tsc; "@opencode-ai/plugin" resolves inside opencode's
14
- // own config-directory install.
11
+ // This file is shipped as-is (package.json "files") and reaches a session by
12
+ // two routes, neither compiling it through abathur's tsc:
13
+ // (A) copied into ~/.config/opencode/plugins/ by `abathur opencode install`
14
+ // (adds the /abathur command too); "@opencode-ai/plugin" then resolves in
15
+ // opencode's config-directory node_modules.
16
+ // (B) served as the package's "./server" export when the npm package name is
17
+ // listed in opencode.jsonc "plugin" — opencode's arborist install places
18
+ // @opencode-ai/plugin (runtime dependency) next to the package in its
19
+ // cache, so the same import resolves there too.
15
20
 
16
21
  import { execFile } from "node:child_process";
17
22
  import { tool } from "@opencode-ai/plugin";
@@ -21,13 +26,12 @@ const TIMEOUT_MS = 120_000;
21
26
  /** Per-stream output cap fed back into the session; keeps one tool call from flooding context. */
22
27
  const MAX_OUTPUT_BYTES = 64 * 1024;
23
28
 
24
- /** The nine real top-level commands plus --help. Anything else is refused locally. */
29
+ /** The seven tool-reachable top-level commands plus --help. Anything else is refused locally.
30
+ * promote/tombstone are human gates and deliberately terminal-only (0.2.1): not reachable here. */
25
31
  const ALLOWED_COMMANDS: readonly string[] = [
26
32
  "genome",
27
33
  "run",
28
34
  "status",
29
- "promote",
30
- "tombstone",
31
35
  "bundle",
32
36
  "graft",
33
37
  "self-eval",
@@ -89,14 +93,18 @@ function runCli(bin: string, argv: readonly string[]): Promise<CliResult> {
89
93
  }
90
94
  if (typeof failure.code !== "number") {
91
95
  const timedOut = failure.killed === true || (failure.signal !== undefined && failure.signal !== null);
92
- resolve({
93
- status: timedOut ? 124 : -1,
94
- stdout: out,
95
- stderr: errOut,
96
- note: timedOut
97
- ? `killed after ${String(TIMEOUT_MS / 1000)}s timeout (signal: ${String(failure.signal ?? "SIGTERM")})`
98
- : `spawn failed: ${failure.message}`,
99
- });
96
+ if (timedOut) {
97
+ let note = `killed after ${String(TIMEOUT_MS / 1000)}s timeout (signal: ${String(failure.signal ?? "SIGTERM")})`;
98
+ if (argv[0] === "run") {
99
+ // The timeout kills the CLI only; mutator/bench children are detached by design.
100
+ note +=
101
+ " abathur run spawns detached children (mutator/bench sessions) " +
102
+ "that may still be running — the next 'abathur run' reaps them";
103
+ }
104
+ resolve({ status: 124, stdout: out, stderr: errOut, note });
105
+ return;
106
+ }
107
+ resolve({ status: -1, stdout: out, stderr: errOut, note: `spawn failed: ${failure.message}` });
100
108
  return;
101
109
  }
102
110
  // Ordinary non-zero exit: the CLI's own verdict (blocked / cannot-answer).
@@ -114,11 +122,14 @@ export default {
114
122
  description:
115
123
  "Run the abathur evolution-harness CLI on this machine. " +
116
124
  "Pass the top-level command word in `command` (one of: genome, run, status, " +
117
- "promote, tombstone, bundle, graft, self-eval, kernel, --help) and every " +
125
+ "bundle, graft, self-eval, kernel, --help) and every " +
118
126
  "remaining argv token in `extra`. The call is spawned argv-only (no shell) " +
119
127
  "with a 120s timeout; the result text always ends with the CLI exit code " +
120
- "(0 ok, 1 blocked decision, 2 cannot-answer). Promotion is a human gate — " +
121
- "never call `promote` without the user explicitly asking.",
128
+ "(0 ok, 1 blocked decision, 2 cannot-answer). Honest privilege note: this " +
129
+ "tool carries bash-equivalent privilege — `run` and `genome` legitimately " +
130
+ "spawn mutator/engine binaries by design — so the allowlist limits typos " +
131
+ "and UX, not capability. `promote` and `tombstone` are deliberately NOT " +
132
+ "reachable here: they are human gates, run in a terminal.",
122
133
  args: {
123
134
  command: tool.schema
124
135
  .string()