@xmarts/genius-setup 1.7.2 → 1.9.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.
- package/CLAUDE_PROTOCOL.md +11 -0
- package/bin/genius-setup.js +108 -8
- package/hooks/genius_inject.py +24 -6
- package/hooks/plan_committee_guard.py +279 -0
- package/package.json +3 -2
package/CLAUDE_PROTOCOL.md
CHANGED
|
@@ -9,6 +9,10 @@ El Brain de Genius está conectado vía el MCP **`beesmart_genius`** y dos hooks
|
|
|
9
9
|
learnings, playbooks y patrones relevantes — no tienes que pedirlos.
|
|
10
10
|
- **Captura automática** (SessionEnd/PreCompact/Stop): tus hallazgos y tu sesión se capturan solos al
|
|
11
11
|
final; no tienes que documentar manualmente.
|
|
12
|
+
- **Enforcement de plan/comité** (Stop): si una sesión generó un **plan** o convocó un **comité** pero
|
|
13
|
+
no se capturó **ningún** learning al Brain, el guard bloquea el cierre **una sola vez** y te recuerda
|
|
14
|
+
destilarlo con `capture_learning`. Falla-abierto (nunca te atrapa) y se puede ajustar por máquina con
|
|
15
|
+
`XMA_PLAN_GUARD_MODE=block|warn|off`.
|
|
12
16
|
- **Auto-actualización**: el paquete se refresca solo en segundo plano (1×/día). Cuando agregamos tools,
|
|
13
17
|
skills o features, las recibes en tus turnos **sin hacer nada**.
|
|
14
18
|
|
|
@@ -21,6 +25,13 @@ El Brain de Genius está conectado vía el MCP **`beesmart_genius`** y dos hooks
|
|
|
21
25
|
5. **NUNCA te conectes directo al Odoo del cliente** — todo fluye por el MCP `beesmart_genius`.
|
|
22
26
|
6. Tu tiempo se sugiere solo a partir de tu actividad; confírmalo cuando puedas en **My Week**
|
|
23
27
|
(`/mcp/webapp/my-week`). Solo el tiempo que confirmas cuenta como horas reales.
|
|
28
|
+
7. **PLAN o COMITÉ ⇒ captura MANDATORIA al Brain.** Siempre que generes un **plan** (modo plan /
|
|
29
|
+
estrategia aprobada) o convoques un **comité** (multi-agente / Workflow / panel), es OBLIGATORIO
|
|
30
|
+
destilar sus perspectivas, análisis, soluciones, decision-records y estrategia en learnings vía
|
|
31
|
+
`capture_learning` (central/universal — **sin** `target_client`) **ANTES de cerrar la tarea**. El
|
|
32
|
+
análisis de un plan/comité ES conocimiento durable: la tarea **no está terminada** hasta que el Brain
|
|
33
|
+
quedó alimentado (move-list + hallazgos + alternativas rechazadas + gotchas + pasos human-gated, cada
|
|
34
|
+
uno con su `forward_directive`). El guard de Stop lo hace cumplir por máquina.
|
|
24
35
|
|
|
25
36
|
## Tools (servidas dinámicamente por `beesmart_genius`)
|
|
26
37
|
|
package/bin/genius-setup.js
CHANGED
|
@@ -123,16 +123,17 @@ function runSetup(token) {
|
|
|
123
123
|
if (secret) { try { fs.chmodSync(p, 0o600); } catch (_) {} }
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// 1) ~/.genius/config.json + hook
|
|
126
|
+
// 1) ~/.genius/config.json + hook. MERGE into any existing config so state we
|
|
127
|
+
// track across runs (e.g. managed_mcp_keys for MCP de-provisioning) survives.
|
|
127
128
|
ensureDir(HOOKS_DIR);
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
const cfgPath0 = path.join(GENIUS_DIR, 'config.json');
|
|
130
|
+
const cfg = readJson(cfgPath0);
|
|
131
|
+
cfg.base_url = BASE;
|
|
132
|
+
cfg.token = token;
|
|
133
|
+
cfg.capture_endpoint = BASE + '/xma/genius/v1/capture/transcript';
|
|
134
|
+
cfg.inject_endpoint = BASE + '/xma/genius/v1/inject';
|
|
134
135
|
if (CLIENT) cfg.target_client = CLIENT;
|
|
135
|
-
writeJson(
|
|
136
|
+
writeJson(cfgPath0, cfg, true); // secret: 0600
|
|
136
137
|
|
|
137
138
|
// Copy the hooks shipped alongside this script (capture + per-turn inject).
|
|
138
139
|
function copyHook(fname) {
|
|
@@ -148,6 +149,7 @@ function runSetup(token) {
|
|
|
148
149
|
}
|
|
149
150
|
const hookDst = copyHook('genius_capture.py');
|
|
150
151
|
const injectDst = copyHook('genius_inject.py');
|
|
152
|
+
const guardDst = copyHook('plan_committee_guard.py');
|
|
151
153
|
|
|
152
154
|
// 2) ~/.claude.json mcpServers (merge, no clobber)
|
|
153
155
|
const claudeJsonPath = path.join(HOME, '.claude.json');
|
|
@@ -179,6 +181,12 @@ function runSetup(token) {
|
|
|
179
181
|
['SessionEnd', 'PreCompact', 'Stop'].forEach((e) => addHook(e, hookDst));
|
|
180
182
|
// Per-turn knowledge injection + session priming (consultant_access guarantee).
|
|
181
183
|
['UserPromptSubmit', 'SessionStart'].forEach((e) => addHook(e, injectDst));
|
|
184
|
+
// Plan/committee -> Brain capture ENFORCEMENT (CLAUDE.md §6). On Stop it blocks the
|
|
185
|
+
// turn ONCE if a plan or committee ran this session without any capture_learning.
|
|
186
|
+
// Fails open, blocks at most once per session, and is mode-overridable per machine
|
|
187
|
+
// (XMA_PLAN_GUARD_MODE=block|warn|off). Unlike the fire-and-forget hooks above, this
|
|
188
|
+
// one can return {"decision":"block"} — by design — but never traps a session.
|
|
189
|
+
addHook('Stop', guardDst);
|
|
182
190
|
writeJson(settingsPath, settings);
|
|
183
191
|
|
|
184
192
|
// 4) ~/.claude/CLAUDE.md — install/refresh the Genius Protocol between auto-managed
|
|
@@ -204,11 +212,103 @@ function runSetup(token) {
|
|
|
204
212
|
log('WARN: could not write CLAUDE.md protocol (' + e.message + ')');
|
|
205
213
|
}
|
|
206
214
|
|
|
215
|
+
// 5) Fan out the consultant's OTHER MCP servers (asana, clickup, google, ...).
|
|
216
|
+
// Their non-secret shape (manifest) + per-device secret VALUES come from the
|
|
217
|
+
// Bearer-authed /xma/genius/v1/mcp/secrets endpoint. We write Claude Code
|
|
218
|
+
// always and Claude Desktop only if it is installed. Fail-open: a fetch
|
|
219
|
+
// error never breaks onboarding. Re-runs (self-update / epoch bump) converge.
|
|
220
|
+
function desktopConfigPath() {
|
|
221
|
+
try {
|
|
222
|
+
if (process.platform === 'darwin')
|
|
223
|
+
return path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
224
|
+
if (process.platform === 'win32')
|
|
225
|
+
return path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
|
|
226
|
+
return path.join(HOME, '.config', 'Claude', 'claude_desktop_config.json');
|
|
227
|
+
} catch (_) { return ''; }
|
|
228
|
+
}
|
|
229
|
+
function mcpEntry(srv, secrets, host) {
|
|
230
|
+
// One mcpServers entry, or null if a required secret is missing (never write a
|
|
231
|
+
// half-broken server) or it is not portable to this host.
|
|
232
|
+
const env = {};
|
|
233
|
+
let ok = true;
|
|
234
|
+
(srv.env_keys || []).forEach((k) => { if (secrets[k]) env[k] = secrets[k]; else ok = false; });
|
|
235
|
+
if (!ok) return null;
|
|
236
|
+
const isHttp = srv.transport === 'http' || srv.transport === 'sse';
|
|
237
|
+
if (host === 'desktop') {
|
|
238
|
+
if (srv.host_targets !== 'both' || srv.desktop_strategy === 'skip') return null;
|
|
239
|
+
if (isHttp) return { command: 'npx', args: ['-y', 'mcp-remote', srv.url] }; // stdio-only Desktop
|
|
240
|
+
const e = { command: srv.command, args: srv.args || [] };
|
|
241
|
+
if (Object.keys(env).length) e.env = env;
|
|
242
|
+
return e;
|
|
243
|
+
}
|
|
244
|
+
if (isHttp) return { type: srv.transport, url: srv.url };
|
|
245
|
+
const e = { type: 'stdio', command: srv.command, args: srv.args || [] };
|
|
246
|
+
if (Object.keys(env).length) e.env = env;
|
|
247
|
+
return e;
|
|
248
|
+
}
|
|
249
|
+
function writeHostMcps(cfgPath, manifest, secrets, host, prevKeys) {
|
|
250
|
+
let obj = readJson(cfgPath);
|
|
251
|
+
if (!obj || typeof obj !== 'object') obj = {};
|
|
252
|
+
backup(cfgPath);
|
|
253
|
+
obj.mcpServers = obj.mcpServers || {};
|
|
254
|
+
// De-provision: drop servers WE previously managed that are no longer granted.
|
|
255
|
+
(prevKeys || []).forEach((k) => {
|
|
256
|
+
if (obj.mcpServers[k] && !manifest.find((s) => s.key === k)) delete obj.mcpServers[k];
|
|
257
|
+
});
|
|
258
|
+
const nowKeys = [];
|
|
259
|
+
manifest.forEach((srv) => {
|
|
260
|
+
const entry = mcpEntry(srv, secrets, host);
|
|
261
|
+
if (entry) { obj.mcpServers[srv.key] = entry; nowKeys.push(srv.key); }
|
|
262
|
+
else if (obj.mcpServers[srv.key] && (prevKeys || []).includes(srv.key)) {
|
|
263
|
+
delete obj.mcpServers[srv.key]; // lost its secret / portability -> remove
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
writeJson(cfgPath, obj, true); // may carry env secrets -> 0600
|
|
267
|
+
return nowKeys;
|
|
268
|
+
}
|
|
269
|
+
function provisionMcps(token) {
|
|
270
|
+
let url;
|
|
271
|
+
try { url = new URL(BASE + '/xma/genius/v1/mcp/secrets'); } catch (_) { return; }
|
|
272
|
+
const mod = url.protocol === 'http:' ? require('http') : require('https');
|
|
273
|
+
const payload = JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: {} });
|
|
274
|
+
const req = mod.request({
|
|
275
|
+
hostname: url.hostname, port: url.port || (url.protocol === 'http:' ? 80 : 443),
|
|
276
|
+
path: url.pathname, method: 'POST',
|
|
277
|
+
headers: {
|
|
278
|
+
'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload),
|
|
279
|
+
'Authorization': 'Bearer ' + token, 'User-Agent': 'XmartsGenius-Setup/1.6.0',
|
|
280
|
+
},
|
|
281
|
+
timeout: 15000,
|
|
282
|
+
}, (res) => {
|
|
283
|
+
let data = ''; res.on('data', (c) => { data += c; });
|
|
284
|
+
res.on('end', () => {
|
|
285
|
+
try {
|
|
286
|
+
const parsed = JSON.parse(data); const r = parsed.result || parsed;
|
|
287
|
+
if (!r || r.status !== 'ok' || !Array.isArray(r.manifest)) return;
|
|
288
|
+
const secrets = r.secrets || {};
|
|
289
|
+
const cfgPath = path.join(GENIUS_DIR, 'config.json');
|
|
290
|
+
const prev = (readJson(cfgPath).managed_mcp_keys) || [];
|
|
291
|
+
const managed = writeHostMcps(path.join(HOME, '.claude.json'), r.manifest, secrets, 'code', prev);
|
|
292
|
+
const dp = desktopConfigPath();
|
|
293
|
+
if (dp && (fs.existsSync(dp) || fs.existsSync(path.dirname(dp)))) {
|
|
294
|
+
try { writeHostMcps(dp, r.manifest, secrets, 'desktop', prev); } catch (_) {}
|
|
295
|
+
}
|
|
296
|
+
try { const c = readJson(cfgPath); c.managed_mcp_keys = managed; writeJson(cfgPath, c, true); } catch (_) {}
|
|
297
|
+
if (managed.length) log(' - MCP fan-out : ' + managed.join(', '));
|
|
298
|
+
} catch (_) {}
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
req.on('error', () => {}); req.on('timeout', () => { try { req.destroy(); } catch (_) {} });
|
|
302
|
+
req.write(payload); req.end();
|
|
303
|
+
}
|
|
304
|
+
provisionMcps(token);
|
|
305
|
+
|
|
207
306
|
log('');
|
|
208
307
|
log(' Xmarts Genius is set up.');
|
|
209
308
|
log(' - MCP server : beesmart_genius -> ' + BASE + '/mcp/consultant');
|
|
210
309
|
log(' - Capture hook : ' + hookDst + ' (SessionEnd / PreCompact / Stop)');
|
|
211
310
|
log(' - Inject hook : ' + injectDst + ' (UserPromptSubmit / SessionStart)');
|
|
311
|
+
log(' - Plan/committee : ' + guardDst + ' (Stop — enforces plan/comité → Brain; XMA_PLAN_GUARD_MODE=block|warn|off)');
|
|
212
312
|
log(' - Config : ' + path.join(GENIUS_DIR, 'config.json'));
|
|
213
313
|
log(' - Protocol : ' + path.join(CLAUDE_DIR, 'CLAUDE.md') + ' (auto-managed)');
|
|
214
314
|
if (CLIENT) log(' - Default client : ' + CLIENT);
|
package/hooks/genius_inject.py
CHANGED
|
@@ -25,6 +25,7 @@ TIMEOUT = 3.5
|
|
|
25
25
|
SELF_UPDATE_TTL = 86400 # at most once/day
|
|
26
26
|
SELF_UPDATE_STAMP = os.path.join(HOME, ".genius", ".last_selfupdate")
|
|
27
27
|
FORCE_STAMP = os.path.join(HOME, ".genius", ".last_forced_epoch")
|
|
28
|
+
MCP_EPOCH_STAMP = os.path.join(HOME, ".genius", ".last_mcp_epoch")
|
|
28
29
|
|
|
29
30
|
SESSION_PRIMER = (
|
|
30
31
|
"[Xmarts Genius — Brain connected]\n"
|
|
@@ -77,9 +78,10 @@ def _inject(cfg, prompt):
|
|
|
77
78
|
result = data.get("result", data) or {}
|
|
78
79
|
return ((result.get("context", "") or ""),
|
|
79
80
|
result.get("hook_force_epoch"),
|
|
80
|
-
result.get("hook_pin_version")
|
|
81
|
+
result.get("hook_pin_version"),
|
|
82
|
+
result.get("mcp_manifest_epoch"))
|
|
81
83
|
except Exception:
|
|
82
|
-
return "", None, None
|
|
84
|
+
return "", None, None, None
|
|
83
85
|
|
|
84
86
|
|
|
85
87
|
# M6 (Phase-0 integrity gate): NEVER resolve `@latest`. An unsigned `npx @latest`
|
|
@@ -101,7 +103,7 @@ def _pkg_spec(pin_version):
|
|
|
101
103
|
return "@xmarts/genius-setup@%s" % PINNED_FALLBACK
|
|
102
104
|
|
|
103
105
|
|
|
104
|
-
def _maybe_self_update(cfg, force_epoch=None, pin_version=None):
|
|
106
|
+
def _maybe_self_update(cfg, force_epoch=None, pin_version=None, mcp_epoch=None):
|
|
105
107
|
"""Keep the consultant on the SERVER-PINNED Genius package with ZERO action: at most
|
|
106
108
|
once/day, silently re-run the installer in the background. New hooks, MCP
|
|
107
109
|
tools, settings and the CLAUDE.md protocol then flow automatically — the
|
|
@@ -129,6 +131,18 @@ def _maybe_self_update(cfg, force_epoch=None, pin_version=None):
|
|
|
129
131
|
forced = fe > last
|
|
130
132
|
except Exception:
|
|
131
133
|
forced = False
|
|
134
|
+
# MCP fan-out reconvergence: a newer manifest epoch forces an immediate
|
|
135
|
+
# re-run (re-provisions ~/.claude.json + Desktop), bypassing the daily TTL.
|
|
136
|
+
if not forced and mcp_epoch:
|
|
137
|
+
try:
|
|
138
|
+
me = int(mcp_epoch)
|
|
139
|
+
lastm = 0
|
|
140
|
+
if os.path.exists(MCP_EPOCH_STAMP):
|
|
141
|
+
with open(MCP_EPOCH_STAMP) as fh:
|
|
142
|
+
lastm = int((fh.read() or "0").strip() or 0)
|
|
143
|
+
forced = me > lastm
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
132
146
|
if not forced and os.path.exists(SELF_UPDATE_STAMP) and (
|
|
133
147
|
time.time() - os.path.getmtime(SELF_UPDATE_STAMP)) < SELF_UPDATE_TTL:
|
|
134
148
|
return
|
|
@@ -140,6 +154,9 @@ def _maybe_self_update(cfg, force_epoch=None, pin_version=None):
|
|
|
140
154
|
if force_epoch:
|
|
141
155
|
with open(FORCE_STAMP, "w") as fh:
|
|
142
156
|
fh.write(str(int(force_epoch)))
|
|
157
|
+
if mcp_epoch:
|
|
158
|
+
with open(MCP_EPOCH_STAMP, "w") as fh:
|
|
159
|
+
fh.write(str(int(mcp_epoch)))
|
|
143
160
|
except Exception:
|
|
144
161
|
return
|
|
145
162
|
env = dict(os.environ)
|
|
@@ -174,11 +191,12 @@ def main():
|
|
|
174
191
|
_maybe_self_update(cfg) # TTL-only on priming
|
|
175
192
|
_emit("SessionStart", SESSION_PRIMER)
|
|
176
193
|
else:
|
|
177
|
-
context, force_epoch, pin_version = _inject(cfg, event.get("prompt") or "")
|
|
194
|
+
context, force_epoch, pin_version, mcp_epoch = _inject(cfg, event.get("prompt") or "")
|
|
178
195
|
# The inject call already round-tripped the server, so honour any
|
|
179
196
|
# admin-pushed force-update epoch here (bypasses the daily TTL) and install
|
|
180
|
-
# the exact server-pinned version (never @latest).
|
|
181
|
-
|
|
197
|
+
# the exact server-pinned version (never @latest). A newer MCP manifest
|
|
198
|
+
# epoch likewise forces an immediate re-provision of the fan-out servers.
|
|
199
|
+
_maybe_self_update(cfg, force_epoch, pin_version, mcp_epoch)
|
|
182
200
|
_emit("UserPromptSubmit", context)
|
|
183
201
|
|
|
184
202
|
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
plan_committee_guard.py — machine enforcement of the canonical Genius rule:
|
|
5
|
+
|
|
6
|
+
"Plan o comité ⇒ captura MANDATORIA al Brain" (CLAUDE.md §6 / CLAUDE_PROTOCOL.md)
|
|
7
|
+
|
|
8
|
+
Fires on the Claude Code **Stop** event (end of a main-agent turn). It scans the
|
|
9
|
+
session transcript: if this session generated a PLAN (plan mode / ExitPlanMode, a
|
|
10
|
+
`~/.claude/plans/` file, or the writing-plans skill) or convened a COMMITTEE (a
|
|
11
|
+
`Workflow` run or a subagent fan-out) but captured ZERO learnings to the Genius Brain
|
|
12
|
+
(`mcp__beesmart_genius__capture_learning`), it BLOCKS the Stop once with a directive
|
|
13
|
+
telling Claude to distill the plan/committee into the Brain before finishing.
|
|
14
|
+
|
|
15
|
+
This is the enforcement layer for THIS session and for EVERY consultant session —
|
|
16
|
+
it ships with @xmarts/genius-setup (npx rail → ~/.genius/hooks/) and the
|
|
17
|
+
`xmarts-genius` plugin (plugin/hooks/hooks.json → ${CLAUDE_PLUGIN_ROOT}/hooks/).
|
|
18
|
+
|
|
19
|
+
DESIGN GUARANTEES (the §15 "never break a session" discipline):
|
|
20
|
+
* FAILS OPEN — any parse/IO/unknown error → exit 0 (allow the Stop). A hook must
|
|
21
|
+
never trap or crash a consultant's session.
|
|
22
|
+
* BLOCKS AT MOST ONCE per session (state file under ~/.genius/state/). After the
|
|
23
|
+
single nudge it degrades to a non-blocking log line, so if the Brain is
|
|
24
|
+
unreachable or Claude can't capture, the user is NEVER trapped in a loop.
|
|
25
|
+
* Honors `stop_hook_active` to avoid stop-loops.
|
|
26
|
+
* Only nudges once REAL post-plan work exists (a mutation after the plan signal, or
|
|
27
|
+
>= MIN_TURNS_AFTER_SIGNAL assistant turns), so it doesn't interrupt mid-planning.
|
|
28
|
+
* The block is delivered as STDOUT JSON {"decision":"block","reason":...} + exit 0,
|
|
29
|
+
so it survives the installer's `... || true` command wrapper (which only forces the
|
|
30
|
+
exit code, not stdout).
|
|
31
|
+
* Mode override per machine via env:
|
|
32
|
+
XMA_PLAN_GUARD_MODE = block (default) | warn (log only, never blocks) | off
|
|
33
|
+
|
|
34
|
+
A single deliberate capture_learning call clears the debt — the guard never audits
|
|
35
|
+
"enough" captures, it only insists the plan/committee was fed to the Brain at all.
|
|
36
|
+
"""
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
40
|
+
import time
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
# --- tunables (env-overridable) ---------------------------------------------
|
|
44
|
+
MODE = (os.environ.get("XMA_PLAN_GUARD_MODE", "block") or "block").strip().lower()
|
|
45
|
+
try:
|
|
46
|
+
MIN_TURNS_AFTER_SIGNAL = int(os.environ.get("XMA_PLAN_GUARD_MIN_TURNS", "3"))
|
|
47
|
+
except Exception:
|
|
48
|
+
MIN_TURNS_AFTER_SIGNAL = 3
|
|
49
|
+
MAX_BLOCKS_PER_SESSION = 1
|
|
50
|
+
SUBAGENT_THRESHOLD = 4 # >= this many Task/Agent subagent calls ≈ a committee fan-out
|
|
51
|
+
|
|
52
|
+
# --- signal vocabulary ------------------------------------------------------
|
|
53
|
+
PLAN_TOOLS = {"ExitPlanMode", "EnterPlanMode"}
|
|
54
|
+
PLAN_SKILLS = {"writing-plans", "executing-plans"}
|
|
55
|
+
PLAN_FILE_HINT = "/.claude/plans/"
|
|
56
|
+
COMMITTEE_TOOLS = {"Workflow"}
|
|
57
|
+
COMMITTEE_SKILLS = {"dispatching-parallel-agents", "subagent-driven-development"}
|
|
58
|
+
SUBAGENT_TOOLS = {"Task", "Agent"}
|
|
59
|
+
CAPTURE_SUFFIX = "__capture_learning"
|
|
60
|
+
MUTATION_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"}
|
|
61
|
+
MUTATION_SUBSTR = ("create_record", "update_record", "delete_record",
|
|
62
|
+
"manage_module", "manage_webapp", "execute_orm", "execute_method")
|
|
63
|
+
|
|
64
|
+
STATE_DIR = Path(os.path.expanduser("~/.genius/state/plan_committee_guard"))
|
|
65
|
+
LOG_FILE = STATE_DIR / "guard.log"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _log(msg):
|
|
69
|
+
try:
|
|
70
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
with open(LOG_FILE, "a") as f:
|
|
72
|
+
f.write("%s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), msg))
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _allow():
|
|
78
|
+
"""Allow the Stop (success, no block)."""
|
|
79
|
+
sys.exit(0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _iter_tool_uses(obj):
|
|
83
|
+
"""Yield (name, input_dict) for every tool_use block in a transcript line object."""
|
|
84
|
+
if not isinstance(obj, dict):
|
|
85
|
+
return
|
|
86
|
+
msg = obj.get("message")
|
|
87
|
+
content = msg.get("content") if isinstance(msg, dict) else None
|
|
88
|
+
if isinstance(content, list):
|
|
89
|
+
for item in content:
|
|
90
|
+
if isinstance(item, dict) and item.get("type") == "tool_use":
|
|
91
|
+
yield (item.get("name") or ""), (item.get("input") or {})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_assistant(obj):
|
|
95
|
+
if not isinstance(obj, dict):
|
|
96
|
+
return False
|
|
97
|
+
if obj.get("type") == "assistant":
|
|
98
|
+
return True
|
|
99
|
+
msg = obj.get("message")
|
|
100
|
+
return isinstance(msg, dict) and msg.get("role") == "assistant"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_mutation(name):
|
|
104
|
+
if name in MUTATION_TOOLS:
|
|
105
|
+
return True
|
|
106
|
+
return any(s in name for s in MUTATION_SUBSTR)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def scan(transcript_path):
|
|
110
|
+
"""One pass over the JSONL transcript. Returns detections (cheap pre-filter then parse)."""
|
|
111
|
+
plan = committee = False
|
|
112
|
+
capture_count = 0
|
|
113
|
+
subagent_count = 0
|
|
114
|
+
committee_from_subagent = False
|
|
115
|
+
signal_idx = None # index of first plan/committee signal line
|
|
116
|
+
mutation_after = False
|
|
117
|
+
assistant_turns_after = 0
|
|
118
|
+
|
|
119
|
+
with open(transcript_path, "r", errors="ignore") as f:
|
|
120
|
+
for idx, line in enumerate(f):
|
|
121
|
+
# cheap pre-filter: skip lines that cannot carry a tool_use or an assistant turn
|
|
122
|
+
if ('"tool_use"' not in line) and ('"assistant"' not in line):
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
obj = json.loads(line)
|
|
126
|
+
except Exception:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
line_has_signal = False
|
|
130
|
+
for name, inp in _iter_tool_uses(obj):
|
|
131
|
+
# --- plan signals ---
|
|
132
|
+
if name in PLAN_TOOLS:
|
|
133
|
+
plan = True
|
|
134
|
+
line_has_signal = True
|
|
135
|
+
if name == "Skill":
|
|
136
|
+
sk = (inp.get("skill") or inp.get("command") or "") if isinstance(inp, dict) else ""
|
|
137
|
+
if sk in PLAN_SKILLS:
|
|
138
|
+
plan = True
|
|
139
|
+
line_has_signal = True
|
|
140
|
+
if sk in COMMITTEE_SKILLS:
|
|
141
|
+
committee = True
|
|
142
|
+
line_has_signal = True
|
|
143
|
+
if name in ("Write", "Edit", "MultiEdit", "NotebookEdit"):
|
|
144
|
+
fp = ""
|
|
145
|
+
if isinstance(inp, dict):
|
|
146
|
+
fp = inp.get("file_path") or inp.get("notebook_path") or ""
|
|
147
|
+
if PLAN_FILE_HINT in fp:
|
|
148
|
+
plan = True
|
|
149
|
+
line_has_signal = True
|
|
150
|
+
# --- committee signals ---
|
|
151
|
+
if name in COMMITTEE_TOOLS:
|
|
152
|
+
committee = True
|
|
153
|
+
line_has_signal = True
|
|
154
|
+
if name in SUBAGENT_TOOLS:
|
|
155
|
+
subagent_count += 1
|
|
156
|
+
# --- capture (clears the debt) ---
|
|
157
|
+
if name.endswith(CAPTURE_SUFFIX):
|
|
158
|
+
capture_count += 1
|
|
159
|
+
# --- mutation-after bookkeeping ---
|
|
160
|
+
if signal_idx is not None and idx > signal_idx and _is_mutation(name):
|
|
161
|
+
mutation_after = True
|
|
162
|
+
|
|
163
|
+
# subagent fan-out crossing the threshold is itself a committee signal (once)
|
|
164
|
+
if (not committee_from_subagent) and subagent_count >= SUBAGENT_THRESHOLD:
|
|
165
|
+
committee = True
|
|
166
|
+
committee_from_subagent = True
|
|
167
|
+
line_has_signal = True
|
|
168
|
+
|
|
169
|
+
if line_has_signal and signal_idx is None:
|
|
170
|
+
signal_idx = idx
|
|
171
|
+
|
|
172
|
+
if signal_idx is not None and idx > signal_idx and _is_assistant(obj):
|
|
173
|
+
assistant_turns_after += 1
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"plan": plan,
|
|
177
|
+
"committee": committee,
|
|
178
|
+
"capture_count": capture_count,
|
|
179
|
+
"subagent_count": subagent_count,
|
|
180
|
+
"signal_idx": signal_idx,
|
|
181
|
+
"mutation_after": mutation_after,
|
|
182
|
+
"assistant_turns_after": assistant_turns_after,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main():
|
|
187
|
+
raw = sys.stdin.read()
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
190
|
+
except Exception:
|
|
191
|
+
_allow()
|
|
192
|
+
|
|
193
|
+
if MODE == "off":
|
|
194
|
+
_allow()
|
|
195
|
+
|
|
196
|
+
event = data.get("hook_event_name") or ""
|
|
197
|
+
if event and event != "Stop":
|
|
198
|
+
# Defensive: this guard only enforces on Stop. Any other wiring → allow.
|
|
199
|
+
_allow()
|
|
200
|
+
|
|
201
|
+
transcript = os.path.expanduser(data.get("transcript_path") or "")
|
|
202
|
+
if not transcript or not os.path.exists(transcript):
|
|
203
|
+
_allow()
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
det = scan(transcript)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
_log("scan-error %s" % e)
|
|
209
|
+
_allow()
|
|
210
|
+
|
|
211
|
+
debt = (det["plan"] or det["committee"]) and det["capture_count"] == 0
|
|
212
|
+
if not debt:
|
|
213
|
+
_allow()
|
|
214
|
+
|
|
215
|
+
kind = "plan" if det["plan"] else "comité"
|
|
216
|
+
session_id = str(data.get("session_id") or "unknown")
|
|
217
|
+
|
|
218
|
+
# never loop on a stop-hook continuation
|
|
219
|
+
if data.get("stop_hook_active"):
|
|
220
|
+
_log("session=%s debt=%s stop_hook_active -> allow" % (session_id, kind))
|
|
221
|
+
_allow()
|
|
222
|
+
|
|
223
|
+
# substantial-work gate: don't nudge mid-planning (wait for real post-plan work)
|
|
224
|
+
substantial = det["mutation_after"] or det["assistant_turns_after"] >= MIN_TURNS_AFTER_SIGNAL
|
|
225
|
+
if not substantial:
|
|
226
|
+
_log("session=%s debt=%s not-yet-substantial -> allow" % (session_id, kind))
|
|
227
|
+
_allow()
|
|
228
|
+
|
|
229
|
+
# block-once-per-session state
|
|
230
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
state_file = STATE_DIR / ("%s.json" % session_id.replace("/", "_"))
|
|
232
|
+
state = {}
|
|
233
|
+
if state_file.exists():
|
|
234
|
+
try:
|
|
235
|
+
state = json.loads(state_file.read_text())
|
|
236
|
+
except Exception:
|
|
237
|
+
state = {}
|
|
238
|
+
blocks = int(state.get("blocks", 0))
|
|
239
|
+
|
|
240
|
+
if MODE == "warn" or blocks >= MAX_BLOCKS_PER_SESSION:
|
|
241
|
+
_log("session=%s debt=%s mode=%s blocks=%d -> advise-only (allow)"
|
|
242
|
+
% (session_id, kind, MODE, blocks))
|
|
243
|
+
_allow()
|
|
244
|
+
|
|
245
|
+
state["blocks"] = blocks + 1
|
|
246
|
+
state["last"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
247
|
+
state["kind"] = kind
|
|
248
|
+
try:
|
|
249
|
+
state_file.write_text(json.dumps(state))
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
_log("session=%s debt=%s BLOCK#%d" % (session_id, kind, blocks + 1))
|
|
253
|
+
|
|
254
|
+
reason = (
|
|
255
|
+
u"⛔ Captura-debt al Genius Brain (regla canónica CLAUDE.md §6 / "
|
|
256
|
+
u"Protocolo de Consultor).\n"
|
|
257
|
+
u"Esta sesión generó un %s pero NO se capturó ningún learning al Brain "
|
|
258
|
+
u"(0 llamadas a capture_learning).\n\n"
|
|
259
|
+
u"ANTES de cerrar: destilá el %s al Brain vía mcp__beesmart_genius__capture_learning "
|
|
260
|
+
u"(central/universal — SIN target_client). Capturá, como mínimo: la move-list / "
|
|
261
|
+
u"decisiones, los hallazgos clave + alternativas rechazadas, los gotchas / breaking-changes, "
|
|
262
|
+
u"y los pasos human-gated — cada uno con su forward_directive (category best_practice o "
|
|
263
|
+
u"pattern).\n\n"
|
|
264
|
+
u"Si ya capturaste fuera de esta sesión, o el Brain está inalcanzable: anotalo en una "
|
|
265
|
+
u"línea y entonces sí podés cerrar (este recordatorio es de UNA sola vez por sesión)."
|
|
266
|
+
) % (kind, kind)
|
|
267
|
+
|
|
268
|
+
sys.stdout.write(json.dumps({"decision": "block", "reason": reason}))
|
|
269
|
+
sys.exit(0)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
try:
|
|
274
|
+
main()
|
|
275
|
+
except SystemExit:
|
|
276
|
+
raise
|
|
277
|
+
except Exception as e: # absolute backstop — never break a session
|
|
278
|
+
_log("fatal %s" % e)
|
|
279
|
+
sys.exit(0)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmarts/genius-setup",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "One-command, self-updating onboarding for Xmarts Genius (Brain MCP + per-turn injection + automatic session/time capture + CLAUDE.md protocol). Secret-free onboarding via single-use setup-token exchange
|
|
3
|
+
"version": "1.9.0",
|
|
4
|
+
"description": "One-command, self-updating onboarding for Xmarts Genius (Brain MCP + per-turn injection + automatic session/time capture + plan/committee→Brain enforcement + CLAUDE.md protocol + MCP fan-out: provisions the consultant's granted MCP servers into Claude Code & Claude Desktop). Secret-free onboarding via single-use setup-token exchange. Installs once; self-updates daily so new tools/skills/features arrive with zero action.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"genius-setup": "bin/genius-setup.js"
|
|
7
7
|
},
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"bin/genius-setup.js",
|
|
10
10
|
"hooks/genius_inject.py",
|
|
11
11
|
"hooks/genius_capture.py",
|
|
12
|
+
"hooks/plan_committee_guard.py",
|
|
12
13
|
"CLAUDE_PROTOCOL.md",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|