@xmarts/genius-setup 1.7.2 → 1.8.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.
@@ -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
 
@@ -148,6 +148,7 @@ function runSetup(token) {
148
148
  }
149
149
  const hookDst = copyHook('genius_capture.py');
150
150
  const injectDst = copyHook('genius_inject.py');
151
+ const guardDst = copyHook('plan_committee_guard.py');
151
152
 
152
153
  // 2) ~/.claude.json mcpServers (merge, no clobber)
153
154
  const claudeJsonPath = path.join(HOME, '.claude.json');
@@ -179,6 +180,12 @@ function runSetup(token) {
179
180
  ['SessionEnd', 'PreCompact', 'Stop'].forEach((e) => addHook(e, hookDst));
180
181
  // Per-turn knowledge injection + session priming (consultant_access guarantee).
181
182
  ['UserPromptSubmit', 'SessionStart'].forEach((e) => addHook(e, injectDst));
183
+ // Plan/committee -> Brain capture ENFORCEMENT (CLAUDE.md §6). On Stop it blocks the
184
+ // turn ONCE if a plan or committee ran this session without any capture_learning.
185
+ // Fails open, blocks at most once per session, and is mode-overridable per machine
186
+ // (XMA_PLAN_GUARD_MODE=block|warn|off). Unlike the fire-and-forget hooks above, this
187
+ // one can return {"decision":"block"} — by design — but never traps a session.
188
+ addHook('Stop', guardDst);
182
189
  writeJson(settingsPath, settings);
183
190
 
184
191
  // 4) ~/.claude/CLAUDE.md — install/refresh the Genius Protocol between auto-managed
@@ -209,6 +216,7 @@ function runSetup(token) {
209
216
  log(' - MCP server : beesmart_genius -> ' + BASE + '/mcp/consultant');
210
217
  log(' - Capture hook : ' + hookDst + ' (SessionEnd / PreCompact / Stop)');
211
218
  log(' - Inject hook : ' + injectDst + ' (UserPromptSubmit / SessionStart)');
219
+ log(' - Plan/committee : ' + guardDst + ' (Stop — enforces plan/comité → Brain; XMA_PLAN_GUARD_MODE=block|warn|off)');
212
220
  log(' - Config : ' + path.join(GENIUS_DIR, 'config.json'));
213
221
  log(' - Protocol : ' + path.join(CLAUDE_DIR, 'CLAUDE.md') + ' (auto-managed)');
214
222
  if (CLIENT) log(' - Default client : ' + CLIENT);
@@ -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.7.2",
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 (no Bearer in the GET-able bootstrap). Installs once; self-updates daily so new tools/skills/features arrive with zero action.",
3
+ "version": "1.8.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). Secret-free onboarding via single-use setup-token exchange (no Bearer in the GET-able bootstrap). 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
  ],