@heretek-ai/epistemic-swarm 0.4.2 → 0.4.4

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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/hooks/hooks.json +22 -1
  3. package/install.sh +25 -0
  4. package/package.json +1 -1
  5. package/plugins/antigravity/plugin.json +1 -1
  6. package/plugins/gemini/gemini-extension.json +1 -1
  7. package/plugins/opencode/index.js +44 -15
  8. package/plugins/opencode/tui.js +40 -18
  9. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  22. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  23. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  24. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  25. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  26. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  27. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  28. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  29. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  35. package/runner/tests/test_bet1_spike.py +95 -0
  36. package/runner/tests/test_opencode_ux.py +76 -0
  37. package/runner/tests/test_webcache.py +222 -0
  38. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  39. package/scripts/bet1_advisory_spike.py +426 -0
  40. package/skills/epistemic_search/scripts/webcache.py +292 -0
  41. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  42. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  43. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  44. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistemic-swarm",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "High-Integrity Dialectic Research Agent Harness for Claude Code enforcing empirical evidence over parametric hallucination.",
5
5
  "author": {
6
6
  "name": "Heretek AI",
package/hooks/hooks.json CHANGED
@@ -15,7 +15,28 @@
15
15
  "hooks": [
16
16
  {
17
17
  "type": "command",
18
- "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"WebFetch intercepted by epistemic-swarm to enforce content-addressed caching. Use epistemic fetch (python3 ${CLAUDE_PLUGIN_ROOT}/skills/epistemic_search/scripts/fetch.py \\\"<url>\\\") which automatically stores documents in .research/sources/<sha256>.md for mathematical auditability.\"}}'"
18
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/skills/epistemic_search/scripts/webcache.py\" gate"
19
+ }
20
+ ]
21
+ }
22
+ ],
23
+ "PostToolUse": [
24
+ {
25
+ "matcher": "WebFetch",
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/skills/epistemic_search/scripts/webcache.py\" archive"
30
+ }
31
+ ]
32
+ }
33
+ ],
34
+ "SessionStart": [
35
+ {
36
+ "hooks": [
37
+ {
38
+ "type": "command",
39
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/skills/epistemic_search/scripts/webcache.py\" stats"
19
40
  }
20
41
  ]
21
42
  }
package/install.sh CHANGED
@@ -177,6 +177,31 @@ try:
177
177
  print(f" + Added agent: {name}")
178
178
  else:
179
179
  print(f" ℹ️ Agent already configured: {name}")
180
+
181
+ # Skills: snippet paths are repo-relative ("./skills/x") which breaks
182
+ # from ~/.config — merge as absolute paths, repairing relative entries.
183
+ import os as _os
184
+ wanted = []
185
+ for sp in snippet.get("skills", {}).get("paths", []):
186
+ wanted.append(sp if _os.path.isabs(sp) else str(repo / sp.lstrip("./")))
187
+ skills_cfg = cfg.setdefault("skills", {})
188
+ paths = skills_cfg.setdefault("paths", [])
189
+ # Repair repo-relative entries in place, then append missing absolutes.
190
+ for i, existing in enumerate(list(paths)):
191
+ for abs_sp in wanted:
192
+ if existing == abs_sp:
193
+ break
194
+ rel = "./" + str(Path(abs_sp).relative_to(repo)) if str(abs_sp).startswith(str(repo)) else None
195
+ if rel and existing == rel:
196
+ paths[i] = abs_sp
197
+ print(f" ~ Repaired relative skill path: {rel} -> {abs_sp}")
198
+ break
199
+ for abs_sp in wanted:
200
+ if abs_sp not in paths:
201
+ paths.append(abs_sp)
202
+ print(f" + Added skill path: {abs_sp}")
203
+ else:
204
+ print(f" ℹ️ Skill path already configured: {abs_sp}")
180
205
  except Exception as e:
181
206
  print(f" ⚠️ Non-critical warning merging agents: {e}")
182
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "IUMBTEMS: I Use My Brain To Express My Self — High-Integrity Dialectic Research Agent Harness for Claude Code, OpenCode V2, Pi, OMP (oh-my-pi), Gemini CLI, Codex CLI, and AntiGravity",
5
5
  "main": "bin/cli.js",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://antigravity.google/schemas/v1/plugin.json",
3
3
  "name": "epistemic-swarm",
4
- "version": "0.4.2",
4
+ "version": "0.4.4",
5
5
  "description": "IUMBTEMS Epistemic Swarm: dialectic research, code audit, OSS scout, and lateral brainstorming for AntiGravity.",
6
6
  "author": "Heretek AI",
7
7
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistemic-swarm",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "IUMBTEMS Epistemic Swarm: dialectic research, code audit, OSS scout, and lateral brainstorming for Gemini CLI.",
5
5
  "mcpServers": {
6
6
  "brave-search": {
@@ -11,7 +11,7 @@
11
11
  * catalog. Schemas mirror runner/mcp_server.py build_tools() — keep in sync.
12
12
  */
13
13
 
14
- import { spawnSync } from 'child_process';
14
+ import { spawn } from 'child_process';
15
15
  import { readFileSync } from 'fs';
16
16
  import path from 'path';
17
17
  import { fileURLToPath } from 'url';
@@ -30,21 +30,40 @@ try {
30
30
  // keep fallback; version is informational only
31
31
  }
32
32
 
33
- /** Uniform dispatch: one-shot MCP call, return OpenCode's {content, status}. */
33
+ /**
34
+ * Uniform dispatch: one-shot MCP call, return OpenCode's {content, status}.
35
+ * Async (non-blocking spawn) so long swarms/audits never freeze the host
36
+ * event loop; cancellation remains the host's prerogative.
37
+ */
34
38
  function callMcp(tool, args = {}, cwd) {
35
- const res = spawnSync(
36
- 'python3',
37
- [MCP_SERVER, 'call', tool, JSON.stringify(args || {})],
38
- {
39
- encoding: 'utf-8',
40
- cwd: cwd || process.cwd(),
41
- env: { ...process.env, PYTHONPATH: PKG_ROOT },
39
+ return new Promise((resolve) => {
40
+ let stdout = '';
41
+ let stderr = '';
42
+ let settled = false;
43
+ const done = (content, status) => {
44
+ if (settled) return;
45
+ settled = true;
46
+ resolve({ content, status });
47
+ };
48
+ let child;
49
+ try {
50
+ child = spawn(
51
+ 'python3',
52
+ [MCP_SERVER, 'call', tool, JSON.stringify(args || {})],
53
+ {
54
+ cwd: cwd || process.cwd(),
55
+ env: { ...process.env, PYTHONPATH: PKG_ROOT },
56
+ }
57
+ );
58
+ } catch (err) {
59
+ done(String((err && err.message) || err), 'error');
60
+ return;
42
61
  }
43
- );
44
- return {
45
- content: res.stdout || res.stderr,
46
- status: res.status === 0 ? 'success' : 'error',
47
- };
62
+ child.stdout.on('data', (d) => { stdout += String(d); });
63
+ child.stderr.on('data', (d) => { stderr += String(d); });
64
+ child.on('error', (err) => done(String((err && err.message) || err), 'error'));
65
+ child.on('close', (code) => done(stdout || stderr, code === 0 ? 'success' : 'error'));
66
+ });
48
67
  }
49
68
 
50
69
  /** Catalog mirrors runner/mcp_server.py build_tools(). */
@@ -351,9 +370,11 @@ export const OPENCODE_COMMANDS = [
351
370
  {
352
371
  name: 'swarm',
353
372
  description: 'Run IUMBTEMS dialectic research swarm on an objective',
373
+ usage: '/swarm <objective>',
354
374
  template: [
355
375
  'Run an IUMBTEMS dialectic research swarm.',
356
376
  'Objective: $ARGUMENTS',
377
+ 'If $ARGUMENTS is empty, ask the user for the research objective first; never call the tool with placeholder, empty, or literal "<objective>" arguments.',
357
378
  '1. If the objective is ambiguous, frame it first via iumbtems_socratic_frontier.',
358
379
  '2. Execute iumbtems_swarm_research with {"objective": "<objective>"} (pass "mock_mode": true only for dry runs).',
359
380
  '3. Summarize .research/final_synthesis.md, preserving [VERIFIED:<hash>] pointers.',
@@ -362,6 +383,7 @@ export const OPENCODE_COMMANDS = [
362
383
  {
363
384
  name: 'grill',
364
385
  description: 'Launch Socratic grilling and decision tree frontier exploration',
386
+ usage: '/grill [--objective <text>]',
365
387
  template: [
366
388
  'Launch Socratic grilling on the decision frontier.',
367
389
  'Objective: $ARGUMENTS (may be empty to inspect the current frontier)',
@@ -372,6 +394,7 @@ export const OPENCODE_COMMANDS = [
372
394
  {
373
395
  name: 'swarm-config',
374
396
  description: 'Inspect or update Epistemic Swarm parameters (engine, depth, mode)',
397
+ usage: '/swarm-config [--engine <e>] [--depth <d>] [--mode <m>] [--show]',
375
398
  template: [
376
399
  'Inspect or update the Epistemic Swarm configuration.',
377
400
  'Arguments: $ARGUMENTS (may be empty to inspect current settings)',
@@ -381,9 +404,11 @@ export const OPENCODE_COMMANDS = [
381
404
  {
382
405
  name: 'audit',
383
406
  description: 'Run dialectic codebase architectural and security audit with line-level proof',
407
+ usage: '/audit <target_path_or_scope>',
384
408
  template: [
385
409
  'Run an IUMBTEMS dialectic codebase audit (structural architect vs adversarial red-teamer).',
386
410
  'Target: $ARGUMENTS (path, component, or empty for full-repository architecture and vulnerability audit)',
411
+ 'If $ARGUMENTS names a path that does not exist, ask the user to clarify the target first; never audit a placeholder path.',
387
412
  '1. Execute iumbtems_code_audit with {"target": "<target>"} (pass "mock_mode": true only for dry runs).',
388
413
  '2. Summarize .research/code_audit_report.md with line-level proof pointers.',
389
414
  ].join('\n'),
@@ -391,9 +416,11 @@ export const OPENCODE_COMMANDS = [
391
416
  {
392
417
  name: 'scout',
393
418
  description: 'Scout open-source libraries, audit copyleft licenses, generate clean-room blueprints',
419
+ usage: '/scout <feature_or_algorithm>',
394
420
  template: [
395
421
  'Scout open-source solutions for the requested capability.',
396
422
  'Feature: $ARGUMENTS',
423
+ 'If $ARGUMENTS is empty, ask the user for the feature or algorithm first; never call the tool with placeholder, empty, or literal "<feature>" arguments.',
397
424
  '1. Execute iumbtems_oss_scout with {"feature": "<feature>"} (pass "mock_mode": true only for dry runs).',
398
425
  '2. Report mature candidates, GPL/AGPL copyleft risks, and the clean-room blueprint in .research/oss_scout_report.md.',
399
426
  ].join('\n'),
@@ -401,6 +428,7 @@ export const OPENCODE_COMMANDS = [
401
428
  {
402
429
  name: 'brainstorming',
403
430
  description: 'Lateral brainstorming: novel feature vectors, paradigm inversions, falsifiable spikes',
431
+ usage: '/brainstorming <ambiguous-prompt>',
404
432
  template: [
405
433
  'Run lateral brainstorming (divergent what-if ideation, never bug-fix lists).',
406
434
  'Prompt: $ARGUMENTS (defaults to "Where do we go from here?" when empty)',
@@ -411,6 +439,7 @@ export const OPENCODE_COMMANDS = [
411
439
  {
412
440
  name: 'brainstorm',
413
441
  description: 'Alias for /brainstorming',
442
+ usage: '/brainstorm <ambiguous-prompt>',
414
443
  template: [
415
444
  'Alias for /brainstorming: run lateral brainstorming (divergent what-if ideation, never bug-fix lists).',
416
445
  'Prompt: $ARGUMENTS (defaults to "Where do we go from here?" when empty)',
@@ -640,7 +669,7 @@ export function createOpenCodePlugin(context = {}) {
640
669
  updates.divergence_threshold = opts.divergence_threshold;
641
670
  }
642
671
  if (Object.keys(updates).length > 0) {
643
- callMcp('iumbtems_config', updates, appContext?.cwd);
672
+ await callMcp('iumbtems_config', updates, appContext?.cwd);
644
673
  }
645
674
  }
646
675
  // Phase C: idle staleness nudge (opt out with {staleness_nudge: false}).
@@ -15,6 +15,7 @@ import { readFileSync, readdirSync, statSync } from 'fs';
15
15
  import path from 'path';
16
16
  import { createElement, insert, setProp } from '@opentui/solid';
17
17
  import { createEffect, createSignal, onCleanup } from 'solid-js';
18
+ import { OPENCODE_COMMANDS } from './index.js';
18
19
 
19
20
  const PLUGIN_ID = 'heretek.iumbtems.epistemic-swarm.tui';
20
21
  const REFRESH_MS = 5000;
@@ -172,26 +173,47 @@ function SwarmSidebar(api, root, sessionID) {
172
173
  }
173
174
 
174
175
  function SwarmKeymapLayer(api) {
176
+ const toast = (title, message) => {
177
+ try {
178
+ api.ui.toast.show({ title, message, variant: 'info', duration: 4000 });
179
+ } catch {
180
+ /* toast is best-effort */
181
+ }
182
+ };
183
+ const commands = [
184
+ {
185
+ id: 'iumbtems.swarm-status',
186
+ title: 'Swarm status',
187
+ description: 'Show IUMBTEMS workspace status (mode, reports, frontier, STALE claims)',
188
+ group: 'IUMBTEMS',
189
+ palette: true,
190
+ run: () => {
191
+ const root = resolveRoot(api);
192
+ toast('IUMBTEMS', summarize(readSwarmStatus(root)));
193
+ },
194
+ },
195
+ ];
196
+ // Every slash command gets a palette entry (usage toast; the host has no
197
+ // programmatic slash-invoke API, so discovery + usage guidance is the win).
198
+ for (const cmd of OPENCODE_COMMANDS || []) {
199
+ if (!cmd || !cmd.name) continue;
200
+ commands.push({
201
+ id: `iumbtems.command.${cmd.name}`,
202
+ title: `/${cmd.name}`,
203
+ description: cmd.description || cmd.name,
204
+ group: 'IUMBTEMS',
205
+ palette: true,
206
+ run: () => {
207
+ toast(
208
+ `IUMBTEMS /${cmd.name}`,
209
+ `${cmd.usage || cmd.description || ''} — type /${cmd.name} in the prompt to run.`
210
+ );
211
+ },
212
+ });
213
+ }
175
214
  api.keymap.layer(() => ({
176
215
  mode: 'global',
177
- commands: [
178
- {
179
- id: 'iumbtems.swarm-status',
180
- title: 'Swarm status',
181
- description: 'Show IUMBTEMS workspace status (mode, reports, frontier, STALE claims)',
182
- group: 'IUMBTEMS',
183
- palette: true,
184
- run: () => {
185
- const root = resolveRoot(api);
186
- api.ui.toast.show({
187
- title: 'IUMBTEMS',
188
- message: summarize(readSwarmStatus(root)),
189
- variant: 'info',
190
- duration: 4000,
191
- });
192
- },
193
- },
194
- ],
216
+ commands,
195
217
  }));
196
218
  return null;
197
219
  }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """CI-safe tests for the Bet-1 advisory spike harness (no LLM, no network)."""
3
+
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
12
+ if str(PROJECT_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(PROJECT_ROOT))
14
+
15
+ from scripts.bet1_advisory_spike import ( # noqa: E402
16
+ advisory_comment,
17
+ generate,
18
+ score_fixture,
19
+ )
20
+
21
+
22
+ class TestBet1Harness(unittest.TestCase):
23
+ def test_generate_counts_and_layout(self):
24
+ with tempfile.TemporaryDirectory() as tmp:
25
+ manifest = generate(Path(tmp))
26
+ self.assertEqual(len(manifest), 20)
27
+ defective = [e for e in manifest if not e["clean"]]
28
+ clean = [e for e in manifest if e["clean"]]
29
+ self.assertEqual(len(defective), 16)
30
+ self.assertEqual(len(clean), 4)
31
+ for entry in manifest:
32
+ self.assertTrue((Path(tmp) / entry["id"] / entry["file"]).exists())
33
+
34
+ def test_generate_deterministic(self):
35
+ with tempfile.TemporaryDirectory() as t1, tempfile.TemporaryDirectory() as t2:
36
+ m1 = generate(Path(t1))
37
+ m2 = generate(Path(t2))
38
+ self.assertEqual(m1, m2)
39
+
40
+ def test_scorer_hit_and_tolerance(self):
41
+ entry = {"id": "x", "file": "users.py", "line": 6, "clean": False}
42
+ self.assertTrue(score_fixture(entry, "see users.py:6 SQLi")["hit"])
43
+ self.assertTrue(score_fixture(entry, "users.py L8 concat")["hit"])
44
+ self.assertFalse(score_fixture(entry, "users.py:40 unrelated")["hit"])
45
+ self.assertFalse(score_fixture(entry, "no file cited")["hit"])
46
+
47
+ def test_scorer_clean_flags_fp(self):
48
+ entry = {"id": "c", "file": "util.py", "line": None, "clean": True}
49
+ self.assertTrue(score_fixture(entry, "issue in util.py:2")["false_positive"])
50
+ self.assertFalse(
51
+ score_fixture(entry, "all clear, nothing found")["false_positive"]
52
+ )
53
+
54
+ def test_advisory_is_non_blocking_shape(self):
55
+ comment = advisory_comment("bet1-01", "findings text")
56
+ self.assertIn("non-blocking", comment)
57
+ self.assertIn("bet1-01", comment)
58
+ self.assertIn("findings text", comment)
59
+
60
+ def test_cli_generate_and_score_exit_zero(self):
61
+ with tempfile.TemporaryDirectory() as tmp:
62
+ for argv in (["--generate", "--dir", tmp], ["--score", "--dir", tmp]):
63
+ res = subprocess.run(
64
+ [
65
+ sys.executable,
66
+ str(PROJECT_ROOT / "scripts" / "bet1_advisory_spike.py"),
67
+ ]
68
+ + argv,
69
+ capture_output=True,
70
+ text=True,
71
+ timeout=120,
72
+ )
73
+ self.assertEqual(res.returncode, 0, res.stderr)
74
+ summary = json.loads(
75
+ subprocess.run(
76
+ [
77
+ sys.executable,
78
+ str(PROJECT_ROOT / "scripts" / "bet1_advisory_spike.py"),
79
+ "--score",
80
+ "--dir",
81
+ tmp,
82
+ ],
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=120,
86
+ ).stdout
87
+ )
88
+ self.assertEqual(summary["fixtures"], 20)
89
+ # No advisories written yet -> all misses, no false positives.
90
+ self.assertEqual(summary["hits"], 0)
91
+ self.assertEqual(summary["false_positives"], 0)
92
+
93
+
94
+ if __name__ == "__main__":
95
+ unittest.main()
@@ -75,6 +75,39 @@ class TestOpenCodeCommandCatalog(unittest.TestCase):
75
75
  self.assertIn(c, data["registered"])
76
76
  self.assertTrue(data["templates"][c])
77
77
 
78
+ def test_empty_arg_guards(self):
79
+ res = run_node(
80
+ """
81
+ import { OPENCODE_COMMANDS } from "./plugins/opencode/index.js";
82
+ const guarded = OPENCODE_COMMANDS.filter(c => /empty/i.test(c.template) && /ask the user/i.test(c.template)).map(c => c.name);
83
+ const withUsage = OPENCODE_COMMANDS.filter(c => !!c.usage).map(c => c.name);
84
+ console.log(JSON.stringify({guarded, withUsage, total: OPENCODE_COMMANDS.length}));
85
+ """
86
+ )
87
+ self.assertEqual(res.returncode, 0, f"guard test failed: {res.stderr}")
88
+ data = last_json_object(res.stdout)
89
+ for c in ["swarm", "scout", "audit"]:
90
+ self.assertIn(c, data["guarded"])
91
+ self.assertEqual(len(data["withUsage"]), data["total"])
92
+
93
+ def test_async_execute_resolves(self):
94
+ res = run_node(
95
+ """
96
+ import plugin from "./plugins/opencode/index.js";
97
+ const shell = await plugin.server();
98
+ let ticked = false;
99
+ const timer = setInterval(() => { ticked = true; }, 5);
100
+ const r = await shell.tool.iumbtems_config.execute({});
101
+ clearInterval(timer);
102
+ console.log(JSON.stringify({status: r.status, hasContent: typeof r.content === "string" && r.content.length > 0, loopAlive: ticked}));
103
+ """
104
+ )
105
+ self.assertEqual(res.returncode, 0, f"async execute test failed: {res.stderr}")
106
+ data = last_json_object(res.stdout)
107
+ self.assertEqual(data["status"], "success")
108
+ self.assertTrue(data["hasContent"])
109
+ self.assertTrue(data["loopAlive"])
110
+
78
111
  def test_registration_never_overwrites(self):
79
112
  res = run_node(
80
113
  """
@@ -180,6 +213,13 @@ class TestInstallOpenCode(unittest.TestCase):
180
213
  cmd = cfg["mcp"]["iumbtems"]["command"]
181
214
  self.assertTrue(Path(cmd[-1]).is_absolute())
182
215
  self.assertEqual(cmd[-1], str(PROJECT_ROOT / "runner" / "mcp_server.py"))
216
+ # Skills merged as absolute repo paths (relative entries repaired).
217
+ skill_paths = cfg.get("skills", {}).get("paths", [])
218
+ self.assertTrue(skill_paths)
219
+ for sp in skill_paths:
220
+ self.assertTrue(Path(sp).is_absolute(), f"relative skill path: {sp}")
221
+ self.assertIn(str(PROJECT_ROOT / "skills" / "grilling"), skill_paths)
222
+ self.assertFalse(any(not Path(sp).is_absolute() for sp in skill_paths))
183
223
  # Second run changes nothing.
184
224
  before = oc_path.read_bytes()
185
225
  second = self._run_install(home)
@@ -285,6 +325,42 @@ class TestOpenCodeTui(unittest.TestCase):
285
325
  self.assertEqual(data["suspect"], 1)
286
326
  self.assertEqual(data["requeued"], 2)
287
327
 
328
+ def test_palette_entries_for_all_commands(self):
329
+ res = run_node(
330
+ """
331
+ import { setupTui } from "./plugins/opencode/tui.js";
332
+ import { OPENCODE_COMMANDS } from "./plugins/opencode/index.js";
333
+ const renders = [];
334
+ let factory = null;
335
+ const toasts = [];
336
+ const mock = {
337
+ theme: {}, directory: process.cwd(),
338
+ ui: { slot: (arg) => { renders.push(arg); return () => {}; }, toast: { show: (t) => toasts.push(t) }, router: { current: () => ({type: "other"}) } },
339
+ keymap: { layer: (fn) => { factory = fn; } }
340
+ };
341
+ setupTui(mock);
342
+ const appSlot = renders.find(r => r && r.append === "app");
343
+ if (!appSlot) throw new Error("app slot not registered");
344
+ appSlot.render();
345
+ const spec = factory();
346
+ const palette = spec.commands.filter(c => c.palette);
347
+ for (const c of palette) c.run();
348
+ console.log(JSON.stringify({
349
+ total: spec.commands.length,
350
+ palette: palette.length,
351
+ groups: [...new Set(palette.map(c => c.group))],
352
+ toasts: toasts.length,
353
+ expected: OPENCODE_COMMANDS.length + 1
354
+ }));
355
+ """
356
+ )
357
+ self.assertEqual(res.returncode, 0, f"palette test failed: {res.stderr}")
358
+ data = last_json_object(res.stdout)
359
+ self.assertEqual(data["total"], data["expected"])
360
+ self.assertEqual(data["palette"], data["expected"])
361
+ self.assertEqual(data["groups"], ["IUMBTEMS"])
362
+ self.assertEqual(data["toasts"], data["expected"])
363
+
288
364
 
289
365
  class TestOpenCodeLifecycle(unittest.TestCase):
290
366
  @staticmethod