@heretek-ai/epistemic-swarm 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.agents/skills/darkharvest/SKILL.md +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.omp/commands/darkharvest.md +11 -0
  4. package/README.md +4 -0
  5. package/bin/cli.js +73 -7
  6. package/config/mcp_launcher.py +75 -48
  7. package/config/opencode-snippet.json +14 -1
  8. package/extensions/pi/index.js +72 -6
  9. package/install.sh +5 -5
  10. package/package.json +5 -3
  11. package/plugins/antigravity/plugin.json +1 -1
  12. package/plugins/antigravity/skills/darkharvest/SKILL.md +13 -0
  13. package/plugins/codex/skills/darkharvest/SKILL.md +13 -0
  14. package/plugins/gemini/commands/darkharvest.toml +8 -0
  15. package/plugins/gemini/gemini-extension.json +1 -1
  16. package/plugins/gemini/skills/darkharvest/SKILL.md +13 -0
  17. package/plugins/opencode/index.js +357 -144
  18. package/plugins/opencode/tui.js +7 -9
  19. package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
  20. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
  21. package/prompts/agent_darkharvest.md +91 -0
  22. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  29. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  30. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  31. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  32. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  33. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  34. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  35. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  36. package/runner/auditor_engine.py +122 -102
  37. package/runner/claim_store.py +5 -4
  38. package/runner/living_dossiers.py +32 -10
  39. package/runner/mcp_protocol.py +1 -1
  40. package/runner/mcp_server.py +225 -73
  41. package/runner/path_safety.py +62 -0
  42. package/runner/pcrb.py +68 -39
  43. package/runner/pcrb_verify.py +82 -52
  44. package/runner/refinement.py +61 -47
  45. package/runner/research_swarm.py +198 -22
  46. package/runner/state_machine.py +9 -6
  47. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  61. package/runner/tests/test_mcp_server.py +80 -38
  62. package/runner/tests/test_opencode_ux.py +389 -35
  63. package/runner/tests/test_swarm.py +32 -9
  64. package/runner/tests/test_sweep_regressions.py +420 -0
  65. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  66. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  67. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  68. package/scripts/auction_experiment.py +38 -30
  69. package/scripts/bet1_advisory_spike.py +41 -25
  70. package/scripts/build_adapters.py +57 -35
  71. package/scripts/divergence_experiment.py +61 -31
  72. package/skills/brainstorming/scripts/brainstorm.py +3 -2
  73. package/skills/darkharvest/SKILL.md +70 -0
  74. package/skills/darkharvest/scripts/harvest.py +252 -0
  75. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  76. package/skills/epistemic_search/scripts/fetch.py +0 -1
  77. package/skills/epistemic_search/scripts/search.py +202 -73
  78. package/skills/epistemic_search/scripts/webcache.py +1 -1
  79. package/skills/grilling/socratic_tree.py +26 -18
  80. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  81. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  82. package/skills/research_cache/hasher.py +27 -9
  83. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  84. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  85. package/skills/swarm_config/configure.py +44 -30
@@ -11,16 +11,35 @@
11
11
  * catalog. Schemas mirror runner/mcp_server.py build_tools() — keep in sync.
12
12
  */
13
13
 
14
- import { spawn } from 'child_process';
15
- import { readFileSync } from 'fs';
16
- import path from 'path';
17
- import { fileURLToPath } from 'url';
14
+ import { spawn } from 'node:child_process';
15
+ import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
18
 
19
19
  const __filename = fileURLToPath(import.meta.url);
20
20
  const __dirname = path.dirname(__filename);
21
21
  const PKG_ROOT = path.resolve(__dirname, '../..');
22
22
  const MCP_SERVER = path.join(PKG_ROOT, 'runner', 'mcp_server.py');
23
23
 
24
+ function resolveBinary(name) {
25
+ const pathDirs = (process.env.PATH || '').split(path.delimiter);
26
+ for (const dir of pathDirs) {
27
+ if (!dir || dir === '.' || dir.startsWith('./') || dir.startsWith('../')) continue;
28
+ const full = path.join(dir, name);
29
+ try {
30
+ // Match Node's own PATH search: skip non-files and non-executables so a
31
+ // directory or a non-executable named `python3` earlier on PATH cannot
32
+ // shadow the real binary.
33
+ if (!statSync(full).isFile()) continue;
34
+ accessSync(full, constants.X_OK);
35
+ return realpathSync(full);
36
+ } catch {
37
+ /* not present, not a file, or not executable — keep searching */
38
+ }
39
+ }
40
+ return name;
41
+ }
42
+
24
43
  /** Plugin version tracks package.json so it cannot drift across releases. */
25
44
  let PKG_VERSION = '0.0.0';
26
45
  try {
@@ -35,7 +54,7 @@ try {
35
54
  * Async (non-blocking spawn) so long swarms/audits never freeze the host
36
55
  * event loop; cancellation remains the host's prerogative.
37
56
  */
38
- function callMcp(tool, args = {}, cwd) {
57
+ function callMcp(tool, args = {}, cwd = undefined) {
39
58
  return new Promise((resolve) => {
40
59
  let stdout = '';
41
60
  let stderr = '';
@@ -48,7 +67,7 @@ function callMcp(tool, args = {}, cwd) {
48
67
  let child;
49
68
  try {
50
69
  child = spawn(
51
- 'python3',
70
+ resolveBinary('python3'),
52
71
  [MCP_SERVER, 'call', tool, JSON.stringify(args || {})],
53
72
  {
54
73
  cwd: cwd || process.cwd(),
@@ -56,12 +75,12 @@ function callMcp(tool, args = {}, cwd) {
56
75
  }
57
76
  );
58
77
  } catch (err) {
59
- done(String((err && err.message) || err), 'error');
78
+ done(String(err?.message || err), 'error');
60
79
  return;
61
80
  }
62
81
  child.stdout.on('data', (d) => { stdout += String(d); });
63
82
  child.stderr.on('data', (d) => { stderr += String(d); });
64
- child.on('error', (err) => done(String((err && err.message) || err), 'error'));
83
+ child.on('error', (err) => done(String(err?.message || err), 'error'));
65
84
  child.on('close', (code) => done(stdout || stderr, code === 0 ? 'success' : 'error'));
66
85
  });
67
86
  }
@@ -73,6 +92,7 @@ export const IUMBTEMS_TOOL_NAMES = [
73
92
  'iumbtems_code_audit',
74
93
  'iumbtems_oss_scout',
75
94
  'iumbtems_brainstorm',
95
+ 'iumbtems_darkharvest',
76
96
  'iumbtems_verify_quote',
77
97
  'iumbtems_socratic_frontier',
78
98
  'iumbtems_reindex_claims',
@@ -107,8 +127,8 @@ const TOOL_CATALOG = [
107
127
  },
108
128
  mode: {
109
129
  type: 'string',
110
- enum: ['research', 'audit', 'scout', 'hybrid', 'brainstorm'],
111
- description: 'Operating mode: research (literature), audit (codebase), scout (OSS), hybrid, brainstorm (lateral ideation)',
130
+ enum: ['research', 'audit', 'scout', 'hybrid', 'brainstorm', 'darkharvest'],
131
+ description: 'Operating mode: research (literature), audit (codebase), scout (OSS), hybrid, brainstorm (lateral ideation), darkharvest (competitor teardown)',
112
132
  },
113
133
  divergence_threshold: {
114
134
  type: 'number',
@@ -204,6 +224,39 @@ const TOOL_CATALOG = [
204
224
  required: ['objective'],
205
225
  },
206
226
  },
227
+ {
228
+ name: 'iumbtems_darkharvest',
229
+ description:
230
+ 'Product competitor teardown: seed inspirations plus prompt, expand to adjacents, emit per-feature depend/vendor/clean-room/skip verdicts with SPDX attribution.',
231
+ input: {
232
+ type: 'object',
233
+ properties: {
234
+ objective: {
235
+ type: 'string',
236
+ description: 'Product arena to tear down (e.g. Paseo-class agent harness competitor)',
237
+ },
238
+ seeds: {
239
+ type: 'array',
240
+ items: { type: 'string' },
241
+ description: 'Seed inspiration repo URLs',
242
+ },
243
+ maxRepos: {
244
+ type: 'integer',
245
+ description: 'Cap on total competitors (default 10)',
246
+ },
247
+ depth: {
248
+ type: 'integer',
249
+ description: 'Dialectic depth 1-4',
250
+ },
251
+ mock_mode: {
252
+ type: 'boolean',
253
+ description: 'Run in mock mode without invoking LLM tokens',
254
+ default: false,
255
+ },
256
+ },
257
+ required: ['objective'],
258
+ },
259
+ },
207
260
  {
208
261
  name: 'iumbtems_verify_quote',
209
262
  description:
@@ -446,19 +499,36 @@ export const OPENCODE_COMMANDS = [
446
499
  'Execute iumbtems_brainstorm with {"objective": "<prompt>"} and report feature vectors, paradigm inversions, and falsifiable spikes.',
447
500
  ].join('\n'),
448
501
  },
502
+ {
503
+ name: 'darkharvest',
504
+ description: 'Product competitor teardown with per-feature harvest verdicts',
505
+ usage: '/darkharvest <objective> [--seeds <urls>] [--max-repos <n>]',
506
+ template: [
507
+ 'Run a product competitor teardown (seed inspirations plus prompt, expand to adjacents).',
508
+ 'Objective: $ARGUMENTS (product arena; seeds may be embedded as URLs)',
509
+ 'If $ARGUMENTS is empty, ask the user for the product arena and seed URLs first; never call the tool with placeholder arguments.',
510
+ '1. Execute iumbtems_darkharvest with {"objective": "<objective>", "seeds": ["<url>", "..."]} (pass "mock_mode": true only for dry runs).',
511
+ '2. Report the competitor x capability matrix, white-space gaps both ways, and the SPDX-attributed harvest backlog in .research/darkharvest_report.md.',
512
+ '3. Legal rule: permissive-only vendor; GPL/AGPL clean-room-rebuild only; workflows clonable, assets never.',
513
+ ].join('\n'),
514
+ },
449
515
  ];
450
516
 
451
517
  /**
452
- * Register the command catalog into an OpenCode config object without
453
- * overwriting user-defined commands of the same name. Exported for tests.
518
+ * Command catalog shaped for a declarative opencode config.
519
+ *
520
+ * V2's schema key is `commands` (plural) — `command` is the deprecated V1 key
521
+ * and is ignored by a V2 host. Registration on a live V2 host goes through
522
+ * `command.transform` instead of this; this exists so tests and docs can assert
523
+ * the catalog shape and the correct key.
454
524
  */
455
- export function registerOpenCodeCommands(cfg = {}) {
456
- cfg.command ??= {};
525
+ export function commandCatalog() {
526
+ const out = {};
457
527
  for (const cmd of OPENCODE_COMMANDS) {
458
- if (cfg.command[cmd.name]) continue;
459
- cfg.command[cmd.name] = { description: cmd.description, template: cmd.template };
528
+ if (!cmd?.name) continue;
529
+ out[cmd.name] = { description: cmd.description, template: cmd.template };
460
530
  }
461
- return cfg;
531
+ return out;
462
532
  }
463
533
 
464
534
  /** Tool map (object form) for the server hook; array catalog stays canonical. */
@@ -469,7 +539,7 @@ function buildToolMap() {
469
539
  {
470
540
  ...tool,
471
541
  options: { codemode: false },
472
- execute: async (args = {}, toolContext) =>
542
+ execute: async (args = {}, toolContext = undefined) =>
473
543
  callMcp(tool.name, normalizeArgs(tool.name, args), toolContext?.cwd),
474
544
  },
475
545
  ])
@@ -482,7 +552,7 @@ function buildToolMap() {
482
552
  * Reference pattern: @prevalentware/opencode-goal-plugin subscribes via
483
553
  * `context.event.subscribe({signal})`, detects idle as `session.idle` or an
484
554
  * idle `session.status`, continues via `context.session.prompt`, and preserves
485
- * state across compaction with the `experimental.session.compacting` hook.
555
+ * state across compaction with `context.session.hook("compaction", ...)`.
486
556
  * Every hook here is best-effort and never throws into the host.
487
557
  */
488
558
 
@@ -507,22 +577,31 @@ function readJsonFile(file) {
507
577
  }
508
578
  }
509
579
 
580
+ function parseLedgerEntries(ledger) {
581
+ const stale = [];
582
+ const suspect = [];
583
+ const last = new Map();
584
+ for (const e of ledger) {
585
+ if (typeof e?.claim_id === 'string') last.set(e.claim_id, e.to_status);
586
+ }
587
+ for (const [id, s] of last) {
588
+ if (s === 'STALE') stale.push(id);
589
+ else if (s === 'SUSPECT') suspect.push(id);
590
+ }
591
+ const lastAt = ledger.at(-1)?.at ?? '';
592
+ return { stale, suspect, fingerprint: `${ledger.length}:${lastAt}` };
593
+ }
594
+
510
595
  /** Last-write-wins degraded claims + requeue list. Pure fs reads. */
511
596
  export function readLedgerDegraded(root) {
512
597
  const out = { stale: [], suspect: [], requeued: [], fingerprint: 'empty' };
513
598
  try {
514
599
  const ledger = readJsonFile(path.join(root, '.research', 'ledger', 'claim_status.json'));
515
600
  if (Array.isArray(ledger)) {
516
- const last = new Map();
517
- for (const e of ledger) {
518
- if (e && typeof e.claim_id === 'string') last.set(e.claim_id, e.to_status);
519
- }
520
- for (const [id, s] of last) {
521
- if (s === 'STALE') out.stale.push(id);
522
- else if (s === 'SUSPECT') out.suspect.push(id);
523
- }
524
- const lastAt = ledger.length > 0 ? ledger[ledger.length - 1]?.at : '';
525
- out.fingerprint = `${ledger.length}:${lastAt}`;
601
+ const parsed = parseLedgerEntries(ledger);
602
+ out.stale = parsed.stale;
603
+ out.suspect = parsed.suspect;
604
+ out.fingerprint = parsed.fingerprint;
526
605
  }
527
606
  const requeue = readJsonFile(path.join(root, '.research', 'requeue.json'));
528
607
  if (Array.isArray(requeue)) {
@@ -635,6 +714,200 @@ export function startStalenessNudge(host, root) {
635
714
  }
636
715
  }
637
716
 
717
+ /**
718
+ * Plugin log. Never throws.
719
+ *
720
+ * Before this existed every setup block swallowed errors with a bare
721
+ * `catch {}`, so `--log-level all` showed only host-side symptoms and a
722
+ * registration failure was indistinguishable from success.
723
+ */
724
+ function log(host, level, message, detail) {
725
+ let text = message;
726
+ if (detail !== undefined) {
727
+ try {
728
+ text = `${message} ${JSON.stringify(detail)}`;
729
+ } catch {
730
+ text = `${message} [unserialisable detail]`;
731
+ }
732
+ }
733
+ try {
734
+ const sink = host?.client?.app?.log;
735
+ if (typeof sink === 'function') {
736
+ Promise.resolve(sink.call(host.client.app, {
737
+ body: { service: 'iumbtems', level, message: text },
738
+ })).catch(() => {});
739
+ return;
740
+ }
741
+ } catch {
742
+ /* fall through to console */
743
+ }
744
+ try {
745
+ (console.error || console.log)(`[iumbtems] ${level}: ${text}`);
746
+ } catch {
747
+ /* logging must never throw into the host */
748
+ }
749
+ }
750
+
751
+ function errDetail(err) {
752
+ return { error: String(err?.message ?? err) };
753
+ }
754
+
755
+ async function registerHostCommands(host) {
756
+ if (typeof host?.command?.transform !== 'function') return [];
757
+
758
+ // Snapshot which names are already taken *at setup time*. This snapshot is
759
+ // COPIED into the transform callback on every invocation and never grown.
760
+ //
761
+ // The host replays this callback on each command-registry rebuild, and the
762
+ // rebuilt registry starts from base config — so if the callback mutates a
763
+ // shared "already added" set, the second replay sees all six names as taken
764
+ // and adds nothing. That is exactly why the slash commands appeared at
765
+ // startup and vanished seconds later, and why only a full process restart
766
+ // brought them back (fresh setup -> fresh set).
767
+ //
768
+ // The reference plugin (@prevalentware/opencode-goal-plugin) gets this right
769
+ // with `const claimed = new Set(existingCommands)` inside the callback.
770
+ let existingNames = new Set();
771
+ try {
772
+ const listed = await host.command.list();
773
+ existingNames = new Set((listed?.data || []).map((c) => c?.name));
774
+ } catch (err) {
775
+ log(host, 'warn', 'command.list() failed; assuming an empty registry', errDetail(err));
776
+ }
777
+
778
+ const registration = await host.command.transform((draft) => {
779
+ const claimed = new Set(existingNames); // fresh copy per replay
780
+ const added = [];
781
+ for (const cmd of OPENCODE_COMMANDS) {
782
+ if (!cmd?.name || claimed.has(cmd.name)) continue;
783
+ claimed.add(cmd.name);
784
+ added.push(cmd.name);
785
+ draft.add({
786
+ name: cmd.name,
787
+ description: cmd.description,
788
+ execute: async (input) => {
789
+ const args = input?.prompt?.text || '';
790
+ const prompt = (typeof input?.prompt === 'object' && input?.prompt !== null) ? input.prompt : {};
791
+ await host.session.prompt({
792
+ ...prompt,
793
+ sessionID: input?.sessionID,
794
+ text: cmd.template.split('$ARGUMENTS').join(String(args).trim()),
795
+ delivery: input?.delivery,
796
+ });
797
+ },
798
+ });
799
+ }
800
+ log(host, 'debug', 'command.transform pass', {
801
+ added,
802
+ skippedAsPreexisting: [...existingNames].filter((n) => !added.includes(n)),
803
+ });
804
+ });
805
+
806
+ // Force materialisation now instead of waiting for the host's own later
807
+ // reconcile (which is the pass that used to wipe them).
808
+ try {
809
+ if (typeof host.command.reload === 'function') await host.command.reload();
810
+ log(host, 'info', 'slash commands registered', { count: OPENCODE_COMMANDS.length });
811
+ } catch (err) {
812
+ log(host, 'warn', 'command.reload() failed', errDetail(err));
813
+ }
814
+
815
+ return registration ? [registration] : [];
816
+ }
817
+
818
+ async function registerHostTools(host) {
819
+ if (typeof host?.tool?.transform !== 'function') return [];
820
+ const registration = await host.tool.transform((draft) => {
821
+ for (const [name, spec] of Object.entries(buildToolMap())) {
822
+ draft.add({
823
+ name,
824
+ description: spec.description,
825
+ input: spec.input,
826
+ options: { codemode: false },
827
+ execute: async (args = {}, toolContext = undefined) => {
828
+ const r = await callMcp(name, normalizeArgs(name, args), toolContext?.cwd);
829
+ return { content: r.content };
830
+ },
831
+ });
832
+ }
833
+ log(host, 'debug', 'tool.transform pass', { count: Object.keys(buildToolMap()).length });
834
+ });
835
+ try {
836
+ if (typeof host.tool.reload === 'function') await host.tool.reload();
837
+ } catch (err) {
838
+ log(host, 'warn', 'tool.reload() failed', errDetail(err));
839
+ }
840
+ return registration ? [registration] : [];
841
+ }
842
+
843
+ /**
844
+ * V2 compaction hook: keep .research state across context compaction.
845
+ *
846
+ * The V1 equivalent (`experimental.session.compacting`) lived in the old
847
+ * `server()` hook and was dead code on V2 — compaction silently lost the
848
+ * swarm state. Shape follows the reference plugin: push a text part onto
849
+ * `event.system`.
850
+ */
851
+ async function registerCompactionHook(host, context) {
852
+ const hook = host?.session?.hook || context?.session?.hook;
853
+ if (typeof hook !== 'function') return [];
854
+ try {
855
+ const registration = await hook.call(host.session || context.session, 'compaction', async (event) => {
856
+ try {
857
+ const root =
858
+ event?.directory || host.location?.directory || context.location?.directory || event?.cwd;
859
+ if (!root) return;
860
+ const text = buildCompactionContext(root);
861
+ if (!text) return;
862
+ const system = Array.isArray(event?.system) ? event.system : null;
863
+ if (!system) return;
864
+ // Idempotent: the host may invoke this more than once per session.
865
+ if (system.some((part) => part?.type === 'text' && part?.text === text)) return;
866
+ system.push({ type: 'text', text });
867
+ } catch (err) {
868
+ log(host, 'warn', 'compaction hook failed', errDetail(err));
869
+ }
870
+ });
871
+ return registration ? [registration] : [];
872
+ } catch (err) {
873
+ log(host, 'warn', 'session.hook("compaction") unavailable', errDetail(err));
874
+ return [];
875
+ }
876
+ }
877
+
878
+ async function applyConfigOptions(host, opts, context) {
879
+ if (!opts || typeof opts !== 'object') return;
880
+ const updates = {};
881
+ if (opts.search_engine) updates.search_engine = opts.search_engine;
882
+ if (opts.max_iterations) updates.max_iterations = opts.max_iterations;
883
+ if (opts.mode) updates.mode = opts.mode;
884
+ if (opts.divergence_threshold !== undefined) {
885
+ updates.divergence_threshold = opts.divergence_threshold;
886
+ }
887
+ if (Object.keys(updates).length > 0) {
888
+ await callMcp(
889
+ 'iumbtems_config',
890
+ updates,
891
+ host.location?.directory || context.location?.directory || host.cwd
892
+ );
893
+ log(host, 'debug', 'applied tuple options via iumbtems_config', updates);
894
+ }
895
+ }
896
+
897
+ function startNudgeSubscription(host, opts, context, cleanups) {
898
+ if (!(opts?.staleness_nudge ?? true)) return;
899
+ const nudgeHost = {
900
+ event: host.event || context.event,
901
+ session: host.session || context.session,
902
+ };
903
+ const root = host.location?.directory || context.location?.directory;
904
+ if (root) {
905
+ const stop = startStalenessNudge(nudgeHost, root);
906
+ if (typeof stop === 'function') cleanups.add(stop);
907
+ log(host, 'debug', 'staleness nudge subscription', { root, started: typeof stop === 'function' });
908
+ }
909
+ }
910
+
638
911
  export function createOpenCodePlugin(context = {}) {
639
912
  return {
640
913
  id: 'heretek.iumbtems.epistemic-swarm',
@@ -643,137 +916,77 @@ export function createOpenCodePlugin(context = {}) {
643
916
  description:
644
917
  'I Use My Brain To Express My Self: High-integrity dialectic research, code audits, and open-source scouting',
645
918
 
646
- server: async () => ({
647
- // Config hook: slash-command catalog (mirrors the goal plugin's
648
- // registerDesktopCommands pattern; never overwrites user commands).
649
- config: (cfg) => registerOpenCodeCommands(cfg),
650
- // Tool map (object form, as in the reference implementation).
651
- tool: buildToolMap(),
652
- // Compaction hook: preserve .research state (best-effort, silent skip
653
- // when no workspace is present so compaction never breaks).
654
- 'experimental.session.compacting': async (input, output) => {
655
- try {
656
- const root = input?.directory ?? input?.location?.directory ?? process.cwd();
657
- const ctx = buildCompactionContext(root);
658
- if (ctx && output && Array.isArray(output.context)) output.context.push(ctx);
659
- } catch {
660
- /* never break compaction */
661
- }
662
- },
663
- }),
664
-
919
+ // V2 only. The V1 `server()` factory and its `config` hook wrote the
920
+ // deprecated `command` key (V2's schema is `commands`), so they registered
921
+ // nothing on a V2 host — and carrying both shapes meant the real
922
+ // registration path had to guess which host it was talking to.
665
923
  setup: async (appContext) => {
666
924
  const host = appContext || {};
667
925
  const opts = context.options || host.options;
668
- // V2 host transforms FIRST (official dual-package pattern: V1 calls
669
- // server(), V2 calls setup()). Registration must precede any slow
670
- // work: the host may not wait out subprocess calls before reconciling.
926
+ // Transform registrations must precede any slow work: the host may
927
+ // reconcile past a slow setup and never see the commands.
928
+ const registrations = [];
929
+ const adopt = (list) => {
930
+ for (const r of list || []) if (r) registrations.push(r);
931
+ };
932
+
933
+ log(host, 'info', 'iumbtems setup', {
934
+ version: PKG_VERSION,
935
+ directory: host.location?.directory || context.location?.directory || null,
936
+ });
937
+
671
938
  try {
672
- if (host.command && typeof host.command.transform === 'function') {
673
- let existing = new Set();
674
- try {
675
- const listed = await host.command.list();
676
- existing = new Set(((listed && listed.data) || []).map((c) => c && c.name));
677
- } catch {
678
- /* treat as empty */
679
- }
680
- await host.command.transform((draft) => {
681
- for (const cmd of OPENCODE_COMMANDS) {
682
- if (!cmd || !cmd.name || existing.has(cmd.name)) continue;
683
- existing.add(cmd.name);
684
- draft.add({
685
- name: cmd.name,
686
- description: cmd.description,
687
- execute: async (input) => {
688
- const args = (input && input.prompt && input.prompt.text) || '';
689
- const prompt = (input && input.prompt && typeof input.prompt === 'object') ? input.prompt : {};
690
- await host.session.prompt({
691
- ...prompt,
692
- sessionID: input && input.sessionID,
693
- text: cmd.template.split('$ARGUMENTS').join(String(args).trim()),
694
- delivery: input && input.delivery,
695
- });
696
- },
697
- });
698
- }
699
- });
700
- }
701
- } catch {
702
- /* command transform is best-effort */
939
+ adopt(await registerHostCommands(host));
940
+ } catch (err) {
941
+ log(host, 'error', 'registering slash commands failed', errDetail(err));
703
942
  }
704
943
  try {
705
- if (host.tool && typeof host.tool.transform === 'function') {
706
- await host.tool.transform((draft) => {
707
- for (const [name, spec] of Object.entries(buildToolMap())) {
708
- draft.add({
709
- name,
710
- description: spec.description,
711
- input: spec.input,
712
- options: { codemode: false },
713
- execute: async (args = {}, toolContext) => {
714
- const r = await callMcp(name, normalizeArgs(name, args), toolContext?.cwd);
715
- return { content: r.content };
716
- },
717
- });
718
- }
719
- });
720
- }
721
- } catch {
722
- /* tool transform is best-effort */
944
+ adopt(await registerHostTools(host));
945
+ } catch (err) {
946
+ log(host, 'error', 'registering tools failed', errDetail(err));
723
947
  }
724
- // Slow work AFTER registration: apply tuple options via MCP config
725
- // write, scoped to the plugin location (never the service cwd).
726
948
  try {
727
- if (opts && typeof opts === 'object') {
728
- const updates = {};
729
- if (opts.search_engine) updates.search_engine = opts.search_engine;
730
- if (opts.max_iterations) updates.max_iterations = opts.max_iterations;
731
- if (opts.mode) updates.mode = opts.mode;
732
- if (opts.divergence_threshold !== undefined) {
733
- updates.divergence_threshold = opts.divergence_threshold;
734
- }
735
- if (Object.keys(updates).length > 0) {
736
- await callMcp(
737
- 'iumbtems_config',
738
- updates,
739
- host.location?.directory || context.location?.directory || host.cwd
740
- );
741
- }
742
- }
743
- } catch {
744
- /* options apply is best-effort */
949
+ adopt(await registerCompactionHook(host, context));
950
+ } catch (err) {
951
+ log(host, 'error', 'registering compaction hook failed', errDetail(err));
745
952
  }
746
- // Phase C: idle staleness nudge (opt out with {staleness_nudge: false}).
747
- // Subscribes only when the host exposes event/session; one per root.
953
+ // Slow work AFTER registration.
748
954
  try {
749
- if ((opts?.staleness_nudge ?? true)) {
750
- const nudgeHost = {
751
- event: host.event || context.event,
752
- session: host.session || context.session,
753
- };
754
- const root =
755
- host.location?.directory || context.location?.directory;
756
- if (root) {
757
- const stop = startStalenessNudge(nudgeHost, root);
758
- if (typeof stop === 'function') setupCleanups.add(stop);
955
+ await applyConfigOptions(host, opts, context);
956
+ } catch (err) {
957
+ log(host, 'warn', 'applying tuple options failed', errDetail(err));
958
+ }
959
+ try {
960
+ startNudgeSubscription(host, opts, context, setupCleanups);
961
+ } catch (err) {
962
+ log(host, 'warn', 'starting staleness nudge failed', errDetail(err));
963
+ }
964
+
965
+ // The host calls this on unload/reload. It MUST be a function: returning
966
+ // a plain object used to abort the whole plugin reload with
967
+ // `TypeError: de is not a function`, dropping every registration.
968
+ return async () => {
969
+ let disposed = 0;
970
+ for (const registration of registrations) {
971
+ try {
972
+ await registration.dispose();
973
+ disposed += 1;
974
+ } catch (err) {
975
+ log(host, 'warn', 'registration.dispose() failed', errDetail(err));
759
976
  }
760
977
  }
761
- } catch {
762
- /* lifecycle hooks are best-effort */
763
- }
764
- // Disposer: the host calls setup's return value as a cleanup function
765
- // on unload/reload. It MUST be a function (returning a plain object
766
- // crashes host reload with "not a function"). Aborts nudge streams so
767
- // reloads never stack duplicate subscriptions.
768
- return () => {
769
- for (const stop of [...setupCleanups]) {
770
- setupCleanups.delete(stop);
978
+ registrations.length = 0;
979
+ let stopped = 0;
980
+ for (const stop of setupCleanups) {
771
981
  try {
772
982
  stop();
773
- } catch {
774
- /* cleanup is best-effort */
983
+ stopped += 1;
984
+ } catch (err) {
985
+ log(host, 'warn', 'cleanup failed', errDetail(err));
775
986
  }
776
987
  }
988
+ setupCleanups.clear();
989
+ log(host, 'info', 'iumbtems disposed', { registrations: disposed, nudges: stopped });
777
990
  };
778
991
  },
779
992
  };
@@ -11,8 +11,8 @@
11
11
  * `keymap.layer` (inside a component scope), `api.theme`.
12
12
  */
13
13
 
14
- import { readFileSync, readdirSync, statSync } from 'fs';
15
- import path from 'path';
14
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import path from 'node:path';
16
16
  import { createElement, insert, setProp } from '@opentui/solid';
17
17
  import { createEffect, createSignal, onCleanup } from 'solid-js';
18
18
  import { OPENCODE_COMMANDS } from './index.js';
@@ -65,9 +65,8 @@ export function readSwarmStatus(root) {
65
65
  suspect: 0,
66
66
  requeued: 0,
67
67
  };
68
- let entries = [];
69
68
  try {
70
- entries = readdirSync(research);
69
+ readdirSync(research);
71
70
  status.workspace = true;
72
71
  } catch {
73
72
  return status;
@@ -149,7 +148,7 @@ function summarize(status) {
149
148
  return `IUMBTEMS: ${bits.join(' · ')}`;
150
149
  }
151
150
 
152
- function SwarmSidebar(api, root, sessionID) {
151
+ function SwarmSidebar(api, root) {
153
152
  const colors = themeFg(api?.theme);
154
153
  const [snapshot, setSnapshot] = createSignal(readSwarmStatus(root));
155
154
  createEffect(() => {
@@ -162,7 +161,6 @@ function SwarmSidebar(api, root, sessionID) {
162
161
  }, REFRESH_MS);
163
162
  onCleanup(() => clearInterval(timer));
164
163
  });
165
- void sessionID;
166
164
  return box({}, [
167
165
  () => {
168
166
  const s = snapshot();
@@ -196,7 +194,7 @@ function SwarmKeymapLayer(api) {
196
194
  // Every slash command gets a palette entry (usage toast; the host has no
197
195
  // programmatic slash-invoke API, so discovery + usage guidance is the win).
198
196
  for (const cmd of OPENCODE_COMMANDS || []) {
199
- if (!cmd || !cmd.name) continue;
197
+ if (!cmd?.name) continue;
200
198
  commands.push({
201
199
  id: `iumbtems.command.${cmd.name}`,
202
200
  title: `/${cmd.name}`,
@@ -247,8 +245,8 @@ function registerSlot(api, name, render) {
247
245
  export function setupTui(api) {
248
246
  try {
249
247
  const root = resolveRoot(api);
250
- const offSidebar = registerSlot(api, 'sidebar.content', (props) =>
251
- SwarmSidebar(api, root, props?.sessionID)
248
+ const offSidebar = registerSlot(api, 'sidebar.content', () =>
249
+ SwarmSidebar(api, root)
252
250
  );
253
251
  const offApp = registerSlot(api, 'app', () => SwarmKeymapLayer(api));
254
252
  return () => {