@heretek-ai/epistemic-swarm 0.4.0 → 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 +318 -17
  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.0",
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.0",
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.0",
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.0",
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": {
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { spawnSync } from 'child_process';
15
+ import { readFileSync } from 'fs';
15
16
  import path from 'path';
16
17
  import { fileURLToPath } from 'url';
17
18
 
@@ -20,6 +21,15 @@ const __dirname = path.dirname(__filename);
20
21
  const PKG_ROOT = path.resolve(__dirname, '../..');
21
22
  const MCP_SERVER = path.join(PKG_ROOT, 'runner', 'mcp_server.py');
22
23
 
24
+ /** Plugin version tracks package.json so it cannot drift across releases. */
25
+ let PKG_VERSION = '0.0.0';
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf-8'));
28
+ if (pkg && typeof pkg.version === 'string') PKG_VERSION = pkg.version;
29
+ } catch {
30
+ // keep fallback; version is informational only
31
+ }
32
+
23
33
  /** Uniform dispatch: one-shot MCP call, return OpenCode's {content, status}. */
24
34
  function callMcp(tool, args = {}, cwd) {
25
35
  const res = spawnSync(
@@ -224,7 +234,7 @@ const TOOL_CATALOG = [
224
234
  properties: {
225
235
  base_dir: {
226
236
  type: 'string',
227
- description: 'Path to .research workspace',
237
+ description: 'Path to .research workspace (default: current directory)',
228
238
  },
229
239
  },
230
240
  },
@@ -239,7 +249,7 @@ const TOOL_CATALOG = [
239
249
  hash: { type: 'string', description: 'Content-addressed SHA-256 of the affected source' },
240
250
  event: { type: 'string', enum: ['RETRACTED', 'REVISED'] },
241
251
  note: { type: 'string', description: 'Why the source was retracted/revised' },
242
- base_dir: { type: 'string' },
252
+ base_dir: { type: 'string', description: 'Path to .research workspace (default: current directory)' },
243
253
  },
244
254
  required: ['hash', 'event'],
245
255
  },
@@ -251,7 +261,7 @@ const TOOL_CATALOG = [
251
261
  input: {
252
262
  type: 'object',
253
263
  properties: {
254
- base_dir: { type: 'string' },
264
+ base_dir: { type: 'string', description: 'Path to .research workspace (default: current directory)' },
255
265
  scope_ids: {
256
266
  type: 'array',
257
267
  items: { type: 'string' },
@@ -268,7 +278,7 @@ const TOOL_CATALOG = [
268
278
  type: 'object',
269
279
  properties: {
270
280
  pack: { type: 'string', description: 'Pack id: biopharma | quant | legal (or a path)' },
271
- base_dir: { type: 'string' },
281
+ base_dir: { type: 'string', description: 'Path to .research workspace (default: current directory)' },
272
282
  },
273
283
  required: ['pack'],
274
284
  },
@@ -280,11 +290,11 @@ const TOOL_CATALOG = [
280
290
  input: {
281
291
  type: 'object',
282
292
  properties: {
283
- base_dir: { type: 'string' },
293
+ base_dir: { type: 'string', description: 'Path to .research workspace (default: current directory)' },
284
294
  out: { type: 'string', description: 'Output path (default <base_dir>/brief.pcrb.json)' },
285
- objective: { type: 'string' },
295
+ objective: { type: 'string', description: 'Research objective the brief must cover' },
286
296
  key: { type: 'string', description: 'HMAC key (prefer key_file or env IUMBTEMS_PCRB_KEY)' },
287
- key_file: { type: 'string' },
297
+ key_file: { type: 'string', description: 'Path to file containing the HMAC key' },
288
298
  },
289
299
  },
290
300
  },
@@ -296,8 +306,8 @@ const TOOL_CATALOG = [
296
306
  type: 'object',
297
307
  properties: {
298
308
  brief: { type: 'string', description: 'Path to brief.pcrb.json' },
299
- key: { type: 'string' },
300
- 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' },
301
311
  },
302
312
  required: ['brief'],
303
313
  },
@@ -327,21 +337,297 @@ function normalizeArgs(tool, args = {}) {
327
337
  return a;
328
338
  }
329
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
+
330
605
  export function createOpenCodePlugin(context = {}) {
331
606
  return {
332
607
  id: 'heretek.iumbtems.epistemic-swarm',
333
608
  name: 'IUMBTEMS Epistemic Swarm',
334
- version: '0.3.0',
609
+ version: PKG_VERSION,
335
610
  description:
336
611
  'I Use My Brain To Express My Self: High-integrity dialectic research, code audits, and open-source scouting',
337
612
 
338
- server: (toolContext) =>
339
- TOOL_CATALOG.map((tool) => ({
340
- ...tool,
341
- options: { codemode: false },
342
- execute: async (args = {}) =>
343
- callMcp(tool.name, normalizeArgs(tool.name, args), toolContext?.cwd),
344
- })),
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
+ }),
345
631
 
346
632
  setup: async (appContext) => {
347
633
  const opts = context.options || appContext?.options;
@@ -357,6 +643,21 @@ export function createOpenCodePlugin(context = {}) {
357
643
  callMcp('iumbtems_config', updates, appContext?.cwd);
358
644
  }
359
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
+ }
360
661
  return { initialized: true, platform: 'opencode-v2' };
361
662
  },
362
663
  };