@hybridlabor-api/aos 4.14.0 → 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();
@@ -8,6 +8,7 @@ let raw = '';
8
8
  try { for await (const c of process.stdin) raw += c; } catch {}
9
9
  let ev;
10
10
  try { ev = JSON.parse(raw); } catch { process.exit(0); }
11
+ if (!ev || typeof ev !== 'object') process.exit(0);
11
12
 
12
13
  ev.agent = arg('--agent') || ev.agent || 'claude';
13
14
  ev.hook_event_name ||= arg('--event');
@@ -6,11 +6,11 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/go-gate.mjs\""
9
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/go-gate.mjs\""
10
10
  },
11
11
  {
12
12
  "type": "command",
13
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/conventional-commits.mjs\""
13
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/conventional-commits.mjs\""
14
14
  }
15
15
  ]
16
16
  },
@@ -19,7 +19,7 @@
19
19
  "hooks": [
20
20
  {
21
21
  "type": "command",
22
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/env-file-protection.mjs\""
22
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/env-file-protection.mjs\""
23
23
  }
24
24
  ]
25
25
  },
@@ -48,7 +48,7 @@
48
48
  "hooks": [
49
49
  {
50
50
  "type": "command",
51
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/graph-gate.mjs\""
51
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/graph-gate.mjs\""
52
52
  }
53
53
  ]
54
54
  },
@@ -119,7 +119,7 @@
119
119
  "hooks": [
120
120
  {
121
121
  "type": "command",
122
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/go-token.mjs\""
122
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/go-token.mjs\""
123
123
  }
124
124
  ]
125
125
  },
@@ -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/installer.js CHANGED
@@ -4004,8 +4004,8 @@ function mergeBdbSettingsHooks(settingsPath, { projectLocal = false } = {}) {
4004
4004
  // Global installs must point at the copies installGlobalHooks put under
4005
4005
  // $HOME/.claude/hooks; $CLAUDE_PROJECT_DIR would not exist in other projects.
4006
4006
  const localize = (cmd) => (projectLocal
4007
- ? (machineGlobalHooks.some((n) => cmd.includes(n)) ? cmd : cmd.split('${HOME}').join('$CLAUDE_PROJECT_DIR'))
4008
- : cmd.split('$CLAUDE_PROJECT_DIR/.claude/hooks/').join('$HOME/.claude/hooks/'));
4007
+ ? (machineGlobalHooks.some((n) => cmd.includes(n)) ? cmd : cmd.split('${HOME}').join('${CLAUDE_PROJECT_DIR}').replace(/\$CLAUDE_PROJECT_DIR\b/g, '${CLAUDE_PROJECT_DIR}'))
4008
+ : cmd.replace(/\$\{?CLAUDE_PROJECT_DIR\}?\/\.claude\/hooks\//g, '$HOME/.claude/hooks/'));
4009
4009
  const cloneBdbEntries = (entries) =>
4010
4010
  JSON.parse(JSON.stringify(entries)).map((e) => ({
4011
4011
  ...e,
@@ -4067,18 +4067,61 @@ function mergeBdbSettingsHooks(settingsPath, { projectLocal = false } = {}) {
4067
4067
  }
4068
4068
  }
4069
4069
 
4070
+ // agy runs a hook command through `sh -c` / `cmd /c` with cwd = the hooks.json folder, and
4071
+ // cmd cannot parse the \" Go emits for quotes. So the command is relative and quote-free.
4072
+ function agyHookCommand(hooksJsonDir, scriptPath, args = '') {
4073
+ const rel = path.relative(hooksJsonDir, scriptPath).split(path.sep).join('/');
4074
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel) || /[\s"'&|<>^%]/.test(rel)) {
4075
+ throw new Error(`agy hook script must sit under ${hooksJsonDir} with a plain path, got ${scriptPath}`);
4076
+ }
4077
+ return `node ${rel}${args}`;
4078
+ }
4079
+
4080
+ // Runs every AOS command of a hooks.json once with `{}` on stdin, the way agy does.
4081
+ // Returns [{ command, reason }] for each failure; `spawn` is injectable for tests.
4082
+ function selfCheckAgyHooks(hooksPath, spawn = spawnSync) {
4083
+ const bdbScripts = ['go-gate.mjs', 'graph-gate.mjs', 'memb-inject.mjs', 'startcycle-dispatch.mjs', 'trail-relay.mjs', 'conventional-commits.mjs', 'env-file-protection.mjs'];
4084
+ const data = readJsonFile(hooksPath);
4085
+ const commands = [];
4086
+ const walk = (v) => {
4087
+ if (Array.isArray(v)) v.forEach(walk);
4088
+ else if (v && typeof v === 'object') {
4089
+ if (typeof v.command === 'string' && bdbScripts.some((n) => v.command.includes(n))) commands.push(v.command);
4090
+ Object.values(v).forEach(walk);
4091
+ }
4092
+ };
4093
+ walk(data && data.hooks);
4094
+ const [shell, flag] = process.platform === 'win32' ? ['cmd', '/c'] : ['sh', '-c'];
4095
+ const failures = [];
4096
+ for (const command of [...new Set(commands)]) {
4097
+ // AGENTTRAIL_PORT=1: the no-op payload must not show up on a live map.
4098
+ const r = spawn(shell, [flag, command], { cwd: path.dirname(hooksPath), input: '{}', encoding: 'utf8', timeout: 5000, windowsHide: true, env: { ...process.env, AGENTTRAIL_PORT: '1' } });
4099
+ const stderr = String((r && r.stderr) || '');
4100
+ let reason = null;
4101
+ if (!r || r.error) reason = `spawn failed: ${r && r.error ? r.error.message : 'no result'}`;
4102
+ else if (r.status !== 0) reason = `exit ${r.status}\n ${stderr.trim().slice(0, 500)}`;
4103
+ else if (/MODULE_NOT_FOUND|Cannot find module/.test(stderr)) reason = `module not found\n ${stderr.trim().slice(0, 500)}`;
4104
+ if (reason) failures.push({ command, reason });
4105
+ }
4106
+ return failures;
4107
+ }
4108
+
4070
4109
  // Merge the BDB hooks into Google Antigravity's hooks.json (.agents/hooks.json or
4071
4110
  // ~/.gemini/config/hooks.json), preserving user-defined foreign hooks.
4072
- function mergeAntigravityHooks(hooksPath, { projectLocal = false } = {}) {
4111
+ function mergeAntigravityHooks(hooksPath, { projectLocal = false, selfCheck = spawnSync } = {}) {
4073
4112
  const bdbHookScripts = ['go-gate.mjs', 'graph-gate.mjs', 'memb-inject.mjs', 'startcycle-dispatch.mjs', 'trail-relay.mjs', 'conventional-commits.mjs', 'env-file-protection.mjs'];
4074
4113
  const isBdbHandler = (h) => typeof h?.command === 'string' && bdbHookScripts.some((name) => h.command.includes(name));
4075
4114
 
4076
4115
  const baseDir = path.dirname(hooksPath);
4077
- const hooksDir = projectLocal ? path.join(currentDir, '.agents', 'hooks') : path.join(baseDir, 'hooks');
4078
- const workflowsDir = projectLocal ? path.join(currentDir, '.agents', 'workflows') : path.join(baseDir, 'workflows');
4079
- const globalHooksDir = projectLocal ? path.join(homeDir, '.gemini', 'config', 'hooks') : hooksDir;
4080
-
4081
- const cmd = (dir, script, args = '') => `node "${path.join(dir, script)}"${args}`;
4116
+ // Commands are relative to hooks.json's folder, so every script must live under it
4117
+ // (installProjectHarness copies the hooks into .agents/hooks for the project-local file).
4118
+ const hooksDir = path.join(baseDir, 'hooks');
4119
+ const workflowsDir = path.join(baseDir, 'workflows');
4120
+ const globalHooksDir = hooksDir;
4121
+
4122
+ const cmd = (dir, script, args = '') => agyHookCommand(baseDir, path.join(dir, script), args);
4123
+ // Observability hooks must never block a tool call when they cannot start.
4124
+ const obs = (dir, script, args = '') => cmd(dir, script, `${args} || exit 0`);
4082
4125
  const bdbHooks = {
4083
4126
  PreToolUse: [
4084
4127
  {
@@ -4094,15 +4137,15 @@ function mergeAntigravityHooks(hooksPath, { projectLocal = false } = {}) {
4094
4137
  },
4095
4138
  {
4096
4139
  matcher: "*",
4097
- hooks: [{ type: "command", command: cmd(globalHooksDir, 'trail-relay.mjs', ' --agent agy --event PreToolUse'), timeout: 2 }]
4140
+ hooks: [{ type: "command", command: obs(globalHooksDir, 'trail-relay.mjs', ' --agent agy --event PreToolUse'), timeout: 2 }]
4098
4141
  }
4099
4142
  ],
4100
4143
  Stop: [
4101
4144
  { type: "command", command: cmd(hooksDir, 'graph-gate.mjs'), timeout: 10 },
4102
- { type: "command", command: cmd(globalHooksDir, 'trail-relay.mjs', ' --agent agy --event Stop'), timeout: 2 }
4145
+ { type: "command", command: obs(globalHooksDir, 'trail-relay.mjs', ' --agent agy --event Stop'), timeout: 2 }
4103
4146
  ],
4104
4147
  PreInvocation: [
4105
- { type: "command", command: cmd(globalHooksDir, 'memb-inject.mjs'), timeout: 8 },
4148
+ { type: "command", command: obs(globalHooksDir, 'memb-inject.mjs'), timeout: 8 },
4106
4149
  { type: "command", command: cmd(workflowsDir, 'startcycle-dispatch.mjs'), timeout: 30 }
4107
4150
  ]
4108
4151
  };
@@ -4160,9 +4203,19 @@ function mergeAntigravityHooks(hooksPath, { projectLocal = false } = {}) {
4160
4203
 
4161
4204
  try {
4162
4205
  fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
4206
+ const previousRaw = existing ? fs.readFileSync(hooksPath, 'utf8') : null;
4163
4207
  const tmpPath = `${hooksPath}.${process.pid}.tmp`;
4164
4208
  fs.writeFileSync(tmpPath, JSON.stringify(buildMerged(existing), null, 2) + '\n');
4165
4209
  fs.renameSync(tmpPath, hooksPath);
4210
+ if (!DRY_RUN) {
4211
+ const failures = selfCheckAgyHooks(hooksPath, selfCheck);
4212
+ if (failures.length) {
4213
+ if (previousRaw === null) fs.unlinkSync(hooksPath);
4214
+ else fs.writeFileSync(hooksPath, previousRaw);
4215
+ log.warn(`Antigravity hook self-check FAILED for ${hooksPath}; the previous hooks.json was ${previousRaw === null ? 'removed (none existed)' : 'restored'}.`);
4216
+ for (const f of failures) log.warn(` command: ${f.command}\n ${f.reason}`);
4217
+ }
4218
+ }
4166
4219
  } catch (e) {
4167
4220
  log.warn(`Could not write ${hooksPath}: ${e.message}`);
4168
4221
  }
@@ -5916,6 +5969,8 @@ module.exports = {
5916
5969
  keepExistingEnvValues,
5917
5970
  mergeBdbSettingsHooks,
5918
5971
  mergeAntigravityHooks,
5972
+ agyHookCommand,
5973
+ selfCheckAgyHooks,
5919
5974
  mergeCodexTomlHooks,
5920
5975
  mergeCodexHooks: mergeCodexTomlHooks,
5921
5976
  mergeCodexTomlMcpServers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hybridlabor-api/aos",
3
- "version": "4.14.0",
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/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