@hybridlabor-api/aos 4.14.1 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ // aos-bus spike: master -> running OpenCode session via per-session inbox files.
3
+ // Library + CLI. The receiver (OpenCode plugin) injects each message as a synthetic + ignored part pair.
4
+
5
+ import {
6
+ chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, utimesSync, writeFileSync,
7
+ } from "node:fs";
8
+ import { homedir, userInfo } from "node:os";
9
+ import { join } from "node:path";
10
+ import { randomBytes } from "node:crypto";
11
+ import { fileURLToPath } from "node:url";
12
+ import { slug } from "./go-gate.mjs";
13
+
14
+ const MAX_SEND = 8 * 1024;
15
+ const MAX_FILE = 16 * 1024;
16
+
17
+ export function busPaths(name) {
18
+ const n = slug(String(name ?? ""));
19
+ if (!n || n.startsWith(".")) throw new Error("invalid session name");
20
+ const root = join(homedir(), ".aos", "bus");
21
+ return { name: n, root, sessions: join(root, "sessions"), reg: join(root, "sessions", `${n}.json`), inbox: join(root, "inbox", n) };
22
+ }
23
+
24
+ export function ensurePrivateDir(dir) {
25
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ chmodSync(dir, 0o700);
27
+ }
28
+
29
+ // Returns null when usable, else the refusal reason.
30
+ export function checkPrivateDir(dir) {
31
+ let st;
32
+ try { st = lstatSync(dir); } catch { return `${dir} does not exist`; }
33
+ if (!st.isDirectory()) return `${dir} is not a real directory`;
34
+ if (st.uid !== process.getuid()) return `${dir} is not owned by the current user`;
35
+ if (st.mode & 0o077) return `${dir} is accessible to others (mode ${(st.mode & 0o777).toString(8)})`;
36
+ return null;
37
+ }
38
+
39
+ export function registerSession({ name, sessionID, cwd }) {
40
+ const p = busPaths(name);
41
+ ensurePrivateDir(join(p.root, "inbox"));
42
+ ensurePrivateDir(p.sessions);
43
+ ensurePrivateDir(p.inbox);
44
+ writeFileSync(p.reg, JSON.stringify({ name: p.name, harness: "opencode", pid: process.pid, cwd, sessionID, inbox: p.inbox, ts: Date.now() }), { mode: 0o600 });
45
+ return p.name;
46
+ }
47
+
48
+ export function unregisterSession(name, pid = process.pid) {
49
+ try {
50
+ const p = busPaths(name);
51
+ if (JSON.parse(readFileSync(p.reg, "utf8")).pid === pid) unlinkSync(p.reg);
52
+ } catch { /* already gone */ }
53
+ }
54
+
55
+ // A registration is live only while its owner keeps touching it: a pid check alone passes
56
+ // for a reused pid, and a killed or exited TUI leaves the file behind (no exit hook fires).
57
+ export const STALE_MS = Number(process.env.AOS_BUS_STALE_MS) || 15_000;
58
+
59
+ export function touchSession(name, pid = process.pid) {
60
+ try {
61
+ const p = busPaths(name);
62
+ if (JSON.parse(readFileSync(p.reg, "utf8")).pid !== pid) return false;
63
+ const now = new Date();
64
+ utimesSync(p.reg, now, now);
65
+ return true;
66
+ } catch { return false; }
67
+ }
68
+
69
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
70
+
71
+ export function listSessions() {
72
+ const dir = join(homedir(), ".aos", "bus", "sessions");
73
+ const out = [];
74
+ let files = [];
75
+ try { files = readdirSync(dir).filter((f) => f.endsWith(".json")); } catch { /* none */ }
76
+ for (const f of files) {
77
+ try {
78
+ const file = join(dir, f);
79
+ const r = JSON.parse(readFileSync(file, "utf8"));
80
+ if (Number.isInteger(r.pid) && alive(r.pid) && Date.now() - lstatSync(file).mtimeMs < STALE_MS) out.push(r);
81
+ } catch { /* corrupt entry */ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ // Throws with the refusal reason; returns the message file path.
87
+ export function sendMessage(name, text, { from, wake = false } = {}) {
88
+ const p = busPaths(name);
89
+ if (!listSessions().some((s) => s.name === p.name)) throw new Error(`session "${p.name}" is not registered or not alive`);
90
+ const bad = checkPrivateDir(p.inbox);
91
+ if (bad) throw new Error(bad);
92
+ text = String(text ?? "");
93
+ if (Buffer.byteLength(text) > MAX_SEND) throw new Error(`message exceeds ${MAX_SEND} bytes`);
94
+ from ||= process.env.AOS_SESSION_NAME || `${userInfo().username}:${process.ppid}`;
95
+ const base = `${Date.now()}-${randomBytes(4).toString("hex")}`;
96
+ const tmp = join(p.inbox, `${base}.tmp`);
97
+ const file = join(p.inbox, `${base}.json`);
98
+ writeFileSync(tmp, JSON.stringify({ v: 1, from, text, ts: Date.now(), ...(wake ? { wake: true } : {}) }), { mode: 0o600 });
99
+ renameSync(tmp, file);
100
+ return file;
101
+ }
102
+
103
+ export function readInbox(name) {
104
+ const p = busPaths(name);
105
+ if (checkPrivateDir(p.inbox)) return [];
106
+ const out = [];
107
+ for (const f of readdirSync(p.inbox).filter((x) => x.endsWith(".json")).sort()) {
108
+ const file = join(p.inbox, f);
109
+ try {
110
+ const st = lstatSync(file);
111
+ if (!st.isFile() || st.uid !== process.getuid() || st.size > MAX_FILE) throw new Error("rejected");
112
+ const m = JSON.parse(readFileSync(file, "utf8"));
113
+ if (typeof m?.text !== "string") throw new Error("rejected");
114
+ const from = String(m.from ?? "").replace(/[\x00-\x1f\x7f\]]/g, "").slice(0, 64) || "unknown";
115
+ out.push({ file, from, text: m.text, wake: m.wake === true, uid: st.uid, ts: Number.isFinite(m.ts) ? m.ts : undefined, mtimeMs: st.mtimeMs });
116
+ } catch {
117
+ try { renameSync(file, `${file}.rejected`); } catch { /* gone */ }
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ const isMain = () => { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } };
124
+
125
+ if (isMain()) {
126
+ const [cmd, ...rest] = process.argv.slice(2);
127
+ const opts = {}; const words = [];
128
+ for (let i = 0; i < rest.length; i++) {
129
+ if (rest[i] === "--from") opts.from = rest[++i];
130
+ else if (rest[i] === "--wake") opts.wake = true;
131
+ else words.push(rest[i]);
132
+ }
133
+ try {
134
+ if (cmd === "send" && words.length >= 2) {
135
+ console.log(sendMessage(words[0], words.slice(1).join(" "), opts));
136
+ } else if (cmd === "list") {
137
+ for (const s of listSessions()) console.log(`${s.name}\tpid ${s.pid}\t${s.cwd}`);
138
+ } else {
139
+ console.error("usage: aos-bus send <name> <text...> [--from x] [--wake] | aos-bus list");
140
+ process.exit(2);
141
+ }
142
+ } catch (e) {
143
+ console.error(`aos-bus: ${e.message}`);
144
+ process.exit(2);
145
+ }
146
+ }
@@ -150,7 +150,8 @@ function opencodeLastUser(sessionId) {
150
150
  if (!msg) return null;
151
151
  const text = db.prepare("SELECT data FROM part WHERE message_id = ? ORDER BY id").all(msg.id)
152
152
  .map((r) => JSON.parse(r.data))
153
- .filter((p) => p?.type === "text" && !p.synthetic && typeof p.text === "string")
153
+ // synthetic = model-only, ignored = display-only, aos_bus = bus delivery: none is a human turn.
154
+ .filter((p) => p?.type === "text" && !p.synthetic && !p.ignored && !p.metadata?.aos_bus && typeof p.text === "string")
154
155
  .map((p) => p.text)
155
156
  .join("\n")
156
157
  .trim();
@@ -15,7 +15,7 @@
15
15
  * on unguarded destructive actions.
16
16
  */
17
17
 
18
- import { existsSync, readFileSync } from 'node:fs';
18
+ import { existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
 
21
21
  // Decision logic lives in the shared hooks: installed copy next to this file
@@ -23,8 +23,8 @@ import path from 'node:path';
23
23
  // so a broken installed hook fails loudly instead of silently falling back.
24
24
  const HOOKS = existsSync(new URL('./aos-hooks/go-gate.mjs', import.meta.url)) ? './aos-hooks/' : '../../.claude/hooks/';
25
25
  const hook = (f) => import(new URL(HOOKS + f, import.meta.url).href);
26
- const [{ GUARDED_PATTERNS, tokenGrantsGo }, { issueGoToken }, { checkConventionalCommit }, { envFileReason }, { buildMemoryBlock }] =
27
- await Promise.all(['go-gate.mjs', 'go-token.mjs', 'conventional-commits.mjs', 'env-file-protection.mjs', 'memb-inject.mjs'].map(hook));
26
+ const [{ GUARDED_PATTERNS, tokenGrantsGo }, { issueGoToken }, { checkConventionalCommit }, { envFileReason }, { buildMemoryBlock }, bus] =
27
+ await Promise.all(['go-gate.mjs', 'go-token.mjs', 'conventional-commits.mjs', 'env-file-protection.mjs', 'memb-inject.mjs', 'aos-bus.mjs'].map(hook));
28
28
 
29
29
  // ---------------------------------------------------------------------------
30
30
  // Graph gate (W-5, W-6)
@@ -130,6 +130,15 @@ async function ownSessionName(client, sessionID) {
130
130
  } catch { return ''; }
131
131
  }
132
132
 
133
+ // Only plain text parts are human turns: synthetic = model-only, ignored = display-only,
134
+ // aos_bus = bus delivery (both copies). Each excluded on its own, so safety never hangs on one flag.
135
+ const isHumanText = (p) => p && p.type === 'text' && typeof p.text === 'string' && !p.synthetic && !p.ignored && !p.metadata?.aos_bus;
136
+ const isNonHuman = (p) => p && (p.synthetic || p.ignored || p.metadata?.aos_bus);
137
+
138
+ // A plugin reload (SIGUSR2) re-runs init on the cached module in the same process:
139
+ // stop the previous instance's timers and keep its bus names alive. Keyed by directory.
140
+ const busInstances = new Map();
141
+
133
142
  // Best-effort fire-and-forget telemetry for the AOS live map (agenttrail daemon).
134
143
  // 300 ms timeout per request, all errors swallowed, never throws. Self-contained
135
144
  // on purpose: the plugin must not import from mcps/.
@@ -199,6 +208,94 @@ export default async function bdbAosPlugin(input) {
199
208
  if (!sessions.has(id)) sessions.set(id, { prompt: '', parentID: null, identitySent: false });
200
209
  return sessions.get(id);
201
210
  };
211
+ // aos-bus: bus name -> sessionID of the root session that owns it.
212
+ const prevBus = busInstances.get(directory);
213
+ for (const t of prevBus?.timers || []) clearInterval(t);
214
+ const busNames = prevBus?.names || new Map();
215
+ const busTimers = [];
216
+ busInstances.set(directory, { timers: busTimers, names: busNames });
217
+ const every = (ms, fn) => { const t = setInterval(fn, ms); t.unref?.(); busTimers.push(t); };
218
+ const BUS_PROMPT_TIMEOUT_MS = Number(process.env.AOS_BUS_PROMPT_TIMEOUT_MS) || 10 * 60 * 1000;
219
+ const BUS_BUSY_MAX_MS = Number(process.env.AOS_BUS_BUSY_MAX_MS) || 10 * 60 * 1000;
220
+ const ensureBus = async (id, parentID) => {
221
+ const s = sess(id);
222
+ if (s.bus || !id) return;
223
+ s.bus = true;
224
+ try {
225
+ if (parentID === undefined) {
226
+ const res = await input.client?.session?.get?.({ path: { id } });
227
+ if (!res?.data) { s.bus = false; return; }
228
+ parentID = res.data.parentID ?? null;
229
+ }
230
+ if (parentID) return;
231
+ const name = bus.registerSession({ name: process.env.AOS_SESSION_NAME || id, sessionID: id, cwd: directory });
232
+ busNames.set(name, id);
233
+ } catch (e) {
234
+ console.error(`[aos-bus] registration failed: ${e?.message}`);
235
+ s.bus = /invalid session name/.test(e?.message);
236
+ }
237
+ };
238
+ const dropBus = (id) => {
239
+ for (const [name, sid] of busNames) if (sid === id) { bus.unregisterSession(name); busNames.delete(name); }
240
+ };
241
+ process.on('exit', () => { for (const name of busNames.keys()) bus.unregisterSession(name); });
242
+
243
+ // Heartbeat on its own timer (the poll loop can sit in one prompt for minutes); readers drop a
244
+ // registration whose mtime is older than bus.STALE_MS. touchSession only touches this pid's files.
245
+ every(5000, () => { for (const name of busNames.keys()) bus.touchSession(name); });
246
+
247
+ const busyFirst = new Map(); // inbox file -> time of its first busy attempt
248
+ let polling = false;
249
+ every(1000, async () => {
250
+ if (polling || !busNames.size) return;
251
+ polling = true;
252
+ try {
253
+ for (const [name, sessionID] of busNames) {
254
+ // Only the registered owner (this pid + this session) may consume the inbox.
255
+ let reg; try { reg = JSON.parse(readFileSync(bus.busPaths(name).reg, 'utf8')); } catch {}
256
+ if (reg?.pid !== process.pid || reg?.sessionID !== sessionID) continue;
257
+ for (const m of bus.readInbox(name)) {
258
+ // Claim atomically: with two live pollers (ownership handover) only one rename wins.
259
+ const claim = `${m.file.slice(0, -'.json'.length)}.inflight`;
260
+ try { renameSync(m.file, claim); } catch { continue; }
261
+ // The gate checks this cache before any DB lookup; a stale human GO must not authorize a woken turn.
262
+ sess(sessionID).prompt = '';
263
+ try {
264
+ let timer;
265
+ const text = `[aos-bus from ${m.from}] ${m.text}`;
266
+ const metadata = { aos_bus: { from: m.from, uid: m.uid, ts: m.ts } };
267
+ const res = await Promise.race([input.client.session.prompt({
268
+ path: { id: sessionID },
269
+ query: directory ? { directory } : undefined,
270
+ body: {
271
+ noReply: !m.wake,
272
+ // OpenCode 1.18.30: the TUI hides synthetic parts and the model never sees ignored
273
+ // ones, so one copy each. Neither counts as a human turn (isHumanText).
274
+ parts: [
275
+ { type: 'text', text, synthetic: true, metadata },
276
+ { type: 'text', text, ignored: true, metadata },
277
+ ],
278
+ },
279
+ }), new Promise((_, rej) => { timer = setTimeout(() => rej(Object.assign(new Error('aos-bus prompt timeout'), { timedOut: true })), BUS_PROMPT_TIMEOUT_MS); timer.unref?.(); })]).finally(() => clearTimeout(timer));
280
+ if (res?.error) throw res.error;
281
+ unlinkSync(claim);
282
+ busyFirst.delete(m.file);
283
+ input.client.tui?.showToast?.({ body: { title: 'aos-bus', message: `from ${m.from}`, variant: 'info', duration: 15000 } })?.catch?.(() => {});
284
+ } catch (e) {
285
+ // Busy: hand the file back and retry next tick, up to BUS_BUSY_MAX_MS after the first busy attempt.
286
+ const isBusy = /busy/i.test(`${e?.name} ${e?.message} ${e?.data?.message}`);
287
+ const first = busyFirst.get(m.file) ?? Date.now();
288
+ if (isBusy) busyFirst.set(m.file, first);
289
+ if (isBusy && Date.now() - first < BUS_BUSY_MAX_MS) { try { renameSync(claim, m.file); } catch {} break; }
290
+ busyFirst.delete(m.file);
291
+ // A timed-out prompt may still complete: delivery state unknown, so not .failed.
292
+ try { renameSync(claim, `${m.file}${e?.timedOut ? '.timeout' : '.failed'}`); } catch {}
293
+ }
294
+ }
295
+ }
296
+ } catch {} finally { polling = false; }
297
+ // ponytail: 1 s poll; fs.watch if latency matters. A wake turn is awaited, so it delays later messages until it ends.
298
+ });
202
299
  const trail = (hook_event_name, session_id, extra = {}) =>
203
300
  void postTrail({ hook_event_name, session_id, cwd: directory, agent: 'opencode', ...extra });
204
301
 
@@ -212,10 +309,14 @@ export default async function bdbAosPlugin(input) {
212
309
  if (event?.type === 'session.created' && props.info?.id) {
213
310
  sess(props.info.id).parentID = props.info.parentID ?? null;
214
311
  trail('SessionStart', props.info.id);
312
+ void ensureBus(props.info.id, props.info.parentID ?? null);
313
+ } else if (event?.type === 'session.deleted' && props.info?.id) {
314
+ dropBus(props.info.id);
215
315
  } else if (event?.type === 'permission.updated') {
216
316
  trail('Notification', props.sessionID, { message: props.title });
217
317
  } else if (event?.type === 'session.idle' && props.sessionID) {
218
318
  trail(sess(props.sessionID).parentID ? 'SubagentStop' : 'Stop', props.sessionID);
319
+ void ensureBus(props.sessionID);
219
320
  }
220
321
  } catch {}
221
322
 
@@ -246,10 +347,15 @@ export default async function bdbAosPlugin(input) {
246
347
  },
247
348
 
248
349
  'chat.message': async (msgInput, msgOutput) => {
249
- const textParts = (msgOutput.parts || []).filter((p) => p && p.type === 'text' && typeof p.text === 'string' && !p.synthetic);
350
+ const textParts = (msgOutput.parts || []).filter(isHumanText);
250
351
  const fullText = textParts.map((p) => p.text).join('\n').trim();
251
352
  const s = sess(msgInput.sessionID);
353
+ void ensureBus(msgInput.sessionID);
252
354
  s.prompt = fullText;
355
+ // No human text (aos-bus pair, nudges): never a human GO, skip memB/pipeline/token.
356
+ const parts = msgOutput.parts || [];
357
+ const texts = parts.filter((p) => p && p.type === 'text');
358
+ if ((parts.length > 0 && parts.every((p) => p && p.synthetic)) || (texts.length > 0 && texts.every(isNonHuman))) { s.prompt = ''; return; }
253
359
 
254
360
  try {
255
361
  if (!s.parentID) issueGoToken(fullText, { session_id: msgInput.sessionID, message_id: msgInput.messageID || msgOutput.message?.id });
@@ -333,7 +439,7 @@ export default async function bdbAosPlugin(input) {
333
439
  const m = msgs[i];
334
440
  if ((m.info?.role ?? m.role) === 'user') {
335
441
  const parts = m.parts || [];
336
- const text = parts.filter((p) => p.type === 'text' && !p.synthetic).map((p) => p.text).join('\n').trim();
442
+ const text = parts.filter(isHumanText).map((p) => p.text).join('\n').trim();
337
443
  authorized = text.toUpperCase() === 'GO';
338
444
  break;
339
445
  }
@@ -49,3 +49,18 @@ Double gate: opencode and claude workers also run their own AOS gate inside the
49
49
  - OpenCode token path end to end needs the installed plugin updated; verified by unit test only.
50
50
  - Neither live adapter sent `session/request_permission` for a shell command in the smoke runs (the inner gate answered first); the mapping is covered by the fake-agent tests.
51
51
  - One `aos-acp` process per worker; no resume (`session/load`) and no concurrency cap yet.
52
+
53
+ ## Bus (spike): messaging a hand-started OpenCode session
54
+
55
+ A master can drop a message into a running OpenCode session (no `--port`, no restart). OpenCode only.
56
+
57
+ - **Paths:** registry `~/.aos/bus/sessions/<name>.json` (0600), inbox `~/.aos/bus/inbox/<name>/` (0700), one `<ms>-<rand>.json` per message. `<name>` is `slug(AOS_SESSION_NAME)`, else the OpenCode session id. The inbox path is always derived from the name, never from the registry file.
58
+ - **CLI:** `aos-bus send <name> <text...> [--from x] [--wake]`, `aos-bus list`. Exit 2 with a reason on refusal (unregistered or dead pid, inbox not a private real dir owned by you, text over 8 KiB).
59
+ - **Injection:** the plugin polls the inbox every 1 s and calls `client.session.prompt` with `noReply: true` (`false` with `--wake`) and one text part `[aos-bus from <from>] <text>`, `synthetic: true`, `metadata.aos_bus`. The API has no non-user message, so the stored row is `role: user`; exclusion from GO rests on the part flag.
60
+ - **GO exclusion (3 layers):** `synthetic: true` (existing `!synthetic` filters in `chat.message`, the `session.messages` fallback and `opencodeLastUser`); the `[aos-bus from ...]` prefix, so the text can never be `GO` or `GO <name>`; the plugin clears its cached prompt before each injection and returns early from `chat.message` for all-synthetic messages.
61
+ - **Fail-closed side effect:** a bus message becomes the newest user row, so the last human `GO` no longer opens that session's gate, and a pending OpenCode-issued `GO <worker>` token for it is invalidated. The human re-types GO.
62
+ - **Delivery rules:** only the registered owner (pid + session) polls. Each file is claimed by renaming `<x>.json` to `<x>.inflight` before the prompt, so a message is delivered by at most one process; a failed claim means someone else has it. Success deletes the claim. A busy session gets the file renamed back to `.json` and retried every tick for up to 10 min counted from the first busy attempt (`AOS_BUS_BUSY_MAX_MS`), then `.failed`. A prompt not finished within 10 min (`AOS_BUS_PROMPT_TIMEOUT_MS`) is parked as `.timeout`: the prompt is not aborted, so delivery state is unknown and it must not be resent blindly. Any other error parks the file as `.failed` (not delivered).
63
+ - **Smoke:** `tests/aos-bus-smoke.sh` (opt-in, isolated HOME/XDG, `opencode serve`).
64
+ - **[verified]** `synthetic: true`, prefix and `metadata.aos_bus` persist in `opencode.db`; no token file; `tokenGrantsGo` rejects a token pointing at the bus message.
65
+ - **[open]** TUI mode (toast, plugin client without `--port`), `--wake` (needs model credentials), real busy-session behaviour.
66
+ - **Out of scope:** Claude Code receivers (native `SendMessage`), Codex/agy, AO daemon, slash commands, agenttrail roster, replies/acks/expiry, `fs.watch`, authenticating `from` beyond file-owner uid.
@@ -105,6 +105,8 @@ Additionally the parser at `installer.js:1857` uses `block.match(/([A-Za-z0-9_]+
105
105
  The same ruleset exists in `CLAUDE.md`, `GEMINI.md`, `agent.md`, `.codex-plugin/system.md`, `.cursor/rules/000_global_rules.mdc`, and (partially) `.roomodes`. `GEMINI.md` is missing two clauses that `CLAUDE.md` has ("Plans are not approval", "No inheritance, no silent retries"). `CODEX.md` contains no rules at all. There is no single source of truth; the installer regenerates *some* targets from `agent.md` but the root `CLAUDE.md`/`GEMINI.md` are separately maintained.
106
106
 
107
107
  ### F-07 — No `disable-model-invocation`, no argument contract *(P1)*
108
+
109
+ > **Superseded (2026-10).** The flag was later set on pipeline and role skills and playbooks, which stopped the Master and every orchestrator from starting `/startcycle` through the Skill tool. It was removed again; go-gate guards the side effects and `validate-skills` (E-DMI01, E-PB06) keeps pipeline skills model-invocable. Kept only on `bdb-updater` and `triage`.
108
110
  B1: *"Use `disable-model-invocation: true` for workflows with side effects that you want to trigger manually."*
109
111
 
110
112
  `/startcycle`, `/ship`, `/build` and `bdbsaastraining` all have side effects (git commit, git push, npm publish, cloud calls). **0 of 134** skills set `disable-model-invocation`. Only 2 skills use `$ARGUMENTS`. Frontmatter census:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hybridlabor-api/aos",
3
- "version": "4.14.1",
3
+ "version": "4.15.0",
4
4
  "description": "AOS — A Curated AI AGENT OS. Optimized agent skills and add-ons like memB, OpenWiki, Heimdall Token Saver, and Godmode architectures.",
5
5
  "main": "installer.js",
6
6
  "engines": {
@@ -12,6 +12,7 @@
12
12
  "bdb-agent-skills-pro": "installer.js",
13
13
  "bdb-agent-skills": "installer.js",
14
14
  "aos-plan-canvas": "skills/global_config/plan-canvas/scripts/plan-canvas.js",
15
+ "aos-bus": ".claude/hooks/aos-bus.mjs",
15
16
  "aos-trail": "skills/global_config/agenttrail/bin/agenttrail.mjs",
16
17
  "aos-archify": "skills/global_config/archify/bin/archify.mjs",
17
18
  "aos-dashboard": "bin/aos-dashboard.mjs",
@@ -28,7 +29,7 @@
28
29
  "plugin:build": "node scripts/build-plugin-manifest.mjs",
29
30
  "plugin:check": "node scripts/build-plugin-manifest.mjs --check",
30
31
  "doctor": "node bin/aos-doctor.mjs",
31
- "test": "node scripts/validate-skills.mjs --selftest && node scripts/validate-skills.mjs && node scripts/build-plugin-manifest.mjs --check && node --test tests/opencode-graph-gate.test.js && node --test tests/cross-harness-hooks.test.js && node --test tests/plan-canvas-security.test.mjs && node --test tests/plan-canvas-modes.test.mjs && node --test tests/swarm-wiring.test.js && node tests/aos-store.test.mjs && node --test tests/aos-store-ui.test.mjs && node tests/aos-store-scenario.test.mjs && node tests/aos-doctor.test.mjs && node tests/build-plugin-manifest.test.mjs && node --test tests/agent-models.test.js && node --test tests/installer-p3.test.js && node --test tests/installer-binary.test.js && node --test tests/installer-version.test.js && node --test tests/mcsc-server.test.js && node --test tests/antigravity-hooks.test.js && node --test tests/windows-hooks.test.js && node --test tests/plan-builder.test.mjs && node --test tests/plan-builder-trail.test.mjs && node --test tests/plan-builder-showcase.test.mjs && node --test tests/plan-builder-templates.test.mjs && node --test tests/plan-canvas-home.test.mjs && node --test tests/launchpad-installer.test.js && node --test tests/launchpad-plan-canvas.test.js && node --test tests/go-token.test.js && node --test tests/master-session-skill.test.js && node --test tests/aos-acp.test.js && node --test tests/opencode-go-token.test.js && node --test tests/opencode-adapter.test.js && node --test tests/pr-recap.test.mjs && node --test tests/bdb-visual-edit.test.mjs && node --test tests/opencode-trail-autostart.test.mjs && node --test tests/trail-autostart-hook.test.mjs && node --test tests/agenttrail-ensure.test.mjs"
32
+ "test": "node scripts/validate-skills.mjs --selftest && node scripts/validate-skills.mjs && node scripts/build-plugin-manifest.mjs --check && node --test tests/opencode-graph-gate.test.js && node --test tests/cross-harness-hooks.test.js && node --test tests/plan-canvas-security.test.mjs && node --test tests/plan-canvas-modes.test.mjs && node --test tests/swarm-wiring.test.js && node tests/aos-store.test.mjs && node --test tests/aos-store-ui.test.mjs && node tests/aos-store-scenario.test.mjs && node tests/aos-doctor.test.mjs && node tests/build-plugin-manifest.test.mjs && node --test tests/agent-models.test.js && node --test tests/installer-p3.test.js && node --test tests/installer-binary.test.js && node --test tests/installer-version.test.js && node --test tests/mcsc-server.test.js && node --test tests/antigravity-hooks.test.js && node --test tests/windows-hooks.test.js && node --test tests/plan-builder.test.mjs && node --test tests/plan-builder-trail.test.mjs && node --test tests/plan-builder-showcase.test.mjs && node --test tests/plan-builder-templates.test.mjs && node --test tests/plan-canvas-home.test.mjs && node --test tests/launchpad-installer.test.js && node --test tests/launchpad-plan-canvas.test.js && node --test tests/go-token.test.js && node --test tests/master-session-skill.test.js && node --test tests/aos-acp.test.js && node --test tests/opencode-go-token.test.js && node --test tests/opencode-adapter.test.js && node --test tests/pr-recap.test.mjs && node --test tests/bdb-visual-edit.test.mjs && node --test tests/opencode-trail-autostart.test.mjs && node --test tests/trail-autostart-hook.test.mjs && node --test tests/agenttrail-ensure.test.mjs && node --test tests/aos-bus.test.js"
32
33
  },
33
34
  "publishConfig": {
34
35
  "access": "public"
@@ -242,7 +242,7 @@ function checkSkillRefs(text, file, known, self, findings) {
242
242
  }
243
243
 
244
244
  // Playbooks are skills with `kind: playbook`; skills without `kind` are untouched.
245
- const PB_REQUIRED = ['trigger', 'inputs', 'requires', 'go_points', 'outputs', 'verify', 'difficulty', 'est_time', 'disable-model-invocation'];
245
+ const PB_REQUIRED = ['trigger', 'inputs', 'requires', 'go_points', 'outputs', 'verify', 'difficulty', 'est_time'];
246
246
  const DIFFICULTY = new Set(['beginner', 'intermediate', 'advanced']);
247
247
  const EXTERNAL = ' (external)';
248
248
 
@@ -274,12 +274,28 @@ export function checkPlaybook(byKey, known) {
274
274
  add('E-PB04', `\`difficulty: ${unquote(diff.value)}\` is not one of: ${[...DIFFICULTY].join(', ')}`, 'difficulty');
275
275
  }
276
276
  const dmi = byKey.get('disable-model-invocation');
277
- if (dmi && unquote(dmi.value) !== 'true') {
278
- add('E-PB06', 'playbooks are user-invoked only — set `disable-model-invocation: true`', 'disable-model-invocation');
277
+ if (dmi && unquote(dmi.value) === 'true') {
278
+ add('E-PB06', 'playbooks must stay invocable by agents — remove `disable-model-invocation: true` (side effects are guarded by go-gate)', 'disable-model-invocation');
279
279
  }
280
280
  return out;
281
281
  }
282
282
 
283
+ // Pipeline and role skills must stay invocable through the Skill tool: the Master and
284
+ // every orchestrator start them. Side effects are guarded by go-gate, not by hiding the
285
+ // skill from the model. Anything not listed here may still set the flag.
286
+ export const MODEL_INVOCABLE = new Set([
287
+ 'startcycle', 'startcycle-graph', 'startcycle-graph-user', 'teamwork-preview',
288
+ 'godmode-engineering', 'godmode-shipping', 'godmode-ui-ux', 'godmode-media-eventtech',
289
+ 'master-session', 'ao-orchestrator', 'bdbrainstorm', 'bdbmediastorm',
290
+ 'grill-me', 'grill-with-docs', 'ask-tim', 'memb-skill',
291
+ ]);
292
+
293
+ export function checkInvocation(name, byKey) {
294
+ const dmi = byKey.get('disable-model-invocation');
295
+ if (!dmi || unquote(dmi.value) !== 'true' || !MODEL_INVOCABLE.has(name)) return [];
296
+ return [{ level: 'error', code: 'E-DMI01', line: dmi.line, msg: `\`${name}\` is a pipeline/role skill and must stay model-invocable — remove \`disable-model-invocation: true\` (go-gate guards its side effects)` }];
297
+ }
298
+
283
299
  function validate() {
284
300
  const { skills, strays } = collect();
285
301
  const known = new Set(skills.map((f) => basename(dirname(f))));
@@ -351,6 +367,7 @@ function validate() {
351
367
  }
352
368
 
353
369
  for (const f of checkPlaybook(byKey, known)) findings.push({ ...f, file: rel });
370
+ for (const f of checkInvocation(basename(dir), byKey)) findings.push({ ...f, file: rel });
354
371
 
355
372
  checkLeakedPaths(text, rel, findings);
356
373
  checkLocalRefs(text, dir, rel, findings);
@@ -509,7 +526,7 @@ function selftest() {
509
526
  trigger: '["a"]', inputs: '[repo]',
510
527
  requires: '\n skills: [github, gh (external)]\n agents: []\n mcps: []\n store: []',
511
528
  go_points: '[]', outputs: '["x"]', verify: '"x"', difficulty: 'beginner',
512
- est_time: '5 min', 'disable-model-invocation': 'true', ...over,
529
+ est_time: '5 min', ...over,
513
530
  };
514
531
  const lines = Object.entries(base).filter(([k]) => !drop.includes(k)).map(([k, v]) => `${k}: ${v}`);
515
532
  const fm = scanFrontmatter(['---', ...lines, '---'].join('\n'));
@@ -530,8 +547,14 @@ function selftest() {
530
547
  assert.equal(kind.length, 1);
531
548
  assert.equal(kind[0].code, 'E-PB01');
532
549
  assert.equal(checkPlaybook(pb({ requires: '\n skills:\n - github' }), known)[0].code, 'E-PB05');
533
- assert.equal(checkPlaybook(pb({ 'disable-model-invocation': 'false' }), known)[0].code, 'E-PB06');
534
- assert.deepEqual(checkPlaybook(pb({}, ['kind', 'trigger', 'inputs', 'requires', 'go_points', 'outputs', 'verify', 'difficulty', 'est_time', 'disable-model-invocation']), known).length, 0, 'no kind, no playbook rules');
550
+ assert.equal(checkPlaybook(pb({ 'disable-model-invocation': 'true' }), known)[0].code, 'E-PB06');
551
+ assert.deepEqual(checkPlaybook(pb({ 'disable-model-invocation': 'false' }), known), [], 'explicit false is fine');
552
+ const dm = (v) => new Map([['disable-model-invocation', { key: 'disable-model-invocation', value: v, line: 3 }]]);
553
+ assert.equal(checkInvocation('startcycle-graph', dm('true'))[0].code, 'E-DMI01');
554
+ assert.deepEqual(checkInvocation('startcycle-graph', dm('false')), []);
555
+ assert.deepEqual(checkInvocation('bdb-updater', dm('true')), [], 'unlisted skills may keep the flag');
556
+ assert.deepEqual(checkInvocation('startcycle', new Map()), []);
557
+ assert.deepEqual(checkPlaybook(pb({}, ['kind', 'trigger', 'inputs', 'requires', 'go_points', 'outputs', 'verify', 'difficulty', 'est_time']), known).length, 0, 'no kind, no playbook rules');
535
558
  assert.deepEqual(
536
559
  checkPlaybook(pb({ requires: '\n skills: ["gh (external)", github,]' }), known), [],
537
560
  'quoted item and trailing comma survive',
@@ -2,7 +2,6 @@
2
2
  name: godmode-shipping
3
3
  description: BDB Shipping Godmode, the final gatekeeper for production releases. Use when running pre-launch checks, feature-flag rollouts, or rollback planning under Spec-Driven Development before a release ships.
4
4
  category: engineering-method
5
- disable-model-invocation: true
6
5
  ---
7
6
 
8
7
  # 🚀 BDB Shipping Godmode
@@ -2,7 +2,6 @@
2
2
  name: startcycle
3
3
  description: Linear multi-agent build pipeline with file hand-offs in production_artifacts/, run after /bdbrainstorm or /grill-me. This is the linear variant — no state.json, no repair loop, no dispatcher graph — distinct from startcycle-graph, which adds durable state and automated escalation.
4
4
  category: bdb-core
5
- disable-model-invocation: true
6
5
  ---
7
6
 
8
7
  # `/startcycle` — Linear Build Pipeline
@@ -2,7 +2,6 @@
2
2
  name: startcycle-graph
3
3
  description: Use when running the autonomous multi-agent build pipeline after /bdbrainstorm or /grill-me. Routes to the startcycle-dispatch Dynamic Workflow, which reads production_artifacts/state.json and invokes Architect, TechLead, UI/UX, Engineering, Media/EventTech, Reviewer, and Shipping in turn per .agents/graph.md's edge table — the agents never invoke each other.
4
4
  category: bdb-core
5
- disable-model-invocation: true
6
5
  ---
7
6
 
8
7
  # 🚀 BDB Autonomous Development Cycle (`/startcycle-graph`)
@@ -2,7 +2,6 @@
2
2
  name: ask-tim
3
3
  description: Ask which skill or flow fits your situation. A route through AOS's skills for work that travels idea to ship, plus a catalogue of the 185 available options for lookup. Use when unsure where to start, or when picking between overlapping choices.
4
4
  category: bdb-core
5
- disable-model-invocation: true
6
5
  ---
7
6
 
8
7
  <!-- The flow map below is derived from mattpocock/skills' ask-matt (MIT, see
@@ -2,7 +2,6 @@
2
2
  name: grill-me
3
3
  description: A relentless interview to sharpen a plan or design. Use before committing to an approach, or on any 'grill me' trigger phrase.
4
4
  category: engineering-method
5
- disable-model-invocation: true
6
5
  ---
7
6
 
8
7
  <!-- Source: mattpocock/skills skills/productivity/grill-me — MIT, see THIRD_PARTY_NOTICES.md -->
@@ -2,7 +2,6 @@
2
2
  name: grill-with-docs
3
3
  description: A relentless interview to sharpen a plan or design, which also builds the project's domain model — glossary and ADRs — as it goes. Use in a working directory whenever an idea needs sharpening.
4
4
  category: engineering-method
5
- disable-model-invocation: true
6
5
  ---
7
6
 
8
7
  <!-- Source: mattpocock/skills skills/engineering/grill-with-docs — MIT, see THIRD_PARTY_NOTICES.md -->
@@ -19,7 +19,6 @@ outputs: [".github/workflows/*.yml", "production_artifacts/pb-ci-fix-<date>.md"]
19
19
  verify: "gh run list --commit <sha> --json conclusion -q '.[0].conclusion' == success"
20
20
  difficulty: intermediate
21
21
  est_time: 15-45 min
22
- disable-model-invocation: true
23
22
  ---
24
23
 
25
24
  # Fix red CI or set up GitHub Actions
@@ -19,7 +19,6 @@ outputs: ["brief.md", "tracker.csv", "summary.md", "vendor-requests-draft.md", "
19
19
  verify: "summary.md total recomputed from tracker.csv matches"
20
20
  difficulty: intermediate
21
21
  est_time: 20-40 min
22
- disable-model-invocation: true
23
22
  ---
24
23
 
25
24
  # Event tracker
@@ -19,7 +19,6 @@ outputs: ["notes.md", "decisions.md", "actions.md", "followup-draft.md", "run-lo
19
19
  verify: "every decision and action in the files cites a transcript line"
20
20
  difficulty: beginner
21
21
  est_time: 10-20 min
22
- disable-model-invocation: true
23
22
  ---
24
23
 
25
24
  # Meeting notes and action list
@@ -19,7 +19,6 @@ outputs: [".aos/project.json", "AGENTS.md", ".openwiki/", "production_artifacts/
19
19
  verify: "gh repo view <owner>/<name> --json visibility -q .visibility == PRIVATE"
20
20
  difficulty: intermediate
21
21
  est_time: 20-40 min
22
- disable-model-invocation: true
23
22
  ---
24
23
 
25
24
  # New private AOS project
@@ -19,7 +19,6 @@ outputs: ["inbox.md", "calendar.md", "week-plan.md", "run-log.md"]
19
19
  verify: "every line in week-plan.md points to a line in inbox.md"
20
20
  difficulty: beginner
21
21
  est_time: 10-20 min
22
- disable-model-invocation: true
23
22
  ---
24
23
 
25
24
  # Plan my week