@heretek-ai/epistemic-swarm 0.4.1 → 0.4.2

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 (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/install.sh +88 -0
  3. package/package.json +6 -1
  4. package/plugins/antigravity/plugin.json +1 -1
  5. package/plugins/gemini/gemini-extension.json +1 -1
  6. package/plugins/opencode/index.js +307 -16
  7. package/plugins/opencode/tui.js +247 -0
  8. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  21. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  22. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  23. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  24. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  25. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  26. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  27. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  28. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  29. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  32. package/runner/tests/test_opencode_ux.py +393 -0
  33. package/runner/tests/test_swarm.py +19 -3
  34. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  35. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  36. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  37. 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.1",
3
+ "version": "0.4.2",
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/install.sh CHANGED
@@ -109,10 +109,98 @@ except Exception as e:
109
109
  EOF
110
110
  fi
111
111
 
112
+ # 5. OpenCode V2 integration (plugin tuple, agents, MCP server — merge only)
113
+ OPENCODE_JSON="$HOME/.config/opencode/opencode.json"
114
+ echo "🔧 Merging OpenCode V2 configuration into $OPENCODE_JSON..."
115
+ python3 - <<EOF
116
+ import json
117
+ from pathlib import Path
118
+
119
+ repo = Path("$REPO_DIR")
120
+ oc_path = Path("$OPENCODE_JSON")
121
+ PKG = "@heretek-ai/epistemic-swarm"
122
+ OPTION_KEYS = {"search_engine", "max_iterations", "mode", "divergence_threshold"}
123
+
124
+ oc_path.parent.mkdir(parents=True, exist_ok=True)
125
+ cfg = json.loads(oc_path.read_text(encoding="utf-8")) if oc_path.exists() else {}
126
+
127
+ # --- plugin entry: tuple form [package, options]; repair bare-object form ---
128
+ plugins = cfg.get("plugin", [])
129
+ if not isinstance(plugins, list):
130
+ print(" âš ī¸ 'plugin' is not a list; leaving untouched")
131
+ plugins = cfg.get("plugin", [])
132
+ else:
133
+ repaired = []
134
+ i = 0
135
+ changed = False
136
+ while i < len(plugins):
137
+ item = plugins[i]
138
+ # Bare options object following our package string (or standalone):
139
+ # fold into a tuple instead of leaving an invalid entry.
140
+ if isinstance(item, dict) and set(item) & OPTION_KEYS:
141
+ prev_is_pkg = repaired and repaired[-1] == PKG
142
+ prev_is_tuple = (
143
+ repaired and isinstance(repaired[-1], list)
144
+ and repaired[-1] and repaired[-1][0] == PKG
145
+ )
146
+ if prev_is_pkg:
147
+ repaired[-1] = [PKG, item]
148
+ changed = True
149
+ print(" ~ Repaired bare options object into tuple entry")
150
+ elif prev_is_tuple:
151
+ merged = {**(repaired[-1][1] if len(repaired[-1]) > 1 else {}), **item}
152
+ repaired[-1] = [PKG, merged]
153
+ changed = True
154
+ print(" ~ Folded stray options into existing tuple entry")
155
+ else:
156
+ repaired.append([PKG, item])
157
+ changed = True
158
+ print(" ~ Attached stray options object to new tuple entry")
159
+ else:
160
+ repaired.append(item)
161
+ i += 1
162
+ if not any((p == PKG or (isinstance(p, list) and p and p[0] == PKG)) for p in repaired):
163
+ repaired.append([PKG, {"search_engine": "duckduckgo", "max_iterations": 2, "mode": "research"}])
164
+ changed = True
165
+ print(f" + Added plugin entry: {PKG}")
166
+ else:
167
+ print(f" â„šī¸ Plugin entry already configured: {PKG}")
168
+ cfg["plugin"] = repaired
169
+
170
+ # --- agents: merge snippet definitions, never overwrite user agents ---
171
+ try:
172
+ snippet = json.loads((repo / "config" / "opencode-snippet.json").read_text(encoding="utf-8"))
173
+ agents = cfg.setdefault("agent", {})
174
+ for name, definition in snippet.get("agent", {}).items():
175
+ if name not in agents:
176
+ agents[name] = definition
177
+ print(f" + Added agent: {name}")
178
+ else:
179
+ print(f" â„šī¸ Agent already configured: {name}")
180
+ except Exception as e:
181
+ print(f" âš ī¸ Non-critical warning merging agents: {e}")
182
+
183
+ # --- MCP server: absolute runner path (relative paths only work at repo root) ---
184
+ mcp = cfg.setdefault("mcp", {})
185
+ if "iumbtems" not in mcp:
186
+ mcp["iumbtems"] = {
187
+ "type": "local",
188
+ "command": ["python3", str(repo / "runner" / "mcp_server.py")],
189
+ "enabled": True,
190
+ }
191
+ print(" + Added MCP server: iumbtems")
192
+ else:
193
+ print(" â„šī¸ MCP server already configured: iumbtems")
194
+
195
+ oc_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
196
+ print("✅ OpenCode configuration updated.")
197
+ EOF
198
+
112
199
  echo ""
113
200
  echo "🎉 Epistemic Swarm installation complete!"
114
201
  echo " - Interactive Grilling: run /grilling inside Claude Code"
115
202
  echo " - Lateral Brainstorming: run /brainstorming inside Claude Code (skills/brainstorming)"
203
+ echo " - OpenCode V2: /swarm /grill /audit /scout /brainstorming via plugin commands"
116
204
  echo " - Rebuild harness adapters: python3 scripts/build_adapters.py"
117
205
  echo " - Headless Research Swarm: npx @heretek-ai/epistemic-swarm run \"<objective>\""
118
206
  echo "========================================================"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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": {
@@ -11,12 +11,17 @@
11
11
  ".": "./bin/cli.js",
12
12
  "./opencode": "./plugins/opencode/index.js",
13
13
  "./server": "./plugins/opencode/index.js",
14
+ "./tui": "./plugins/opencode/tui.js",
14
15
  "./pi": "./extensions/pi/index.js",
15
16
  "./omp": "./extensions/pi/index.js"
16
17
  },
17
18
  "publishConfig": {
18
19
  "access": "public"
19
20
  },
21
+ "dependencies": {
22
+ "solid-js": "1.9.12",
23
+ "@opentui/solid": ">=0.4.0 <0.6.0"
24
+ },
20
25
  "repository": {
21
26
  "type": "git",
22
27
  "url": "git+https://github.com/Heretek-AI/IUMBTEMS.git"
@@ -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.1",
4
+ "version": "0.4.2",
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.1",
3
+ "version": "0.4.2",
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": {
@@ -234,7 +234,7 @@ const TOOL_CATALOG = [
234
234
  properties: {
235
235
  base_dir: {
236
236
  type: 'string',
237
- description: 'Path to .research workspace',
237
+ description: 'Path to .research workspace (default: current directory)',
238
238
  },
239
239
  },
240
240
  },
@@ -249,7 +249,7 @@ const TOOL_CATALOG = [
249
249
  hash: { type: 'string', description: 'Content-addressed SHA-256 of the affected source' },
250
250
  event: { type: 'string', enum: ['RETRACTED', 'REVISED'] },
251
251
  note: { type: 'string', description: 'Why the source was retracted/revised' },
252
- base_dir: { type: 'string' },
252
+ base_dir: { type: 'string', description: 'Path to .research workspace (default: current directory)' },
253
253
  },
254
254
  required: ['hash', 'event'],
255
255
  },
@@ -261,7 +261,7 @@ const TOOL_CATALOG = [
261
261
  input: {
262
262
  type: 'object',
263
263
  properties: {
264
- base_dir: { type: 'string' },
264
+ base_dir: { type: 'string', description: 'Path to .research workspace (default: current directory)' },
265
265
  scope_ids: {
266
266
  type: 'array',
267
267
  items: { type: 'string' },
@@ -278,7 +278,7 @@ const TOOL_CATALOG = [
278
278
  type: 'object',
279
279
  properties: {
280
280
  pack: { type: 'string', description: 'Pack id: biopharma | quant | legal (or a path)' },
281
- base_dir: { type: 'string' },
281
+ base_dir: { type: 'string', description: 'Path to .research workspace (default: current directory)' },
282
282
  },
283
283
  required: ['pack'],
284
284
  },
@@ -290,11 +290,11 @@ const TOOL_CATALOG = [
290
290
  input: {
291
291
  type: 'object',
292
292
  properties: {
293
- base_dir: { type: 'string' },
293
+ base_dir: { type: 'string', description: 'Path to .research workspace (default: current directory)' },
294
294
  out: { type: 'string', description: 'Output path (default <base_dir>/brief.pcrb.json)' },
295
- objective: { type: 'string' },
295
+ objective: { type: 'string', description: 'Research objective the brief must cover' },
296
296
  key: { type: 'string', description: 'HMAC key (prefer key_file or env IUMBTEMS_PCRB_KEY)' },
297
- key_file: { type: 'string' },
297
+ key_file: { type: 'string', description: 'Path to file containing the HMAC key' },
298
298
  },
299
299
  },
300
300
  },
@@ -306,8 +306,8 @@ const TOOL_CATALOG = [
306
306
  type: 'object',
307
307
  properties: {
308
308
  brief: { type: 'string', description: 'Path to brief.pcrb.json' },
309
- key: { type: 'string' },
310
- key_file: { type: 'string' },
309
+ key: { type: 'string', description: 'HMAC key (prefer key_file or env IUMBTEMS_PCRB_KEY)' },
310
+ key_file: { type: 'string', description: 'Path to file containing the HMAC key' },
311
311
  },
312
312
  required: ['brief'],
313
313
  },
@@ -337,6 +337,271 @@ function normalizeArgs(tool, args = {}) {
337
337
  return a;
338
338
  }
339
339
 
340
+ /**
341
+ * OpenCode slash-command catalog (Phase A discoverability).
342
+ *
343
+ * Mirrors the Pi/OMP/Gemini command surface. Each entry registers into
344
+ * `config.command[name] = {description, template}` via the server `config`
345
+ * hook (same pattern as @prevalentware/opencode-goal-plugin). `$ARGUMENTS`
346
+ * is the raw text after the slash command. Templates instruct the agent to
347
+ * call the corresponding canonical `iumbtems_*` tool — commands never
348
+ * reimplement tool logic.
349
+ */
350
+ export const OPENCODE_COMMANDS = [
351
+ {
352
+ name: 'swarm',
353
+ description: 'Run IUMBTEMS dialectic research swarm on an objective',
354
+ template: [
355
+ 'Run an IUMBTEMS dialectic research swarm.',
356
+ 'Objective: $ARGUMENTS',
357
+ '1. If the objective is ambiguous, frame it first via iumbtems_socratic_frontier.',
358
+ '2. Execute iumbtems_swarm_research with {"objective": "<objective>"} (pass "mock_mode": true only for dry runs).',
359
+ '3. Summarize .research/final_synthesis.md, preserving [VERIFIED:<hash>] pointers.',
360
+ ].join('\n'),
361
+ },
362
+ {
363
+ name: 'grill',
364
+ description: 'Launch Socratic grilling and decision tree frontier exploration',
365
+ template: [
366
+ 'Launch Socratic grilling on the decision frontier.',
367
+ 'Objective: $ARGUMENTS (may be empty to inspect the current frontier)',
368
+ 'Call iumbtems_socratic_frontier with {"objective": "<objective>", "file": ".research/frontier.json"}; omit "objective" to inspect.',
369
+ 'Challenge premises, invert assumptions, and report open frontier nodes.',
370
+ ].join('\n'),
371
+ },
372
+ {
373
+ name: 'swarm-config',
374
+ description: 'Inspect or update Epistemic Swarm parameters (engine, depth, mode)',
375
+ template: [
376
+ 'Inspect or update the Epistemic Swarm configuration.',
377
+ 'Arguments: $ARGUMENTS (may be empty to inspect current settings)',
378
+ 'Call iumbtems_config; map --engine/--depth/--mode/--show style flags to search_engine/max_iterations/mode, or pass through no arguments to inspect.',
379
+ ].join('\n'),
380
+ },
381
+ {
382
+ name: 'audit',
383
+ description: 'Run dialectic codebase architectural and security audit with line-level proof',
384
+ template: [
385
+ 'Run an IUMBTEMS dialectic codebase audit (structural architect vs adversarial red-teamer).',
386
+ 'Target: $ARGUMENTS (path, component, or empty for full-repository architecture and vulnerability audit)',
387
+ '1. Execute iumbtems_code_audit with {"target": "<target>"} (pass "mock_mode": true only for dry runs).',
388
+ '2. Summarize .research/code_audit_report.md with line-level proof pointers.',
389
+ ].join('\n'),
390
+ },
391
+ {
392
+ name: 'scout',
393
+ description: 'Scout open-source libraries, audit copyleft licenses, generate clean-room blueprints',
394
+ template: [
395
+ 'Scout open-source solutions for the requested capability.',
396
+ 'Feature: $ARGUMENTS',
397
+ '1. Execute iumbtems_oss_scout with {"feature": "<feature>"} (pass "mock_mode": true only for dry runs).',
398
+ '2. Report mature candidates, GPL/AGPL copyleft risks, and the clean-room blueprint in .research/oss_scout_report.md.',
399
+ ].join('\n'),
400
+ },
401
+ {
402
+ name: 'brainstorming',
403
+ description: 'Lateral brainstorming: novel feature vectors, paradigm inversions, falsifiable spikes',
404
+ template: [
405
+ 'Run lateral brainstorming (divergent what-if ideation, never bug-fix lists).',
406
+ 'Prompt: $ARGUMENTS (defaults to "Where do we go from here?" when empty)',
407
+ '1. Execute iumbtems_brainstorm with {"objective": "<prompt>"} (pass "mock_mode": true only for dry runs).',
408
+ '2. Report novel feature vectors, paradigm inversions, and falsifiable spikes from .research/brainstorm_report.md.',
409
+ ].join('\n'),
410
+ },
411
+ {
412
+ name: 'brainstorm',
413
+ description: 'Alias for /brainstorming',
414
+ template: [
415
+ 'Alias for /brainstorming: run lateral brainstorming (divergent what-if ideation, never bug-fix lists).',
416
+ 'Prompt: $ARGUMENTS (defaults to "Where do we go from here?" when empty)',
417
+ 'Execute iumbtems_brainstorm with {"objective": "<prompt>"} and report feature vectors, paradigm inversions, and falsifiable spikes.',
418
+ ].join('\n'),
419
+ },
420
+ ];
421
+
422
+ /**
423
+ * Register the command catalog into an OpenCode config object without
424
+ * overwriting user-defined commands of the same name. Exported for tests.
425
+ */
426
+ export function registerOpenCodeCommands(cfg = {}) {
427
+ cfg.command ??= {};
428
+ for (const cmd of OPENCODE_COMMANDS) {
429
+ if (cfg.command[cmd.name]) continue;
430
+ cfg.command[cmd.name] = { description: cmd.description, template: cmd.template };
431
+ }
432
+ return cfg;
433
+ }
434
+
435
+ /** Tool map (object form) for the server hook; array catalog stays canonical. */
436
+ function buildToolMap() {
437
+ return Object.fromEntries(
438
+ TOOL_CATALOG.map((tool) => [
439
+ tool.name,
440
+ {
441
+ ...tool,
442
+ options: { codemode: false },
443
+ execute: async (args = {}, toolContext) =>
444
+ callMcp(tool.name, normalizeArgs(tool.name, args), toolContext?.cwd),
445
+ },
446
+ ])
447
+ );
448
+ }
449
+
450
+ /**
451
+ * Phase C retention: session lifecycle hooks.
452
+ *
453
+ * Reference pattern: @prevalentware/opencode-goal-plugin subscribes via
454
+ * `context.event.subscribe({signal})`, detects idle as `session.idle` or an
455
+ * idle `session.status`, continues via `context.session.prompt`, and preserves
456
+ * state across compaction with the `experimental.session.compacting` hook.
457
+ * Every hook here is best-effort and never throws into the host.
458
+ */
459
+
460
+ /** Idle detection matching the reference implementation. Exported for tests. */
461
+ export function isIdleEvent(event) {
462
+ if (!event || typeof event.type !== 'string') return false;
463
+ if (event.type === 'session.idle') return true;
464
+ const status = event.properties?.status;
465
+ return (
466
+ event.type === 'session.status' &&
467
+ typeof status === 'object' &&
468
+ status !== null &&
469
+ status.type === 'idle'
470
+ );
471
+ }
472
+
473
+ function readJsonFile(file) {
474
+ try {
475
+ return JSON.parse(readFileSync(file, 'utf-8'));
476
+ } catch {
477
+ return undefined;
478
+ }
479
+ }
480
+
481
+ /** Last-write-wins degraded claims + requeue list. Pure fs reads. */
482
+ export function readLedgerDegraded(root) {
483
+ const out = { stale: [], suspect: [], requeued: [], fingerprint: 'empty' };
484
+ try {
485
+ const ledger = readJsonFile(path.join(root, '.research', 'ledger', 'claim_status.json'));
486
+ if (Array.isArray(ledger)) {
487
+ const last = new Map();
488
+ for (const e of ledger) {
489
+ if (e && typeof e.claim_id === 'string') last.set(e.claim_id, e.to_status);
490
+ }
491
+ for (const [id, s] of last) {
492
+ if (s === 'STALE') out.stale.push(id);
493
+ else if (s === 'SUSPECT') out.suspect.push(id);
494
+ }
495
+ const lastAt = ledger.length > 0 ? ledger[ledger.length - 1]?.at : '';
496
+ out.fingerprint = `${ledger.length}:${lastAt}`;
497
+ }
498
+ const requeue = readJsonFile(path.join(root, '.research', 'requeue.json'));
499
+ if (Array.isArray(requeue)) {
500
+ out.requeued = requeue.map((e) => e?.scope_id).filter((s) => typeof s === 'string');
501
+ }
502
+ } catch {
503
+ /* unreadable workspace -> no degraded claims */
504
+ }
505
+ return out;
506
+ }
507
+
508
+ /**
509
+ * Compaction context preserving .research state. Returns null when there is
510
+ * no workspace (hook then pushes nothing).
511
+ */
512
+ export function buildCompactionContext(root) {
513
+ try {
514
+ const cfg = readJsonFile(path.join(root, '.research', 'config.json'));
515
+ if (!cfg || typeof cfg !== 'object') return null;
516
+ const degraded = readLedgerDegraded(root);
517
+ const lines = [
518
+ 'IUMBTEMS Epistemic Swarm workspace state (preserved across compaction):',
519
+ `- mode=${cfg.mode ?? '?'} engine=${cfg.search_engine ?? '?'} depth=${cfg.max_iterations ?? '?'}`,
520
+ `- degraded claims: ${degraded.stale.length} STALE, ${degraded.suspect.length} SUSPECT`,
521
+ ];
522
+ if (degraded.stale.length > 0) {
523
+ lines.push(`- STALE claim ids: ${degraded.stale.slice(0, 10).join(', ')}`);
524
+ }
525
+ if (degraded.requeued.length > 0) {
526
+ lines.push(`- scopes queued for re-run: ${degraded.requeued.slice(0, 10).join(', ')}`);
527
+ }
528
+ lines.push('- Continue with iumbtems_check_staleness when resuming audit work.');
529
+ return lines.join('\n');
530
+ } catch {
531
+ return null;
532
+ }
533
+ }
534
+
535
+ const NUDGE_MIN_INTERVAL_MS = 5 * 60 * 1000;
536
+ const activeNudges = new Map();
537
+
538
+ /**
539
+ * Handle one subscribed event: nudge once per ledger change when degraded
540
+ * claims exist. Returns true when a nudge was sent. Exported for tests.
541
+ */
542
+ export function handleIdleEvent(host, root, state, event) {
543
+ try {
544
+ if (!isIdleEvent(event)) return false;
545
+ const sessionID = event?.properties?.sessionID ?? event?.sessionID;
546
+ if (typeof sessionID !== 'string' || !sessionID) return false;
547
+ const now = Date.now();
548
+ if (now - (state.lastNudgeAt || 0) < NUDGE_MIN_INTERVAL_MS) return false;
549
+ const degraded = readLedgerDegraded(root);
550
+ const total = degraded.stale.length + degraded.suspect.length;
551
+ if (total === 0) return false;
552
+ if (state.lastFingerprint === degraded.fingerprint) return false;
553
+ state.lastFingerprint = degraded.fingerprint;
554
+ state.lastNudgeAt = now;
555
+ const example = degraded.stale.slice(0, 5).join(', ');
556
+ Promise.resolve(
557
+ host.session.prompt({
558
+ sessionID,
559
+ text:
560
+ `IUMBTEMS staleness nudge: ${total} degraded claim(s) ` +
561
+ `(${degraded.stale.length} STALE, ${degraded.suspect.length} SUSPECT) in .research/ledger/claim_status.json` +
562
+ (example ? `, e.g. ${example}` : '') +
563
+ '. Run iumbtems_check_staleness and re-audit affected scopes, or reply that it is already handled.',
564
+ })
565
+ ).catch(() => {});
566
+ return true;
567
+ } catch {
568
+ return false;
569
+ }
570
+ }
571
+
572
+ /** Subscribe to session events for idle nudges. One subscription per root. */
573
+ export function startStalenessNudge(host, root) {
574
+ try {
575
+ if (!host || typeof host.event?.subscribe !== 'function') return null;
576
+ if (typeof host.session?.prompt !== 'function') return null;
577
+ if (activeNudges.has(root)) return activeNudges.get(root).stop;
578
+ const abort = new AbortController();
579
+ const state = { lastFingerprint: null, lastNudgeAt: 0 };
580
+ const stop = () => {
581
+ try { abort.abort(); } catch { /* noop */ }
582
+ activeNudges.delete(root);
583
+ };
584
+ activeNudges.set(root, { stop });
585
+ (async () => {
586
+ try {
587
+ const sub = host.event.subscribe({ signal: abort.signal });
588
+ const it = sub[Symbol.asyncIterator]();
589
+ while (true) {
590
+ const { value, done } = await it.next();
591
+ if (done) break;
592
+ handleIdleEvent(host, root, state, value);
593
+ }
594
+ } catch {
595
+ /* aborted or host closed the stream */
596
+ }
597
+ activeNudges.delete(root);
598
+ })();
599
+ return stop;
600
+ } catch {
601
+ return null;
602
+ }
603
+ }
604
+
340
605
  export function createOpenCodePlugin(context = {}) {
341
606
  return {
342
607
  id: 'heretek.iumbtems.epistemic-swarm',
@@ -345,13 +610,24 @@ export function createOpenCodePlugin(context = {}) {
345
610
  description:
346
611
  'I Use My Brain To Express My Self: High-integrity dialectic research, code audits, and open-source scouting',
347
612
 
348
- server: (toolContext) =>
349
- TOOL_CATALOG.map((tool) => ({
350
- ...tool,
351
- options: { codemode: false },
352
- execute: async (args = {}) =>
353
- callMcp(tool.name, normalizeArgs(tool.name, args), toolContext?.cwd),
354
- })),
613
+ server: async () => ({
614
+ // Config hook: slash-command catalog (mirrors the goal plugin's
615
+ // registerDesktopCommands pattern; never overwrites user commands).
616
+ config: (cfg) => registerOpenCodeCommands(cfg),
617
+ // Tool map (object form, as in the reference implementation).
618
+ tool: buildToolMap(),
619
+ // Compaction hook: preserve .research state (best-effort, silent skip
620
+ // when no workspace is present so compaction never breaks).
621
+ 'experimental.session.compacting': async (input, output) => {
622
+ try {
623
+ const root = input?.directory ?? input?.location?.directory ?? process.cwd();
624
+ const ctx = buildCompactionContext(root);
625
+ if (ctx && output && Array.isArray(output.context)) output.context.push(ctx);
626
+ } catch {
627
+ /* never break compaction */
628
+ }
629
+ },
630
+ }),
355
631
 
356
632
  setup: async (appContext) => {
357
633
  const opts = context.options || appContext?.options;
@@ -367,6 +643,21 @@ export function createOpenCodePlugin(context = {}) {
367
643
  callMcp('iumbtems_config', updates, appContext?.cwd);
368
644
  }
369
645
  }
646
+ // Phase C: idle staleness nudge (opt out with {staleness_nudge: false}).
647
+ // Subscribes only when the host exposes event/session; one per root.
648
+ try {
649
+ if ((opts?.staleness_nudge ?? true)) {
650
+ const host = {
651
+ event: appContext?.event || context.event,
652
+ session: appContext?.session || context.session,
653
+ };
654
+ const root =
655
+ appContext?.location?.directory || context.location?.directory;
656
+ if (root) startStalenessNudge(host, root);
657
+ }
658
+ } catch {
659
+ /* lifecycle hooks are best-effort */
660
+ }
370
661
  return { initialized: true, platform: 'opencode-v2' };
371
662
  },
372
663
  };
@@ -0,0 +1,247 @@
1
+ /**
2
+ * IUMBTEMS Epistemic Swarm — OpenCode TUI surface (Phase B visibility).
3
+ *
4
+ * Sidebar status panel + command-palette entry. Read-only: pure `fs` reads of
5
+ * the `.research` workspace (config, report inventory, frontier open nodes,
6
+ * claim-status ledger). Never spawns processes, never writes. Every read is
7
+ * defensive — an absent workspace renders as "no swarm state", never throws.
8
+ *
9
+ * Host API mirrors @prevalentware/opencode-goal-plugin's V2 TUI usage:
10
+ * `ui.slot({append, render})`, `ui.toast.show`, `ui.router.current`,
11
+ * `keymap.layer` (inside a component scope), `api.theme`.
12
+ */
13
+
14
+ import { readFileSync, readdirSync, statSync } from 'fs';
15
+ import path from 'path';
16
+ import { createElement, insert, setProp } from '@opentui/solid';
17
+ import { createEffect, createSignal, onCleanup } from 'solid-js';
18
+
19
+ const PLUGIN_ID = 'heretek.iumbtems.epistemic-swarm.tui';
20
+ const REFRESH_MS = 5000;
21
+
22
+ function element(tag, props = {}, children = []) {
23
+ const node = createElement(tag);
24
+ for (const [key, value] of Object.entries(props)) {
25
+ if (value !== undefined) setProp(node, key, value);
26
+ }
27
+ for (const child of children) {
28
+ if (child !== null && child !== undefined && child !== false) insert(node, child);
29
+ }
30
+ return node;
31
+ }
32
+
33
+ function text(props, children) {
34
+ return element('text', props, children);
35
+ }
36
+
37
+ function box(props, children = []) {
38
+ return element('box', props, children);
39
+ }
40
+
41
+ function readJson(file) {
42
+ try {
43
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
44
+ return parsed;
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Snapshot the workspace status. Pure fs reads; safe on missing workspace.
52
+ * Exported for tests.
53
+ */
54
+ export function readSwarmStatus(root) {
55
+ const research = path.join(root || process.cwd(), '.research');
56
+ const status = {
57
+ workspace: false,
58
+ mode: null,
59
+ searchEngine: null,
60
+ maxIterations: null,
61
+ reports: [],
62
+ frontierOpen: null,
63
+ stale: 0,
64
+ suspect: 0,
65
+ requeued: 0,
66
+ };
67
+ let entries = [];
68
+ try {
69
+ entries = readdirSync(research);
70
+ status.workspace = true;
71
+ } catch {
72
+ return status;
73
+ }
74
+ const cfg = readJson(path.join(research, 'config.json'));
75
+ if (cfg && typeof cfg === 'object') {
76
+ status.mode = cfg.mode ?? null;
77
+ status.searchEngine = cfg.search_engine ?? null;
78
+ status.maxIterations = cfg.max_iterations ?? null;
79
+ }
80
+ for (const name of [
81
+ 'final_synthesis.md',
82
+ 'brainstorm_report.md',
83
+ 'code_audit_report.md',
84
+ 'oss_scout_report.md',
85
+ ]) {
86
+ try {
87
+ const st = statSync(path.join(research, name));
88
+ status.reports.push({ name, mtimeMs: st.mtimeMs });
89
+ } catch {
90
+ /* absent report */
91
+ }
92
+ }
93
+ const frontier = readJson(path.join(research, 'frontier.json'));
94
+ if (frontier && typeof frontier === 'object') {
95
+ const nodes = Array.isArray(frontier.nodes) ? frontier.nodes : Object.values(frontier.nodes || {});
96
+ const open = nodes.filter((n) => n && n.status !== 'settled' && n.status !== 'closed');
97
+ status.frontierOpen = open.length;
98
+ }
99
+ const ledger = readJson(path.join(research, 'ledger', 'claim_status.json'));
100
+ if (Array.isArray(ledger)) {
101
+ const last = new Map();
102
+ for (const e of ledger) {
103
+ if (e && typeof e.claim_id === 'string') last.set(e.claim_id, e.to_status);
104
+ }
105
+ for (const s of last.values()) {
106
+ if (s === 'STALE') status.stale += 1;
107
+ else if (s === 'SUSPECT') status.suspect += 1;
108
+ }
109
+ }
110
+ const requeue = readJson(path.join(research, 'requeue.json'));
111
+ if (Array.isArray(requeue)) status.requeued = requeue.length;
112
+ return status;
113
+ }
114
+
115
+ function themeFg(theme) {
116
+ try {
117
+ const t = theme ?? {};
118
+ const pick = (...paths) => {
119
+ for (const p of paths) {
120
+ let cur = t;
121
+ for (const k of p) {
122
+ if (cur === null || typeof cur !== 'object') { cur = undefined; break; }
123
+ cur = cur[k];
124
+ }
125
+ if (cur && typeof cur === 'object' && 'default' in cur) cur = cur.default;
126
+ if (cur !== undefined && cur !== null) return cur;
127
+ }
128
+ return undefined;
129
+ };
130
+ return {
131
+ text: pick(['text', 'default'], ['text']),
132
+ muted: pick(['text', 'subdued'], ['textMuted']),
133
+ warn: pick(['text', 'feedback', 'warning'], ['warning']),
134
+ };
135
+ } catch {
136
+ return {};
137
+ }
138
+ }
139
+
140
+ function summarize(status) {
141
+ if (!status.workspace) return 'IUMBTEMS: no swarm state';
142
+ const bits = [];
143
+ if (status.mode) bits.push(status.mode);
144
+ bits.push(`${status.reports.length} reports`);
145
+ if (status.frontierOpen !== null) bits.push(`${status.frontierOpen} open frontier`);
146
+ if (status.stale > 0 || status.suspect > 0) bits.push(`${status.stale} STALE/${status.suspect} SUSPECT`);
147
+ if (status.requeued > 0) bits.push(`${status.requeued} requeued`);
148
+ return `IUMBTEMS: ${bits.join(' ¡ ')}`;
149
+ }
150
+
151
+ function SwarmSidebar(api, root, sessionID) {
152
+ const colors = themeFg(api?.theme);
153
+ const [snapshot, setSnapshot] = createSignal(readSwarmStatus(root));
154
+ createEffect(() => {
155
+ const timer = setInterval(() => {
156
+ try {
157
+ setSnapshot(readSwarmStatus(root));
158
+ } catch {
159
+ /* keep last snapshot */
160
+ }
161
+ }, REFRESH_MS);
162
+ onCleanup(() => clearInterval(timer));
163
+ });
164
+ void sessionID;
165
+ return box({}, [
166
+ () => {
167
+ const s = snapshot();
168
+ const degraded = s.stale > 0 || s.suspect > 0;
169
+ return text({ fg: degraded ? colors.warn : colors.muted }, [summarize(s)]);
170
+ },
171
+ ]);
172
+ }
173
+
174
+ function SwarmKeymapLayer(api) {
175
+ api.keymap.layer(() => ({
176
+ 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
+ ],
195
+ }));
196
+ return null;
197
+ }
198
+
199
+ function resolveRoot(api) {
200
+ try {
201
+ return (
202
+ api?.directory ??
203
+ api?.project?.directory ??
204
+ api?.cwd ??
205
+ process.cwd()
206
+ );
207
+ } catch {
208
+ return process.cwd();
209
+ }
210
+ }
211
+
212
+ function registerSlot(api, name, render) {
213
+ const slot = api?.ui?.slot;
214
+ if (typeof slot !== 'function') return () => {};
215
+ try {
216
+ const dispose =
217
+ slot.length <= 1 ? slot({ append: name, render }) : slot(name, render);
218
+ return typeof dispose === 'function' ? dispose : () => {};
219
+ } catch {
220
+ return () => {};
221
+ }
222
+ }
223
+
224
+ /** TUI setup: sidebar slot + palette command. Never throws. */
225
+ export function setupTui(api) {
226
+ try {
227
+ const root = resolveRoot(api);
228
+ const offSidebar = registerSlot(api, 'sidebar.content', (props) =>
229
+ SwarmSidebar(api, root, props?.sessionID)
230
+ );
231
+ const offApp = registerSlot(api, 'app', () => SwarmKeymapLayer(api));
232
+ return () => {
233
+ try { offSidebar(); } catch { /* noop */ }
234
+ try { offApp(); } catch { /* noop */ }
235
+ };
236
+ } catch {
237
+ return () => {};
238
+ }
239
+ }
240
+
241
+ const plugin = {
242
+ id: PLUGIN_ID,
243
+ tui: true,
244
+ setup: setupTui,
245
+ };
246
+
247
+ export default plugin;
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for the OpenCode v2 end-user UX surface (goal Phase A).
3
+
4
+ Covers the slash-command catalog, the server object-form contract, and
5
+ install.sh OpenCode handling (idempotency + bare-object repair).
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import unittest
14
+ from pathlib import Path
15
+
16
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
17
+ if str(PROJECT_ROOT) not in sys.path:
18
+ sys.path.insert(0, str(PROJECT_ROOT))
19
+
20
+ EXPECTED_COMMANDS = [
21
+ "swarm",
22
+ "grill",
23
+ "swarm-config",
24
+ "audit",
25
+ "scout",
26
+ "brainstorming",
27
+ "brainstorm",
28
+ ]
29
+
30
+
31
+ def run_node(code):
32
+ return subprocess.run(
33
+ ["node", "--input-type=module", "-e", code],
34
+ capture_output=True,
35
+ text=True,
36
+ cwd=str(PROJECT_ROOT),
37
+ )
38
+
39
+
40
+ def last_json_object(stdout):
41
+ lines = [l.strip() for l in stdout.strip().split("\n") if l.strip().startswith("{")]
42
+ return json.loads(lines[-1])
43
+
44
+
45
+ class TestOpenCodeCommandCatalog(unittest.TestCase):
46
+ def test_catalog_shape_and_registration(self):
47
+ res = run_node(
48
+ """
49
+ import plugin, { OPENCODE_COMMANDS, registerOpenCodeCommands } from "./plugins/opencode/index.js";
50
+ const shell = await plugin.server();
51
+ const cfg = {};
52
+ shell.config(cfg);
53
+ console.log(JSON.stringify({
54
+ catalog: OPENCODE_COMMANDS.map(c => ({name: c.name, hasDesc: !!c.description, hasArgs: c.template.includes("$ARGUMENTS")})),
55
+ registered: Object.keys(cfg.command || {}),
56
+ templates: Object.fromEntries(Object.entries(cfg.command || {}).map(([k, v]) => [k, !!v.description && !!v.template]))
57
+ }));
58
+ """
59
+ )
60
+ self.assertEqual(
61
+ res.returncode, 0, f"command catalog test failed: {res.stderr}"
62
+ )
63
+ data = last_json_object(res.stdout)
64
+ names = [c["name"] for c in data["catalog"]]
65
+ for c in EXPECTED_COMMANDS:
66
+ self.assertIn(c, names)
67
+ for entry in data["catalog"]:
68
+ self.assertTrue(
69
+ entry["hasDesc"], f"command lacks description: {entry['name']}"
70
+ )
71
+ self.assertTrue(
72
+ entry["hasArgs"], f"template lacks $ARGUMENTS: {entry['name']}"
73
+ )
74
+ for c in EXPECTED_COMMANDS:
75
+ self.assertIn(c, data["registered"])
76
+ self.assertTrue(data["templates"][c])
77
+
78
+ def test_registration_never_overwrites(self):
79
+ res = run_node(
80
+ """
81
+ import { registerOpenCodeCommands } from "./plugins/opencode/index.js";
82
+ const cfg = {command: {swarm: {description: "mine", template: "mine"}}};
83
+ registerOpenCodeCommands(cfg);
84
+ console.log(JSON.stringify({swarm: cfg.command.swarm, count: Object.keys(cfg.command).length}));
85
+ """
86
+ )
87
+ self.assertEqual(res.returncode, 0, f"no-overwrite test failed: {res.stderr}")
88
+ data = last_json_object(res.stdout)
89
+ self.assertEqual(data["swarm"], {"description": "mine", "template": "mine"})
90
+ self.assertEqual(data["count"], len(EXPECTED_COMMANDS))
91
+
92
+ def test_tool_map_shape(self):
93
+ res = run_node(
94
+ """
95
+ import plugin, { IUMBTEMS_TOOL_NAMES } from "./plugins/opencode/index.js";
96
+ const shell = await plugin.server();
97
+ const entries = Object.entries(shell.tool);
98
+ console.log(JSON.stringify({
99
+ keys: entries.map(([k]) => k),
100
+ canonical: IUMBTEMS_TOOL_NAMES,
101
+ ok: entries.map(([k, t]) => (!!t.description && !!t.input && typeof t.execute === "function" && t.options && t.options.codemode === false))
102
+ }));
103
+ """
104
+ )
105
+ self.assertEqual(res.returncode, 0, f"tool map test failed: {res.stderr}")
106
+ data = last_json_object(res.stdout)
107
+ self.assertEqual(sorted(data["keys"]), sorted(data["canonical"]))
108
+ self.assertEqual(len(data["keys"]), 13)
109
+ self.assertTrue(all(data["ok"]))
110
+
111
+
112
+ class TestInstallOpenCode(unittest.TestCase):
113
+ def _run_install(self, home):
114
+ env = dict(os.environ, HOME=str(home))
115
+ return subprocess.run(
116
+ ["bash", str(PROJECT_ROOT / "install.sh")],
117
+ capture_output=True,
118
+ text=True,
119
+ cwd=str(PROJECT_ROOT),
120
+ env=env,
121
+ )
122
+
123
+ def _seed_malformed(self, home):
124
+ oc = home / ".config" / "opencode"
125
+ oc.mkdir(parents=True, exist_ok=True)
126
+ # Exact malformation seen in the wild: bare options object after the string.
127
+ oc.joinpath("opencode.json").write_text(
128
+ json.dumps(
129
+ {
130
+ "$schema": "https://opencode.ai/config.json",
131
+ "plugin": [
132
+ "@prevalentware/opencode-goal-plugin",
133
+ "@heretek-ai/epistemic-swarm",
134
+ {
135
+ "search_engine": "duckduckgo",
136
+ "max_iterations": 10,
137
+ "mode": "research",
138
+ },
139
+ ],
140
+ }
141
+ ),
142
+ encoding="utf-8",
143
+ )
144
+
145
+ def test_idempotent_and_repairs_bare_object(self):
146
+ with tempfile.TemporaryDirectory() as tmp:
147
+ home = Path(tmp)
148
+ self._seed_malformed(home)
149
+ first = self._run_install(home)
150
+ self.assertEqual(first.returncode, 0, f"install failed: {first.stderr}")
151
+ oc_path = home / ".config" / "opencode" / "opencode.json"
152
+ cfg = json.loads(oc_path.read_text(encoding="utf-8"))
153
+ # Bare object repaired into tuple form, preserving user options.
154
+ tuples = [
155
+ p
156
+ for p in cfg["plugin"]
157
+ if isinstance(p, list) and p and p[0] == "@heretek-ai/epistemic-swarm"
158
+ ]
159
+ self.assertEqual(len(tuples), 1)
160
+ self.assertEqual(
161
+ tuples[0][1],
162
+ {
163
+ "search_engine": "duckduckgo",
164
+ "max_iterations": 10,
165
+ "mode": "research",
166
+ },
167
+ )
168
+ self.assertFalse(any(isinstance(p, dict) for p in cfg["plugin"]))
169
+ # Agents merged from the snippet, MCP server absolute.
170
+ for agent in [
171
+ "code-auditor",
172
+ "oss-scout",
173
+ "alpha-thesis",
174
+ "beta-redteam",
175
+ "epistemic-auditor",
176
+ "brainstormer",
177
+ ]:
178
+ self.assertIn(agent, cfg.get("agent", {}))
179
+ self.assertIn("iumbtems", cfg.get("mcp", {}))
180
+ cmd = cfg["mcp"]["iumbtems"]["command"]
181
+ self.assertTrue(Path(cmd[-1]).is_absolute())
182
+ self.assertEqual(cmd[-1], str(PROJECT_ROOT / "runner" / "mcp_server.py"))
183
+ # Second run changes nothing.
184
+ before = oc_path.read_bytes()
185
+ second = self._run_install(home)
186
+ self.assertEqual(second.returncode, 0, f"reinstall failed: {second.stderr}")
187
+ self.assertEqual(oc_path.read_bytes(), before)
188
+
189
+
190
+ class TestOpenCodeTui(unittest.TestCase):
191
+ def test_module_load_and_setup(self):
192
+ res = run_node(
193
+ """
194
+ import plugin, { readSwarmStatus, setupTui } from "./plugins/opencode/tui.js";
195
+ const calls = [];
196
+ const mock = {
197
+ theme: {}, directory: "/tmp/oc-tui-empty",
198
+ ui: { slot: (...a) => { calls.push(a.length); return () => {}; }, toast: { show: () => {} }, router: { current: () => ({type: "other"}) } },
199
+ keymap: { layer: () => {} }
200
+ };
201
+ const cleanup = setupTui(mock);
202
+ const empty = readSwarmStatus("/tmp/oc-tui-empty");
203
+ console.log(JSON.stringify({id: plugin.id, hasTui: !!plugin.tui, setupFn: typeof plugin.setup, slots: calls, cleanup: typeof cleanup, emptyWorkspace: empty.workspace}));
204
+ """
205
+ )
206
+ self.assertEqual(res.returncode, 0, f"tui load test failed: {res.stderr}")
207
+ data = last_json_object(res.stdout)
208
+ self.assertEqual(data["id"], "heretek.iumbtems.epistemic-swarm.tui")
209
+ self.assertTrue(data["hasTui"])
210
+ self.assertEqual(data["setupFn"], "function")
211
+ self.assertEqual(data["slots"], [1, 1])
212
+ self.assertEqual(data["cleanup"], "function")
213
+ self.assertFalse(data["emptyWorkspace"])
214
+
215
+ def test_status_snapshot_counts(self):
216
+ with tempfile.TemporaryDirectory() as tmp:
217
+ root = Path(tmp)
218
+ research = root / ".research"
219
+ (research / "ledger").mkdir(parents=True)
220
+ research.joinpath("config.json").write_text(
221
+ json.dumps(
222
+ {
223
+ "mode": "research",
224
+ "search_engine": "duckduckgo",
225
+ "max_iterations": 2,
226
+ }
227
+ ),
228
+ encoding="utf-8",
229
+ )
230
+ research.joinpath("final_synthesis.md").write_text(
231
+ "# synthesis\n", encoding="utf-8"
232
+ )
233
+ research.joinpath("frontier.json").write_text(
234
+ json.dumps({"nodes": [{"status": "open"}, {"status": "settled"}]}),
235
+ encoding="utf-8",
236
+ )
237
+ research.joinpath("ledger", "claim_status.json").write_text(
238
+ json.dumps(
239
+ [
240
+ {
241
+ "scope_id": "s",
242
+ "claim_id": "a",
243
+ "from_status": "UNVERIFIED",
244
+ "to_status": "VERIFIED",
245
+ "reason": "",
246
+ "at": "t1",
247
+ },
248
+ {
249
+ "scope_id": "s",
250
+ "claim_id": "a",
251
+ "from_status": "VERIFIED",
252
+ "to_status": "STALE",
253
+ "reason": "retracted",
254
+ "at": "t2",
255
+ },
256
+ {
257
+ "scope_id": "s",
258
+ "claim_id": "b",
259
+ "from_status": "VERIFIED",
260
+ "to_status": "SUSPECT",
261
+ "reason": "",
262
+ "at": "t3",
263
+ },
264
+ ]
265
+ ),
266
+ encoding="utf-8",
267
+ )
268
+ research.joinpath("requeue.json").write_text(
269
+ json.dumps([{"scope_id": "s1"}, {"scope_id": "s2"}]), encoding="utf-8"
270
+ )
271
+ res = run_node(
272
+ f"""
273
+ import {{ readSwarmStatus }} from "./plugins/opencode/tui.js";
274
+ console.log(JSON.stringify(readSwarmStatus({str(root)!r})));
275
+ """
276
+ )
277
+ self.assertEqual(res.returncode, 0, f"snapshot test failed: {res.stderr}")
278
+ data = last_json_object(res.stdout)
279
+ self.assertTrue(data["workspace"])
280
+ self.assertEqual(data["mode"], "research")
281
+ self.assertEqual(data["searchEngine"], "duckduckgo")
282
+ self.assertEqual(len(data["reports"]), 1)
283
+ self.assertEqual(data["frontierOpen"], 1)
284
+ self.assertEqual(data["stale"], 1)
285
+ self.assertEqual(data["suspect"], 1)
286
+ self.assertEqual(data["requeued"], 2)
287
+
288
+
289
+ class TestOpenCodeLifecycle(unittest.TestCase):
290
+ @staticmethod
291
+ def _fixture(tmp):
292
+ root = Path(tmp)
293
+ research = root / ".research"
294
+ (research / "ledger").mkdir(parents=True)
295
+ research.joinpath("config.json").write_text(
296
+ json.dumps(
297
+ {"mode": "audit", "search_engine": "brave", "max_iterations": 3}
298
+ ),
299
+ encoding="utf-8",
300
+ )
301
+ research.joinpath("ledger", "claim_status.json").write_text(
302
+ json.dumps(
303
+ [
304
+ {
305
+ "scope_id": "s",
306
+ "claim_id": "x1",
307
+ "from_status": "VERIFIED",
308
+ "to_status": "STALE",
309
+ "reason": "retracted",
310
+ "at": "t1",
311
+ },
312
+ ]
313
+ ),
314
+ encoding="utf-8",
315
+ )
316
+ research.joinpath("requeue.json").write_text(
317
+ json.dumps([{"scope_id": "s9"}]), encoding="utf-8"
318
+ )
319
+ return str(root)
320
+
321
+ def test_idle_detection(self):
322
+ res = run_node(
323
+ """
324
+ import { isIdleEvent } from "./plugins/opencode/index.js";
325
+ console.log(JSON.stringify({
326
+ idle: isIdleEvent({type: "session.idle"}),
327
+ statusIdle: isIdleEvent({type: "session.status", properties: {status: {type: "idle"}}}),
328
+ busy: isIdleEvent({type: "session.status", properties: {status: {type: "busy"}}}),
329
+ other: isIdleEvent({type: "message.updated"}),
330
+ empty: isIdleEvent(null)
331
+ }));
332
+ """
333
+ )
334
+ self.assertEqual(res.returncode, 0, f"idle test failed: {res.stderr}")
335
+ data = last_json_object(res.stdout)
336
+ self.assertTrue(data["idle"])
337
+ self.assertTrue(data["statusIdle"])
338
+ self.assertFalse(data["busy"])
339
+ self.assertFalse(data["other"])
340
+ self.assertFalse(data["empty"])
341
+
342
+ def test_compaction_hook_pushes_state(self):
343
+ with tempfile.TemporaryDirectory() as tmp:
344
+ root = self._fixture(tmp)
345
+ res = run_node(
346
+ f"""
347
+ import plugin from "./plugins/opencode/index.js";
348
+ const shell = await plugin.server();
349
+ const hook = shell["experimental.session.compacting"];
350
+ const out = {{context: []}};
351
+ await hook({{directory: {root!r}}}, out);
352
+ const empty = {{context: []}};
353
+ await hook({{directory: "/tmp/oc-lifecycle-empty"}}, empty);
354
+ console.log(JSON.stringify({{hook: typeof hook, pushed: out.context.length, text: out.context[0] || "", skipped: empty.context.length}}));
355
+ """
356
+ )
357
+ self.assertEqual(res.returncode, 0, f"compaction test failed: {res.stderr}")
358
+ data = last_json_object(res.stdout)
359
+ self.assertEqual(data["hook"], "function")
360
+ self.assertEqual(data["pushed"], 1)
361
+ self.assertIn("mode=audit", data["text"])
362
+ self.assertIn("x1", data["text"])
363
+ self.assertIn("s9", data["text"])
364
+ self.assertEqual(data["skipped"], 0)
365
+
366
+ def test_nudge_once_per_ledger_state(self):
367
+ with tempfile.TemporaryDirectory() as tmp:
368
+ root = self._fixture(tmp)
369
+ res = run_node(
370
+ f"""
371
+ import {{ handleIdleEvent }} from "./plugins/opencode/index.js";
372
+ const prompts = [];
373
+ const host = {{session: {{prompt: async (p) => {{ prompts.push(p); return {{}}; }}}}}};
374
+ const state = {{}};
375
+ const evt = {{type: "session.idle", properties: {{sessionID: "ses_1"}}}};
376
+ const first = handleIdleEvent(host, {root!r}, state, evt);
377
+ await new Promise(r => setTimeout(r, 20));
378
+ const second = handleIdleEvent(host, {root!r}, state, evt);
379
+ const busy = handleIdleEvent(host, {root!r}, state, {{type: "message.updated"}});
380
+ console.log(JSON.stringify({{first, second, busy, prompts: prompts.length, sid: prompts[0]?.sessionID || null}}));
381
+ """
382
+ )
383
+ self.assertEqual(res.returncode, 0, f"nudge test failed: {res.stderr}")
384
+ data = last_json_object(res.stdout)
385
+ self.assertTrue(data["first"])
386
+ self.assertFalse(data["second"])
387
+ self.assertFalse(data["busy"])
388
+ self.assertEqual(data["prompts"], 1)
389
+ self.assertEqual(data["sid"], "ses_1")
390
+
391
+
392
+ if __name__ == "__main__":
393
+ unittest.main()
@@ -318,15 +318,20 @@ In our experiments, the 70B parameter model was trained on 15.0 trillion tokens.
318
318
 
319
319
  # 1. Test OpenCode Plugin registration (A2b: thin forwarders + both
320
320
  # name exports — canonical IUMBTEMS_TOOL_NAMES and the deprecated
321
- # IUMBEMS_TOOL_NAMES alias).
321
+ # IUMBEMS_TOOL_NAMES alias). Object form: server() resolves to
322
+ # {config, tool} with the tool map keyed by name.
322
323
  node_code_oc = """
323
324
  import plugin, { IUMBTEMS_TOOL_NAMES, IUMBEMS_TOOL_NAMES } from "./plugins/opencode/index.js";
324
- const tools = plugin.server();
325
+ const shell = await plugin.server();
326
+ const tools = Object.values(shell.tool);
325
327
  const names = tools.map(t => t.name);
328
+ const cfg = {};
329
+ shell.config(cfg);
326
330
  console.log(JSON.stringify({
327
331
  names,
328
332
  canonical: IUMBTEMS_TOOL_NAMES,
329
- alias: IUMBEMS_TOOL_NAMES
333
+ alias: IUMBEMS_TOOL_NAMES,
334
+ commands: Object.keys(cfg.command || {})
330
335
  }));
331
336
  """
332
337
  res_oc = subprocess.run(
@@ -365,6 +370,17 @@ In our experiments, the 70B parameter model was trained on 15.0 trillion tokens.
365
370
  ]
366
371
  for t in expected_tools:
367
372
  self.assertIn(t, tools)
373
+ # Slash-command catalog registered via the server config hook.
374
+ for c in [
375
+ "swarm",
376
+ "grill",
377
+ "swarm-config",
378
+ "audit",
379
+ "scout",
380
+ "brainstorming",
381
+ "brainstorm",
382
+ ]:
383
+ self.assertIn(c, data_oc["commands"])
368
384
 
369
385
  # 2. Test Pi Extension registration
370
386
  node_code_pi = """