@tachikomagundam/abathur 0.1.1 → 0.2.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/README.md CHANGED
@@ -136,6 +136,44 @@ abathur graft ./bundles/*.bundle.tgz --genome toy-smoke # local re-bench o
136
136
  abathur run --genome toy-smoke --dry-run # plan + requires[] probes, zero spawns
137
137
  ```
138
138
 
139
+ ### Inside opencode
140
+
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:
144
+
145
+ ```bash
146
+ npm i -g @tachikomagundam/abathur
147
+ abathur opencode install # copies plugin assets into ~/.config/opencode/
148
+ # restart opencode
149
+ ```
150
+
151
+ After a restart the session gets two things:
152
+
153
+ - the agent tool **`abathur`** — spawns the `abathur` CLI argv-only (never a
154
+ shell), restricted to eight commands (`genome`, `run`, `status`, `bundle`,
155
+ `graft`, `self-eval`, `kernel`, `--help`), with a 120 s timeout and a 64 KB
156
+ output cap. The binary resolves from `PATH` unless `ABATHUR_BIN` overrides
157
+ it. Honest privilege note: the tool carries bash-equivalent privilege —
158
+ `run` and `genome` legitimately spawn mutator/engine binaries by design —
159
+ so the allowlist limits typos and UX, not capability. `promote` and
160
+ `tombstone` are deliberately NOT reachable through the tool: they are
161
+ human gates and must be run in a terminal.
162
+ - the user command **`/abathur <args…>`** — a slash command that tells the
163
+ agent to translate its arguments into a tool call and report the exit code.
164
+
165
+ `abathur opencode status` shows each target's path, installed/packaged sha256,
166
+ and state (up-to-date / outdated / foreign / absent).
167
+ `abathur opencode uninstall` removes only files carrying the abathur marker; a
168
+ foreign file at a target path makes both commands refuse with exit 2 and name
169
+ the path — nothing is ever overwritten or deleted behind your back (there is
170
+ still no `--force` anywhere).
171
+
172
+ Caveat: fixture benches mirror the real `~/.config/opencode` (plugins and
173
+ commands included) into their sandboxed HOMEs, so an installed plugin also
174
+ loads inside bench sessions. Uninstall first if a bench needs a clean plugin
175
+ environment.
176
+
139
177
  ### The historian genome and the operator workflow
140
178
 
141
179
  `historian` is the first real genome: OpenCode agents work wiki scenarios
@@ -368,6 +406,38 @@ abathur graft ./bundles/*.bundle.tgz --genome toy-smoke # 对端血缘包
368
406
  abathur run --genome toy-smoke --dry-run # 计划 + requires[] 探针,零 spawn
369
407
  ```
370
408
 
409
+ ### 在 opencode 里调用
410
+
411
+ Abathur 自带官方 opencode 插件适配器。工装仍是编排者,但安装之后,opencode
412
+ 会话也可以直接驱动 CLI:
413
+
414
+ ```bash
415
+ npm i -g @tachikomagundam/abathur
416
+ abathur opencode install # 把插件资产复制进 ~/.config/opencode/
417
+ # 重启 opencode
418
+ ```
419
+
420
+ 重启后会话获得两样东西:
421
+
422
+ - 智能体工具 **`abathur`**——以纯 argv 方式 spawn `abathur` CLI(绝不经过
423
+ shell),顶层命令限定为八个(`genome`、`run`、`status`、`bundle`、`graft`、
424
+ `self-eval`、`kernel`、`--help`),120 秒超时、64 KB 输出封顶。二进制从
425
+ `PATH` 解析,除非用 `ABATHUR_BIN` 覆盖。诚实的权限说明:该工具具备与
426
+ bash 等同的权限——`run` 与 `genome` 按设计就会 spawn 变异器/引擎二进制——
427
+ 所以允许清单限制的是笔误与体验,而非能力。`promote` 与 `tombstone` 刻意
428
+ 不可经由工具触达:它们是人类闸门,必须在终端里运行。
429
+ - 用户命令 **`/abathur <参数…>`**——斜杠命令,指示智能体把参数翻译成工具调用
430
+ 并回报退出码。
431
+
432
+ `abathur opencode status` 逐目标打印路径、已安装/随包 sha256 与状态
433
+ (up-to-date / outdated / foreign / absent)。
434
+ `abathur opencode uninstall` 只删除带有 abathur 标记的文件;若目标路径上躺着
435
+ 别人的文件,两条命令都会 exit 2 并点名路径——绝不会背着你覆盖或删除任何东西
436
+ (整个二进制依然没有 `--force`)。
437
+
438
+ 注意:夹具基准会把真实的 `~/.config/opencode`(含插件与命令)镜像进沙箱 HOME,
439
+ 因此装好的插件在基准会话里同样会加载。若基准需要干净的插件环境,请先 uninstall。
440
+
371
441
  ### 历史学家基因组与操作员工作流
372
442
 
373
443
  `historian` 是第一个真实基因组:OpenCode 智能体对着在线 Wiki.js 夹具完成 wiki
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { bundleCommand } from "./commands/bundle.js";
10
10
  import { genomeCommand } from "./commands/genome.js";
11
11
  import { graftCommand } from "./commands/graft.js";
12
12
  import { kernelCommand } from "./commands/kernel.js";
13
+ import { opencodeCommand } from "./commands/opencode.js";
13
14
  import { promoteCommand } from "./commands/promote.js";
14
15
  import { runCommand } from "./commands/run.js";
15
16
  import { selfEvalCommand } from "./commands/self-eval.js";
@@ -28,6 +29,7 @@ export const COMMANDS = [
28
29
  graftCommand,
29
30
  selfEvalCommand,
30
31
  kernelCommand,
32
+ opencodeCommand,
31
33
  ];
32
34
  export function usage() {
33
35
  const width = Math.max(...COMMANDS.map((c) => c.name.length));
@@ -0,0 +1,174 @@
1
+ // `abathur opencode` — installer for the official opencode plugin adapter
2
+ // (todo: opencode-plugin 1). Pure file management: byte-copies the packaged
3
+ // V1 plugin (`<package>/plugin/abathur.ts`) and slash-command template
4
+ // (`<package>/plugin/abathur-command.md`) into <HOME>/.config/opencode/
5
+ // {plugins,commands}/. No network, no shell, no opencode process contact.
6
+ // Identity rule: a target is ours only if its FIRST LINE carries our marker;
7
+ // a foreign file at a target path is refused (exit 2), never overwritten,
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.
11
+ import { createHash } from "node:crypto";
12
+ import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { EXIT_OK, cannotAnswer } from "../exit.js";
17
+ import { writeStdout } from "../out.js";
18
+ /** First-line identity markers (the asset files must start with exactly these prefixes). */
19
+ export const PLUGIN_MARKER = "// abathur-opencode-plugin v";
20
+ export const COMMAND_MARKER = "<!-- abathur-opencode-command -->";
21
+ // Packaged assets live at <package>/plugin/ — two levels above dist/commands/
22
+ // (same import.meta.url-relative style as the shipped-config path in src/config.ts).
23
+ const PLUGIN_TS_ASSET = fileURLToPath(new URL("../../plugin/abathur.ts", import.meta.url));
24
+ const COMMAND_MD_ASSET = fileURLToPath(new URL("../../plugin/abathur-command.md", import.meta.url));
25
+ /** <HOME>/.config/opencode — HOME env-driven (os.homedir() fallback), never a literal. */
26
+ export function resolveOpencodeConfigDir(env = process.env) {
27
+ const home = env.HOME === undefined || env.HOME.length === 0 ? os.homedir() : env.HOME;
28
+ return path.join(home, ".config", "opencode");
29
+ }
30
+ export function pluginTargets(env = process.env) {
31
+ const dir = resolveOpencodeConfigDir(env);
32
+ return [
33
+ {
34
+ label: "plugin",
35
+ source: PLUGIN_TS_ASSET,
36
+ destination: path.join(dir, "plugins", "abathur.ts"),
37
+ marker: PLUGIN_MARKER,
38
+ },
39
+ {
40
+ label: "command",
41
+ source: COMMAND_MD_ASSET,
42
+ destination: path.join(dir, "commands", "abathur.md"),
43
+ marker: COMMAND_MARKER,
44
+ },
45
+ ];
46
+ }
47
+ function readPackagedAsset(target) {
48
+ try {
49
+ return readFileSync(target.source);
50
+ }
51
+ catch {
52
+ return cannotAnswer(`opencode: packaged ${target.label} asset is missing (${target.source})`, "the abathur install looks broken — re-run 'npm i -g @tachikomagundam/abathur'");
53
+ }
54
+ }
55
+ function readTarget(target) {
56
+ try {
57
+ return readFileSync(target.destination);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ function sha256(bytes) {
64
+ return createHash("sha256").update(bytes).digest("hex");
65
+ }
66
+ function carriesMarker(bytes, marker) {
67
+ const text = bytes.toString("utf8");
68
+ const newline = text.indexOf("\n");
69
+ const firstLine = newline < 0 ? text : text.slice(0, newline);
70
+ return firstLine.startsWith(marker);
71
+ }
72
+ function classify(target, packaged) {
73
+ const current = readTarget(target);
74
+ if (current === null)
75
+ return { state: "absent", installed: null };
76
+ if (!carriesMarker(current, target.marker))
77
+ return { state: "foreign", installed: current };
78
+ return { state: current.equals(packaged) ? "up-to-date" : "outdated", installed: current };
79
+ }
80
+ function refuseForeign(target) {
81
+ return cannotAnswer(`opencode: refusing to touch ${target.destination} — the file exists but is not ours ` +
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'");
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
+ }
98
+ /** Atomicity rule shared by install/uninstall: validate EVERY target before writing to ANY. */
99
+ function requireNoForeign(targets) {
100
+ for (const target of targets) {
101
+ requirePlainDestination(target);
102
+ const current = readTarget(target);
103
+ if (current !== null && !carriesMarker(current, target.marker))
104
+ refuseForeign(target);
105
+ }
106
+ }
107
+ function opencodeInstall(env) {
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) }));
111
+ requireNoForeign(targets);
112
+ for (const { target, packaged } of prepared) {
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);
118
+ if (state === "up-to-date") {
119
+ writeStdout(`${target.destination}: up to date`);
120
+ continue;
121
+ }
122
+ mkdirSync(path.dirname(target.destination), { recursive: true }); // precedent: src/core/genome.ts registry dirs
123
+ writeFileSync(target.destination, packaged);
124
+ writeStdout(`${target.destination}: ${state === "outdated" ? "updated" : "installed"}`);
125
+ }
126
+ writeStdout("note: restart opencode to load the plugin — tools and commands are scanned at startup.");
127
+ writeStdout("note: abathur fixture benches mirror the real ~/.config/opencode (plugins and commands " +
128
+ "included) into sandboxed HOMEs, so this plugin will also load inside bench sessions.");
129
+ return EXIT_OK;
130
+ }
131
+ function opencodeStatus(env) {
132
+ for (const target of pluginTargets(env)) {
133
+ const packaged = readPackagedAsset(target);
134
+ const { state, installed } = classify(target, packaged);
135
+ const installedSha = installed === null ? "-" : sha256(installed);
136
+ writeStdout(`${target.destination}: ${state} installed=${installedSha} packaged=${sha256(packaged)}`);
137
+ }
138
+ return EXIT_OK;
139
+ }
140
+ function opencodeUninstall(env) {
141
+ const targets = pluginTargets(env);
142
+ requireNoForeign(targets);
143
+ for (const target of targets) {
144
+ if (!existsSync(target.destination)) {
145
+ writeStdout(`${target.destination}: absent (nothing to remove)`);
146
+ continue;
147
+ }
148
+ rmSync(target.destination);
149
+ writeStdout(`${target.destination}: removed`);
150
+ }
151
+ writeStdout("note: restart opencode for the tool and command to disappear.");
152
+ return EXIT_OK;
153
+ }
154
+ function runOpencode(args) {
155
+ const [sub, ...rest] = args;
156
+ if (rest.length > 0) {
157
+ cannotAnswer(`opencode ${sub}: unexpected argument '${rest[0]}'`, "usage: abathur opencode install | status | uninstall");
158
+ }
159
+ switch (sub) {
160
+ case "install":
161
+ return opencodeInstall(process.env);
162
+ case "status":
163
+ return opencodeStatus(process.env);
164
+ case "uninstall":
165
+ return opencodeUninstall(process.env);
166
+ default:
167
+ return cannotAnswer(`opencode: unknown subcommand '${sub ?? "<none>"}'`, "usage: abathur opencode install | status | uninstall");
168
+ }
169
+ }
170
+ export const opencodeCommand = {
171
+ name: "opencode",
172
+ summary: "install/status/uninstall the official opencode plugin adapter",
173
+ run: ({ args }) => runOpencode(args),
174
+ };
@@ -0,0 +1,263 @@
1
+ // Opencode plugin-adapter acceptance pins (plan abathur-opencode-plugin, todo 1).
2
+ // CLI convention per genome.test.ts: spawn the BUILT dist/cli.js with a sandboxed
3
+ // HOME (tmpdir) + ABATHUR_CONFIG, so install/status/uninstall never touch the
4
+ // real ~/.config/opencode. The packaged plugin assets are additionally pinned
5
+ // here as plain text (marker first line, no shell, version parity) — the real
6
+ // opencode runtime consumes them uncompiled, so these string gates are the CI.
7
+ import assert from "node:assert/strict";
8
+ import { spawnSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
11
+ import * as os from "node:os";
12
+ import path from "node:path";
13
+ import { test } from "node:test";
14
+ import { fileURLToPath } from "node:url";
15
+ const cliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
16
+ /** Repo root as seen from dist/test/: package.json and plugin/ sit one level up each. */
17
+ const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
18
+ const pluginAssetPath = path.join(repoRoot, "plugin", "abathur.ts");
19
+ const commandAssetPath = path.join(repoRoot, "plugin", "abathur-command.md");
20
+ const PLUGIN_MARKER = "// abathur-opencode-plugin v";
21
+ const COMMAND_MARKER = "<!-- abathur-opencode-command -->";
22
+ /** D7 discipline extends to the shipped plugin/ dir even though the gate walks only src/. */
23
+ const FORBIDDEN_NEEDLES = ["/home/lab", "historian"];
24
+ // ------------------------------------------------------------------- fixtures
25
+ async function freshDir(prefix) {
26
+ return mkdtemp(path.join(os.tmpdir(), `abathur-${prefix}-`));
27
+ }
28
+ function keep(t, dir) {
29
+ t.after(() => rm(dir, { recursive: true, force: true }));
30
+ return dir;
31
+ }
32
+ /** Sandboxed HOME: opencode targets resolve under <home>/.config/opencode, config under <home>/.config/abathur. */
33
+ async function makeEnv(t) {
34
+ const home = keep(t, await freshDir("oc-home"));
35
+ const configDir = path.join(home, ".config", "abathur");
36
+ await mkdir(configDir, { recursive: true });
37
+ const configPath = path.join(configDir, "config.jsonc");
38
+ await writeFile(configPath, "// isolated test config\n{}\n", "utf8");
39
+ return { home, env: { ...process.env, ABATHUR_CONFIG: configPath, HOME: home } };
40
+ }
41
+ function abathur(env, ...args) {
42
+ const run = spawnSync(process.execPath, [cliPath, ...args], { env, encoding: "utf8" });
43
+ return { status: run.status ?? -1, stdout: run.stdout, stderr: run.stderr };
44
+ }
45
+ function sha256(bytes) {
46
+ return createHash("sha256").update(bytes).digest("hex");
47
+ }
48
+ function targetPaths(home) {
49
+ const dir = path.join(home, ".config", "opencode");
50
+ return {
51
+ pluginTs: path.join(dir, "plugins", "abathur.ts"),
52
+ commandMd: path.join(dir, "commands", "abathur.md"),
53
+ };
54
+ }
55
+ async function packagedBytes() {
56
+ return {
57
+ ts: await readFile(pluginAssetPath),
58
+ md: await readFile(commandAssetPath),
59
+ };
60
+ }
61
+ // --------------------------------------------------------------- install
62
+ test("install: writes both targets byte-identical to packaged assets + restart/bench notes", async (t) => {
63
+ const { home, env } = await makeEnv(t);
64
+ const packaged = await packagedBytes();
65
+ const { pluginTs, commandMd } = targetPaths(home);
66
+ const run = abathur(env, "opencode", "install");
67
+ assert.equal(run.status, 0, `${run.stdout}${run.stderr}`);
68
+ assert.ok(run.stdout.includes(pluginTs), `output must name ${pluginTs}: ${run.stdout}`);
69
+ assert.ok(run.stdout.includes(commandMd), `output must name ${commandMd}: ${run.stdout}`);
70
+ assert.ok(/restart opencode/.test(run.stdout), `restart note missing: ${run.stdout}`);
71
+ assert.ok(/mirror/.test(run.stdout), `bench-mirror warning missing: ${run.stdout}`);
72
+ assert.deepEqual(await readFile(pluginTs), packaged.ts);
73
+ assert.deepEqual(await readFile(commandMd), packaged.md);
74
+ });
75
+ test("install: idempotent — second run says up to date, bytes untouched", async (t) => {
76
+ const { home, env } = await makeEnv(t);
77
+ const { pluginTs, commandMd } = targetPaths(home);
78
+ assert.equal(abathur(env, "opencode", "install").status, 0);
79
+ const second = abathur(env, "opencode", "install");
80
+ assert.equal(second.status, 0, `${second.stdout}${second.stderr}`);
81
+ const upToDate = second.stdout.split("\n").filter((line) => line.includes("up to date"));
82
+ assert.equal(upToDate.length, 2, `both targets must report up to date: ${second.stdout}`);
83
+ const after = await packagedBytes();
84
+ assert.deepEqual(await readFile(pluginTs), after.ts);
85
+ assert.deepEqual(await readFile(commandMd), after.md);
86
+ });
87
+ test("install: foreign target without our marker ⇒ exit 2 naming the path, NOTHING written", async (t) => {
88
+ const { home, env } = await makeEnv(t);
89
+ const { pluginTs, commandMd } = targetPaths(home);
90
+ const foreign = "// someone else's plugin, no marker\nexport default { id: 'other' };\n";
91
+ await mkdir(path.dirname(pluginTs), { recursive: true });
92
+ await writeFile(pluginTs, foreign, "utf8");
93
+ const run = abathur(env, "opencode", "install");
94
+ assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
95
+ const combined = `${run.stdout}${run.stderr}`;
96
+ assert.ok(combined.includes(pluginTs), `refusal must name the path: ${combined}`);
97
+ assert.equal(await readFile(pluginTs, "utf8"), foreign, "foreign bytes must survive");
98
+ // Atomicity: the refusal must happen before ANY write, so the command target stays absent.
99
+ await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
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
+ });
133
+ test("install: our marker'd older file is overwritten with packaged bytes", async (t) => {
134
+ const { home, env } = await makeEnv(t);
135
+ const { pluginTs } = targetPaths(home);
136
+ await mkdir(path.dirname(pluginTs), { recursive: true });
137
+ await writeFile(pluginTs, `${PLUGIN_MARKER} 0.0.1\n// ancient draft\n`, "utf8");
138
+ const run = abathur(env, "opencode", "install");
139
+ assert.equal(run.status, 0, `${run.stdout}${run.stderr}`);
140
+ assert.ok(run.stdout.includes(pluginTs));
141
+ const packaged = await packagedBytes();
142
+ assert.deepEqual(await readFile(pluginTs), packaged.ts);
143
+ });
144
+ test("install: mkdir -p creates the plugins/ and commands/ dirs in a virgin HOME", async (t) => {
145
+ const { home, env } = await makeEnv(t);
146
+ const { pluginTs, commandMd } = targetPaths(home);
147
+ assert.equal(abathur(env, "opencode", "install").status, 0);
148
+ // Reading succeeded only because the directories were created on demand.
149
+ assert.ok((await readFile(pluginTs, "utf8")).startsWith(PLUGIN_MARKER));
150
+ assert.ok((await readFile(commandMd, "utf8")).startsWith(COMMAND_MARKER));
151
+ });
152
+ test("opencode: unknown subcommand and stray args exit 2 with usage", async (t) => {
153
+ const { env } = await makeEnv(t);
154
+ const unknown = abathur(env, "opencode", "frobnicate");
155
+ assert.equal(unknown.status, 2);
156
+ assert.ok(`${unknown.stdout}${unknown.stderr}`.includes("unknown subcommand"));
157
+ const none = abathur(env, "opencode");
158
+ assert.equal(none.status, 2);
159
+ const stray = abathur(env, "opencode", "install", "--force");
160
+ assert.equal(stray.status, 2);
161
+ assert.ok(`${stray.stdout}${stray.stderr}`.includes("unexpected argument"));
162
+ });
163
+ // ---------------------------------------------------------------- status
164
+ test("status: virgin HOME ⇒ absent; after install ⇒ up-to-date with packaged sha256", async (t) => {
165
+ const { home, env } = await makeEnv(t);
166
+ const packaged = await packagedBytes();
167
+ const fresh = abathur(env, "opencode", "status");
168
+ assert.equal(fresh.status, 0, fresh.stderr);
169
+ const absent = fresh.stdout.split("\n").filter((line) => line.includes(": absent"));
170
+ assert.equal(absent.length, 2, `both targets absent initially: ${fresh.stdout}`);
171
+ assert.equal(abathur(env, "opencode", "install").status, 0);
172
+ const ok = abathur(env, "opencode", "status");
173
+ assert.equal(ok.status, 0);
174
+ const { pluginTs, commandMd } = targetPaths(home);
175
+ const pluginRow = ok.stdout.split("\n").find((line) => line.startsWith(pluginTs));
176
+ const commandRow = ok.stdout.split("\n").find((line) => line.startsWith(commandMd));
177
+ assert.ok(pluginRow !== undefined && commandRow !== undefined, `both rows present: ${ok.stdout}`);
178
+ assert.ok(pluginRow.includes(": up-to-date"), `plugin up-to-date: ${pluginRow}`);
179
+ assert.ok(commandRow.includes(": up-to-date"), `command up-to-date: ${commandRow}`);
180
+ assert.ok(pluginRow.includes(`installed=${sha256(packaged.ts)} packaged=${sha256(packaged.ts)}`), pluginRow);
181
+ assert.ok(commandRow.includes(`installed=${sha256(packaged.md)} packaged=${sha256(packaged.md)}`), commandRow);
182
+ });
183
+ test("status: outdated (our marker, different bytes) and foreign (no marker) are distinct", async (t) => {
184
+ const { home, env } = await makeEnv(t);
185
+ const { pluginTs, commandMd } = targetPaths(home);
186
+ await mkdir(path.dirname(pluginTs), { recursive: true });
187
+ await writeFile(pluginTs, `${PLUGIN_MARKER} 0.0.1\n// stale\n`, "utf8");
188
+ await mkdir(path.dirname(commandMd), { recursive: true });
189
+ await writeFile(commandMd, "not ours at all\n", "utf8");
190
+ const run = abathur(env, "opencode", "status");
191
+ assert.equal(run.status, 0);
192
+ assert.ok(run.stdout.includes(": outdated"), `stale ours → outdated: ${run.stdout}`);
193
+ assert.ok(run.stdout.includes(": foreign"), `unmarked → foreign: ${run.stdout}`);
194
+ });
195
+ // ------------------------------------------------------------- uninstall
196
+ test("uninstall: removes both of ours; second run reports absent and still exits 0", async (t) => {
197
+ const { home, env } = await makeEnv(t);
198
+ const { pluginTs, commandMd } = targetPaths(home);
199
+ assert.equal(abathur(env, "opencode", "install").status, 0);
200
+ const off = abathur(env, "opencode", "uninstall");
201
+ assert.equal(off.status, 0, `${off.stdout}${off.stderr}`);
202
+ await assert.rejects(readFile(pluginTs), (e) => e.code === "ENOENT");
203
+ await assert.rejects(readFile(commandMd), (e) => e.code === "ENOENT");
204
+ const again = abathur(env, "opencode", "uninstall");
205
+ assert.equal(again.status, 0);
206
+ assert.equal(again.stdout.split("\n").filter((l) => l.includes("absent")).length, 2);
207
+ });
208
+ test("uninstall: refuses foreign file and leaves the sibling of ours in place", async (t) => {
209
+ const { home, env } = await makeEnv(t);
210
+ const { pluginTs, commandMd } = targetPaths(home);
211
+ assert.equal(abathur(env, "opencode", "install").status, 0);
212
+ await writeFile(commandMd, "hijacked, marker gone\n", "utf8");
213
+ const run = abathur(env, "opencode", "uninstall");
214
+ assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
215
+ assert.ok(`${run.stdout}${run.stderr}`.includes(commandMd), "refusal names the foreign path");
216
+ // Atomicity: nothing removed — our plugin file must still be installed.
217
+ assert.ok((await readFile(pluginTs, "utf8")).startsWith(PLUGIN_MARKER));
218
+ assert.ok((await readFile(commandMd, "utf8")).includes("hijacked"));
219
+ });
220
+ // --------------------------------------------------- packaged asset sanity
221
+ test("plugin/abathur.ts: V1 shape — marker, default {id, server}, tool allowlist, argv-only spawn", async () => {
222
+ const bytes = await readFile(pluginAssetPath, "utf8");
223
+ const firstLine = bytes.split("\n")[0] ?? "";
224
+ assert.ok(firstLine.startsWith(PLUGIN_MARKER), `first line must be the marker: ${firstLine}`);
225
+ const version = firstLine.slice(PLUGIN_MARKER.length).trim();
226
+ const pkg = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
227
+ assert.equal(version, pkg.version, "plugin marker version must equal package.json version");
228
+ assert.ok(/export default\s*\{/.test(bytes), "V1 default export object required");
229
+ assert.ok(bytes.includes('id: "abathur"'), "file plugins must export id (resolvePluginId)");
230
+ assert.ok(/server:\s*async\s*\(\)/.test(bytes), "server must be an async function (readV1Plugin)");
231
+ assert.ok(bytes.includes("tool.schema"), "args must be built via tool.schema (no local zod dep)");
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");
240
+ assert.ok(bytes.includes("execFile"), "must spawn via execFile argv-only");
241
+ assert.ok(!/shell\s*:\s*true/.test(bytes), "shell:true is banned");
242
+ assert.ok(bytes.includes("ABATHUR_BIN"), "bin overridable via ABATHUR_BIN");
243
+ });
244
+ test("plugin/abathur-command.md: first-line marker, $ARGUMENTS, points at the abathur tool", async () => {
245
+ const text = await readFile(commandAssetPath, "utf8");
246
+ assert.equal(text.split("\n")[0], COMMAND_MARKER, "first line must be the command marker");
247
+ assert.ok(text.includes("$ARGUMENTS"), "template must forward $ARGUMENTS");
248
+ assert.ok(text.includes("`abathur` tool"), "must instruct the agent to use the abathur tool");
249
+ });
250
+ test("shipped plugin assets carry zero machine literals (D7 discipline extended to plugin/)", async () => {
251
+ for (const file of [pluginAssetPath, commandAssetPath]) {
252
+ const text = await readFile(file, "utf8");
253
+ for (const needle of FORBIDDEN_NEEDLES) {
254
+ assert.ok(!text.includes(needle), `${path.relative(repoRoot, file)} must not contain '${needle}'`);
255
+ }
256
+ }
257
+ });
258
+ test("CLI help: opencode is a registered top-level command", async (t) => {
259
+ const { env } = await makeEnv(t);
260
+ const help = abathur(env, "--help");
261
+ assert.equal(help.status, 0);
262
+ assert.ok(help.stdout.includes("opencode"), `usage must list opencode: ${help.stdout}`);
263
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tachikomagundam/abathur",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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",
@@ -25,7 +25,7 @@
25
25
  "config",
26
26
  "graders",
27
27
  "docs",
28
- ".github"
28
+ "plugin"
29
29
  ],
30
30
  "scripts": {
31
31
  "build": "tsc && node scripts/copy-assets.mjs",
@@ -0,0 +1,15 @@
1
+ <!-- abathur-opencode-command -->
2
+ Drive the abathur evolution harness through the `abathur` tool on this machine.
3
+
4
+ User request: $ARGUMENTS
5
+
6
+ Interpret the request as one `abathur` CLI invocation: the first word is the
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.
@@ -0,0 +1,162 @@
1
+ // abathur-opencode-plugin v0.2.1
2
+ // Official opencode plugin adapter for the abathur evolution harness.
3
+ // Registers ONE agent tool, `abathur`, that shells out to the abathur CLI —
4
+ // argv-only (node:child_process execFile, never a shell), top-level commands
5
+ // restricted to an allowlist, hard timeout, capped output. The CLI itself
6
+ // remains the human gate; this adapter only lets a session *ask* abathur.
7
+ //
8
+ // V1 plugin format (opencode >= 1.14): default export { id, server }, where
9
+ // server(input, options) resolves to Hooks; Hooks.tool is a
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.
15
+
16
+ import { execFile } from "node:child_process";
17
+ import { tool } from "@opencode-ai/plugin";
18
+
19
+ /** Spawn cap: a CLI call that outlives this is killed and reported, never awaited forever. */
20
+ const TIMEOUT_MS = 120_000;
21
+ /** Per-stream output cap fed back into the session; keeps one tool call from flooding context. */
22
+ const MAX_OUTPUT_BYTES = 64 * 1024;
23
+
24
+ /** The seven tool-reachable top-level commands plus --help. Anything else is refused locally.
25
+ * promote/tombstone are human gates and deliberately terminal-only (0.2.1): not reachable here. */
26
+ const ALLOWED_COMMANDS: readonly string[] = [
27
+ "genome",
28
+ "run",
29
+ "status",
30
+ "bundle",
31
+ "graft",
32
+ "self-eval",
33
+ "kernel",
34
+ "--help",
35
+ ];
36
+
37
+ function resolveBin(): string {
38
+ const configured = process.env["ABATHUR_BIN"];
39
+ return configured === undefined || configured.length === 0 ? "abathur" : configured;
40
+ }
41
+
42
+ interface CliResult {
43
+ readonly status: number;
44
+ readonly stdout: string;
45
+ readonly stderr: string;
46
+ readonly note: string;
47
+ }
48
+
49
+ /** The subset of the execFile error shape this adapter reads (node sets `code` to the numeric exit status on non-zero exits). */
50
+ interface SpawnError extends Error {
51
+ readonly code?: string | number | null;
52
+ readonly killed?: boolean;
53
+ readonly signal?: string | null;
54
+ }
55
+
56
+ /** Never rejects: every spawn failure becomes a structured result the agent can read. */
57
+ function runCli(bin: string, argv: readonly string[]): Promise<CliResult> {
58
+ return new Promise((resolve) => {
59
+ execFile(
60
+ bin,
61
+ [...argv],
62
+ { timeout: TIMEOUT_MS, maxBuffer: MAX_OUTPUT_BYTES, shell: false },
63
+ (error, stdout, stderr) => {
64
+ const out = stdout.slice(0, MAX_OUTPUT_BYTES);
65
+ const errOut = stderr.slice(0, MAX_OUTPUT_BYTES);
66
+ if (error === null) {
67
+ resolve({ status: 0, stdout: out, stderr: errOut, note: "" });
68
+ return;
69
+ }
70
+ const failure = error as SpawnError;
71
+ if (failure.code === "ENOENT") {
72
+ resolve({
73
+ status: 127,
74
+ stdout: out,
75
+ stderr: errOut,
76
+ note: `binary '${bin}' not found — install abathur globally or point ABATHUR_BIN at it`,
77
+ });
78
+ return;
79
+ }
80
+ if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
81
+ resolve({
82
+ status: -1,
83
+ stdout: out,
84
+ stderr: errOut,
85
+ note: `output truncated at ${String(MAX_OUTPUT_BYTES)} bytes (cap per stream)`,
86
+ });
87
+ return;
88
+ }
89
+ if (typeof failure.code !== "number") {
90
+ const timedOut = failure.killed === true || (failure.signal !== undefined && failure.signal !== null);
91
+ if (timedOut) {
92
+ let note = `killed after ${String(TIMEOUT_MS / 1000)}s timeout (signal: ${String(failure.signal ?? "SIGTERM")})`;
93
+ if (argv[0] === "run") {
94
+ // The timeout kills the CLI only; mutator/bench children are detached by design.
95
+ note +=
96
+ " — abathur run spawns detached children (mutator/bench sessions) " +
97
+ "that may still be running — the next 'abathur run' reaps them";
98
+ }
99
+ resolve({ status: 124, stdout: out, stderr: errOut, note });
100
+ return;
101
+ }
102
+ resolve({ status: -1, stdout: out, stderr: errOut, note: `spawn failed: ${failure.message}` });
103
+ return;
104
+ }
105
+ // Ordinary non-zero exit: the CLI's own verdict (blocked / cannot-answer).
106
+ resolve({ status: failure.code, stdout: out, stderr: errOut, note: "" });
107
+ },
108
+ );
109
+ });
110
+ }
111
+
112
+ export default {
113
+ id: "abathur",
114
+ server: async () => ({
115
+ tool: {
116
+ abathur: tool({
117
+ description:
118
+ "Run the abathur evolution-harness CLI on this machine. " +
119
+ "Pass the top-level command word in `command` (one of: genome, run, status, " +
120
+ "bundle, graft, self-eval, kernel, --help) and every " +
121
+ "remaining argv token in `extra`. The call is spawned argv-only (no shell) " +
122
+ "with a 120s timeout; the result text always ends with the CLI exit code " +
123
+ "(0 ok, 1 blocked decision, 2 cannot-answer). Honest privilege note: this " +
124
+ "tool carries bash-equivalent privilege — `run` and `genome` legitimately " +
125
+ "spawn mutator/engine binaries by design — so the allowlist limits typos " +
126
+ "and UX, not capability. `promote` and `tombstone` are deliberately NOT " +
127
+ "reachable here: they are human gates, run in a terminal.",
128
+ args: {
129
+ command: tool.schema
130
+ .string()
131
+ .describe("top-level abathur command, e.g. 'genome' or 'status' or '--help'"),
132
+ extra: tool.schema
133
+ .array(tool.schema.string())
134
+ .optional()
135
+ .describe("remaining argv tokens, e.g. ['add', '/path/to/genome.jsonc']"),
136
+ },
137
+ execute: async (args, context) => {
138
+ const command = args.command.trim();
139
+ if (!ALLOWED_COMMANDS.includes(command)) {
140
+ return (
141
+ `abathur: refused '${command}' — not an allowed top-level command. ` +
142
+ `Allowed: ${ALLOWED_COMMANDS.join(", ")}. Put the command word in 'command' ` +
143
+ `and every other token in 'extra'.`
144
+ );
145
+ }
146
+ const argv: readonly string[] = [command, ...(args.extra ?? [])];
147
+ context.metadata({ title: `abathur ${argv.join(" ")}` });
148
+ const bin = resolveBin();
149
+ const result = await runCli(bin, argv);
150
+ const sections = [
151
+ `$ abathur ${argv.join(" ")}`,
152
+ `exit: ${String(result.status)}`,
153
+ ];
154
+ if (result.note.length > 0) sections.push(`note: ${result.note}`);
155
+ sections.push(`--- stdout ---\n${result.stdout.length === 0 ? "(empty)" : result.stdout}`);
156
+ sections.push(`--- stderr ---\n${result.stderr.length === 0 ? "(empty)" : result.stderr}`);
157
+ return sections.join("\n");
158
+ },
159
+ }),
160
+ },
161
+ }),
162
+ };
@@ -1,29 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- permissions:
10
- contents: read
11
-
12
- jobs:
13
- build-test:
14
- name: Build & test (Node 22)
15
- runs-on: ubuntu-latest
16
- steps:
17
- - uses: actions/checkout@v4
18
-
19
- - uses: actions/setup-node@v4
20
- with:
21
- node-version: 22
22
- cache: npm
23
- # NOTE: deliberately NO registry-url here. Setting registry-url makes
24
- # setup-node inject NODE_AUTH_TOKEN, which switches npm to authenticated
25
- # legacy auth and defeats Trusted Publishing (OIDC) downstream.
26
-
27
- - run: npm ci
28
- - run: npm run build
29
- - run: npm test
@@ -1,74 +0,0 @@
1
- # npm Trusted Publishing (OIDC) — zero long-lived credentials on GitHub.
2
- #
3
- # One-time setup required in the npmjs.com UI (Publishing access →
4
- # "Trusted publisher" → Add publisher → GitHub Actions):
5
- # Owner / user: TachikomaGundam
6
- # Package name: @tachikomagundam/abathur
7
- # Repository: Abathur
8
- # Workflow name: publish.yml
9
- # Environment: (leave empty unless you gate it)
10
- # Release type: progressive
11
- # The event this workflow publishes on is `push` (of a v* tag);
12
- # the action is `npm publish --provenance --access public`.
13
- #
14
- # If the trusted publisher is not registered, `npm publish` fails with
15
- # a 401/EPUBLISHCONFLICT — that is expected until the UI step is done.
16
- name: Publish
17
-
18
- on:
19
- push:
20
- tags:
21
- - 'v*'
22
- workflow_dispatch:
23
-
24
- permissions:
25
- contents: read
26
- id-token: write # required for npm Trusted Publishing (OIDC provenance)
27
-
28
- jobs:
29
- publish:
30
- name: Publish to npm
31
- runs-on: ubuntu-latest
32
- steps:
33
- - uses: actions/checkout@v4
34
-
35
- - uses: actions/setup-node@v4
36
- with:
37
- # Node 24 ships npm >= 11.5.1, the minimum version that supports
38
- # Trusted Publishing. Older npm silently falls back to token auth.
39
- # NOTE: deliberately NO registry-url here — setting it injects
40
- # NODE_AUTH_TOKEN and defeats OIDC (documented pitfall).
41
- node-version: 24
42
- cache: npm
43
-
44
- - name: Enforce tag == package.json version
45
- env:
46
- GH_REF: ${{ github.ref }}
47
- GH_EVENT: ${{ github.event_name }}
48
- run: |
49
- set -euo pipefail
50
- if [ "${GH_EVENT}" = "workflow_dispatch" ]; then
51
- # A dispatch lands on the default-branch tip, which carries no tag,
52
- # so the describe-based check below would ALWAYS fail there. Skip it:
53
- # the npm-side trusted-publisher event config remains the real gate.
54
- echo "::warning::manual dispatch publish — npm Trusted Publishing event config will reject this unless a workflow_dispatch publisher is registered"
55
- elif [ "${GH_REF#refs/tags/}" != "${GH_REF}" ]; then
56
- # push of a v* tag: HEAD must sit exactly on that tag and the tag
57
- # (minus the leading v) must equal package.json version.
58
- TAG="$(git describe --tags --exact-match HEAD)"
59
- VERSION="$(node -p "require('./package.json').version")"
60
- if [ "${TAG#v}" != "${VERSION}" ]; then
61
- echo "::error::git tag '${TAG}' does not match package.json version '${VERSION}'"
62
- exit 1
63
- fi
64
- echo "Publishing ${VERSION} (tag ${TAG})"
65
- else
66
- echo "::error::unexpected event '${GH_EVENT}' on ref '${GH_REF}' — refusing to publish"
67
- exit 1
68
- fi
69
-
70
- - run: npm ci
71
-
72
- - run: npm run build
73
-
74
- - run: npm publish --provenance --access public