@tachikomagundam/abathur 0.2.1 → 0.2.3

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,14 @@ 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
+ Both routes deliver the tool **and** the `/abathur` slash command; pick by
146
+ taste — A needs no edit of your opencode config and works fully offline, B
147
+ needs no file copying and removes with one deleted line.
148
+
149
+ **Route A — one command (file-copy installer).**
144
150
 
145
151
  ```bash
146
152
  npm i -g @tachikomagundam/abathur
@@ -148,7 +154,27 @@ abathur opencode install # copies plugin assets into ~/.config/opencode/
148
154
  # restart opencode
149
155
  ```
150
156
 
151
- After a restart the session gets two things:
157
+ **Route B config only (auto-downloaded from npm).** Put the package name in
158
+ your opencode config and restart:
159
+
160
+ ```jsonc
161
+ { "plugin": ["@tachikomagundam/abathur"] }
162
+ ```
163
+
164
+ opencode then downloads the package from npm at startup into its own cache
165
+ (`~/.cache/opencode/packages/…`) and loads the package's `./server` export —
166
+ no file copying, no uninstall step (delete the line). Since 0.2.3 the plugin
167
+ also self-registers `/abathur` through its `config` hook: slash commands are
168
+ entries in the merged config's `command` map, and a plugin may add one there
169
+ — the upstream `opencode-acp` plugin registers `/acp` the same way (the
170
+ 0.2.0–0.2.2 note here claiming plugins cannot register slash commands was
171
+ wrong; corrected). If Route A's copied `commands/abathur.md` exists it stays
172
+ authoritative — the injection never overwrites it, and the name-keyed command
173
+ map means `/abathur` can never appear twice. One honest limit stands for both
174
+ routes: the tool spawns the `abathur` **CLI**, which must exist on `PATH` (or
175
+ via `ABATHUR_BIN`) — keep the `npm i -g` install.
176
+
177
+ Either route gives the session:
152
178
 
153
179
  - the agent tool **`abathur`** — spawns the `abathur` CLI argv-only (never a
154
180
  shell), restricted to eight commands (`genome`, `run`, `status`, `bundle`,
@@ -161,9 +187,12 @@ After a restart the session gets two things:
161
187
  human gates and must be run in a terminal.
162
188
  - the user command **`/abathur <args…>`** — a slash command that tells the
163
189
  agent to translate its arguments into a tool call and report the exit code.
190
+ Route A provides it as the copied `commands/abathur.md` file; Route B gets
191
+ it from the plugin's `config` hook at startup.
164
192
 
165
- `abathur opencode status` shows each target's path, installed/packaged sha256,
166
- and state (up-to-date / outdated / foreign / absent).
193
+ Route A ships its own manager commands: `abathur opencode status` shows each
194
+ target's path, installed/packaged sha256, and state (up-to-date / outdated /
195
+ foreign / absent).
167
196
  `abathur opencode uninstall` removes only files carrying the abathur marker; a
168
197
  foreign file at a target path makes both commands refuse with exit 2 and name
169
198
  the path — nothing is ever overwritten or deleted behind your back (there is
@@ -408,8 +437,13 @@ abathur run --genome toy-smoke --dry-run # 计划 + requires[] 探针,零
408
437
 
409
438
  ### 在 opencode 里调用
410
439
 
411
- Abathur 自带官方 opencode 插件适配器。工装仍是编排者,但安装之后,opencode
412
- 会话也可以直接驱动 CLI
440
+ Abathur 自带官方 opencode 插件适配器。工装仍是编排者,但配置之后,opencode
441
+ 会话也可以直接驱动 CLI。安装有两条路线:
442
+
443
+ 两条路线都交付工具**和** `/abathur` 斜杠命令,按喜好挑选:A 不必改动你的
444
+ opencode 配置、且完全离线可用;B 不复制任何文件、删掉一行即卸载。
445
+
446
+ **路线 A——一条命令(文件复制安装器)。**
413
447
 
414
448
  ```bash
415
449
  npm i -g @tachikomagundam/abathur
@@ -417,7 +451,25 @@ abathur opencode install # 把插件资产复制进 ~/.config/opencode/
417
451
  # 重启 opencode
418
452
  ```
419
453
 
420
- 重启后会话获得两样东西:
454
+ **路线 B——纯配置(自动从 npm 下载)。** 把包名写进 opencode 配置再
455
+ 重启:
456
+
457
+ ```jsonc
458
+ { "plugin": ["@tachikomagundam/abathur"] }
459
+ ```
460
+
461
+ opencode 会在启动时自行从 npm 把包下载到它自己的缓存
462
+ (`~/.cache/opencode/packages/…`)并加载该包的 `./server` 导出——不复制文件,
463
+ 也没有 uninstall 步骤(删掉那行即可)。自 0.2.3 起,插件还会通过它的
464
+ `config` 钩子自注册 `/abathur`:斜杠命令本质是合并后配置的 `command`
465
+ 映射里的条目,插件可以在钩子里添加一条——上游的 `opencode-acp` 插件正是
466
+ 这样注册 `/acp` 的(0.2.0–0.2.2 在此声称"插件无法注册斜杠命令"是错的,
467
+ 已更正)。若路线 A 复制的 `commands/abathur.md` 存在,它保持权威——注入
468
+ 绝不覆盖它,而且命令映射按名字键控,`/abathur` 绝不会出现两份。对两条
469
+ 路线同样成立的一个诚实限制:工具 spawn 的是 `abathur` **CLI**,CLI 必须
470
+ 在 `PATH`(或 `ABATHUR_BIN`)上可达,`npm i -g` 不能省。
471
+
472
+ 任一路线都会让会话获得:
421
473
 
422
474
  - 智能体工具 **`abathur`**——以纯 argv 方式 spawn `abathur` CLI(绝不经过
423
475
  shell),顶层命令限定为八个(`genome`、`run`、`status`、`bundle`、`graft`、
@@ -426,10 +478,12 @@ abathur opencode install # 把插件资产复制进 ~/.config/opencode/
426
478
  bash 等同的权限——`run` 与 `genome` 按设计就会 spawn 变异器/引擎二进制——
427
479
  所以允许清单限制的是笔误与体验,而非能力。`promote` 与 `tombstone` 刻意
428
480
  不可经由工具触达:它们是人类闸门,必须在终端里运行。
429
- - 用户命令 **`/abathur <参数…>`**——斜杠命令,指示智能体把参数翻译成工具调用
430
- 并回报退出码。
481
+ - 用户命令 **`/abathur <参数…>`**——斜杠命令,指示智能体把参数
482
+ 翻译成工具调用并回报退出码。路线 A 由复制的 `commands/abathur.md`
483
+ 文件提供;路线 B 由插件的 `config` 钩子在启动时注册。
431
484
 
432
- `abathur opencode status` 逐目标打印路径、已安装/随包 sha256 与状态
485
+ 路线 A 自带管理命令:`abathur opencode status` 逐目标打印路径、已安装/随包
486
+ sha256 与状态
433
487
  (up-to-date / outdated / foreign / absent)。
434
488
  `abathur opencode uninstall` 只删除带有 abathur 标记的文件;若目标路径上躺着
435
489
  别人的文件,两条命令都会 exit 2 并点名路径——绝不会背着你覆盖或删除任何东西
@@ -62,9 +62,13 @@ fi
62
62
  echo "ran $unit"
63
63
  echo '{"tokensEst":123,"turns":4}'
64
64
  `;
65
+ // Unique sleep duration: node --test runs files concurrently and this test's
66
+ // orphan check scans the whole machine's `ps` output — "sleep 31.7" is the
67
+ // shipped toy genome's hang marker (bench-toy.test.ts); reusing it here made
68
+ // the two files' checks see each other's live sleeps (CI-verified 5/5 collision).
65
69
  const BIN_HANG = `#!/usr/bin/env bash
66
70
  if [ "\${1:-}" = "--version" ]; then echo "1.2.3"; exit 0; fi
67
- exec sleep 31.7
71
+ exec sleep 29.31
68
72
  `;
69
73
  const BIN_NO_VERSION = `#!/usr/bin/env bash
70
74
  if [ "\${1:-}" = "--version" ]; then echo "unsupported flag" >&2; exit 1; fi
@@ -296,7 +300,7 @@ test("(E) hanging unit: group kill at timeoutS, zero orphans", async (t) => {
296
300
  assert.equal(run.exitCode, null);
297
301
  assert.match(run.note ?? "", /killed after 1s: process group SIGKILL/);
298
302
  const ps = spawnSync("ps", ["-eo", "args"], { encoding: "utf8" });
299
- assert.ok(!ps.stdout.includes("sleep 31.7"), "orphaned sleep survived the group kill");
303
+ assert.ok(!ps.stdout.includes("sleep 29.31"), "orphaned sleep survived the group kill");
300
304
  });
301
305
  // ------------------------------------------------------------------ AC (F)
302
306
  test("(F) infra_failed is recorded with a distinct status/note, never a silent 0", async (t) => {
@@ -241,6 +241,27 @@ test("plugin/abathur.ts: V1 shape — marker, default {id, server}, tool allowli
241
241
  assert.ok(!/shell\s*:\s*true/.test(bytes), "shell:true is banned");
242
242
  assert.ok(bytes.includes("ABATHUR_BIN"), "bin overridable via ABATHUR_BIN");
243
243
  });
244
+ test("plugin/abathur.ts: config hook self-registers /abathur — template byte-mirrors the command md body", async () => {
245
+ const bytes = await readFile(pluginAssetPath, "utf8");
246
+ // Route B needs the config hook: opencode hands plugins the fully-merged config
247
+ // (file commands already inside cfg.command), so a `??=` injection registers
248
+ // /abathur ONLY when no commands/abathur.md exists — Route A bytes/behaviour
249
+ // stay untouched and the name-keyed command map guarantees no duplicate entry.
250
+ assert.ok(/config:\s*async\s*\(cfg:\s*Config\)/.test(bytes), "config hook with typed cfg required");
251
+ assert.ok(bytes.includes('import { tool, type Config }'), "Config type imported from @opencode-ai/plugin");
252
+ assert.ok(/cfg\.command\s*\?\?=\s*\{\}/.test(bytes), "cfg.command must be lazily created with ??=");
253
+ assert.ok(/cfg\.command\.abathur\s*\?\?=/.test(bytes), "entry must be ??= — a file-installed command is never overwritten");
254
+ assert.ok(bytes.includes('description: "Drive the abathur evolution harness (usage: /abathur <command> [args...])"'), "injected description must be pinned verbatim");
255
+ // Extract the template literal (inner backticks appear escaped as \`) and byte-compare.
256
+ const literal = bytes.match(/const COMMAND_TEMPLATE = `((?:[^`\\]|\\.)*)`;/);
257
+ assert.ok(literal !== null, "COMMAND_TEMPLATE literal must exist");
258
+ const template = (literal[1] ?? "").replaceAll("\\`", "`");
259
+ const md = await readFile(commandAssetPath, "utf8");
260
+ assert.ok(md.startsWith(COMMAND_MARKER), "md must still carry its first-line marker");
261
+ const body = md.slice(md.indexOf("\n") + 1); // everything after the marker line
262
+ assert.ok(body.startsWith("Drive the abathur evolution harness"), "body start sanity");
263
+ assert.equal(template, body, "injected template must byte-equal the command md minus its marker line");
264
+ });
244
265
  test("plugin/abathur-command.md: first-line marker, $ARGUMENTS, points at the abathur tool", async () => {
245
266
  const text = await readFile(commandAssetPath, "utf8");
246
267
  assert.equal(text.split("\n")[0], COMMAND_MARKER, "first line must be the command marker");
@@ -255,6 +276,26 @@ test("shipped plugin assets carry zero machine literals (D7 discipline extended
255
276
  }
256
277
  }
257
278
  });
279
+ test("npm name route: exports['./server'] ships the marker'd plugin + runtime dep declared", async () => {
280
+ const pkg = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
281
+ // opencode picks the server entry via exports["./server"] (extractExportValue
282
+ // accepts a plain string; plugin/shared.ts resolvePackageEntrypoint).
283
+ const serverEntry = pkg.exports?.["./server"];
284
+ assert.equal(typeof serverEntry, "string", "./server export must be a plain string path");
285
+ const server = serverEntry;
286
+ const target = path.resolve(repoRoot, server);
287
+ const head = (await readFile(target, "utf8")).split("\n")[0] ?? "";
288
+ assert.ok(head.startsWith(PLUGIN_MARKER), `server entry must start with the plugin marker: ${head}`);
289
+ // The tarball must actually contain that file or the name route installs nothing to import.
290
+ assert.ok((pkg.files ?? []).some((entry) => server.startsWith(`./${entry}/`)), "server entry must live under a published files[] directory");
291
+ // Arborist installs the package's dependencies next to it in opencode's cache —
292
+ // that sibling copy is how the shipped import of @opencode-ai/plugin resolves (OMO mechanism).
293
+ const dep = pkg.dependencies?.["@opencode-ai/plugin"];
294
+ assert.equal(typeof dep, "string", "@opencode-ai/plugin must be a runtime dependency");
295
+ assert.ok((dep ?? "").length > 0, "dependency range must not be empty");
296
+ // Route B must serve the same bytes the route A installer copies: both resolve to plugin/abathur.ts.
297
+ assert.equal(serverEntry, "./plugin/abathur.ts", "server entry and installer asset must be one file");
298
+ });
258
299
  test("CLI help: opencode is a registered top-level command", async (t) => {
259
300
  const { env } = await makeEnv(t);
260
301
  const help = abathur(env, "--help");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tachikomagundam/abathur",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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": {
package/plugin/abathur.ts CHANGED
@@ -1,4 +1,4 @@
1
- // abathur-opencode-plugin v0.2.1
1
+ // abathur-opencode-plugin v0.2.3
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,13 +8,20 @@
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. Both deliver the
13
+ // tool AND the /abathur slash command:
14
+ // (A) copied into ~/.config/opencode/plugins/ by `abathur opencode install`,
15
+ // which also drops commands/abathur.md; "@opencode-ai/plugin" then
16
+ // resolves in opencode's config-directory node_modules.
17
+ // (B) served as the package's "./server" export when the npm package name is
18
+ // listed in opencode.jsonc "plugin" — opencode's arborist install places
19
+ // @opencode-ai/plugin (runtime dependency) next to the package in its
20
+ // cache, so the same import resolves there too; with no commands/abathur.md
21
+ // on disk, the config hook below self-registers the command instead.
15
22
 
16
23
  import { execFile } from "node:child_process";
17
- import { tool } from "@opencode-ai/plugin";
24
+ import { tool, type Config } from "@opencode-ai/plugin";
18
25
 
19
26
  /** Spawn cap: a CLI call that outlives this is killed and reported, never awaited forever. */
20
27
  const TIMEOUT_MS = 120_000;
@@ -109,9 +116,47 @@ function runCli(bin: string, argv: readonly string[]): Promise<CliResult> {
109
116
  });
110
117
  }
111
118
 
119
+
120
+ /**
121
+ * /abathur slash-command template, injected into cfg.command.abathur by the
122
+ * config hook for installs without a commands/abathur.md on disk (Route B).
123
+ * Byte-mirror of plugin/abathur-command.md minus its first-line marker —
124
+ * src/test/opencode.test.ts pins the equality.
125
+ */
126
+ const COMMAND_TEMPLATE = `Drive the abathur evolution harness through the \`abathur\` tool on this machine.
127
+
128
+ User request: $ARGUMENTS
129
+
130
+ Interpret the request as one \`abathur\` CLI invocation: the first word is the
131
+ top-level command (\`genome\`, \`run\`, \`status\`, \`bundle\`, \`graft\`, \`self-eval\`,
132
+ \`kernel\`, or \`--help\`) and the rest are argv tokens. Call the \`abathur\` tool
133
+ with \`command\` set to the first word and \`extra\` set to the remaining tokens,
134
+ then report the CLI exit code (0 ok / 1 blocked decision / 2 cannot-answer)
135
+ and the relevant lines of its output. If no request was given, call the tool
136
+ with \`command: "--help"\` and summarize the command list. \`promote\` and
137
+ \`tombstone\` cannot be called through the tool at all — the tool refuses them.
138
+ They are human gates that belong to a terminal: if the user asks for one,
139
+ tell them to run \`abathur promote …\` / \`abathur tombstone …\` there.
140
+ `;
141
+
112
142
  export default {
113
143
  id: "abathur",
114
144
  server: async () => ({
145
+ // Self-registering /abathur: opencode calls hook.config(cfg) once per
146
+ // instance after ALL config sources are merged — file-based commands are
147
+ // already in cfg.command by then (config.ts merges {command,commands}/**/*.md;
148
+ // the hook fires from plugin/index.ts after config.get()). `??=` keeps a
149
+ // Route-A commands/abathur.md authoritative (identical behaviour to 0.2.2)
150
+ // and only fills the gap for Route B; the command map is keyed by name, so
151
+ // a same-name file + injection never duplicates the entry. Proven upstream
152
+ // mechanism: opencode-acp registers its /acp command exactly this way.
153
+ config: async (cfg: Config) => {
154
+ cfg.command ??= {};
155
+ cfg.command.abathur ??= {
156
+ description: "Drive the abathur evolution harness (usage: /abathur <command> [args...])",
157
+ template: COMMAND_TEMPLATE,
158
+ };
159
+ },
115
160
  tool: {
116
161
  abathur: tool({
117
162
  description: