insika 0.1.0 → 0.3.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # — the double gate on ONE candidate, in order:
8
+ # eval first (expensive — a full golden replay), conversion second (cheap
9
+ # — a fold read). Synchronous control command (the Studio dispatches it;
10
+ # the gate is minutes of real replay, exactly like `gate_refinement`).
11
+ # It records both reports on the candidate and moves it: both passed ->
12
+ # awaiting_approval; either failed -> rejected with the reason (a failed
13
+ # candidate is terminal — the same finding must re-surface with new
14
+ # evidence, no silent retry).
15
+ class GateHarvest
16
+ def initialize(harvest_store:, gate:, conversion_gate:, criterion: nil, event_stream:)
17
+ @harvest_store = harvest_store
18
+ @gate = gate
19
+ @conversion_gate = conversion_gate
20
+ @criterion = criterion
21
+ @event_stream = event_stream
22
+ end
23
+
24
+ # payload: { candidate_id: } (tenant from command.meta — WS1)
25
+ # -> Candidate (awaiting_approval | rejected | gated-parked; both
26
+ # reports attached)
27
+ def call(command)
28
+ p = AgentPayload.symbolize(command.payload)
29
+ candidate_id = AgentPayload.presence(p[:candidate_id])
30
+ raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
31
+
32
+ candidate = @harvest_store.find_candidate(candidate_id) ||
33
+ (raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
34
+
35
+ # The base graph wires NO gates (the deployment owns the eval surface):
36
+ # the candidate parks at gated with the named reason — refuse-with-a-
37
+ # named-reason, never pass, never crash.
38
+ if @gate.nil? || @conversion_gate.nil?
39
+ parked = @harvest_store.attach_gate(
40
+ candidate.id,
41
+ eval_gate: { "passed" => false, "reason" => "no_eval_gate_wired" },
42
+ conversion_gate: { "passed" => false, "reason" => "no_conversion_gate_wired" },
43
+ criterion_sha: nil
44
+ )
45
+ return parked
46
+ end
47
+
48
+ # The mining budget is read HERE too (the review fix): once the mining
49
+ # pass spent the pack's cap, the expensive golden replay is refused
50
+ # with the named reason — the Refinement::Budget discipline ("not
51
+ # gated — the budget was spent"), applied before the provider bill.
52
+ run = @harvest_store.find_run(candidate.run_id)
53
+ cap = run && run.budget && run.budget["tokens"].to_i
54
+ spent = run && run.cost && run.cost["spent"].to_i
55
+ if cap&.positive? && spent.to_i >= cap
56
+ parked = @harvest_store.attach_gate(
57
+ candidate.id,
58
+ eval_gate: { "passed" => false, "reason" => "budget_exceeded",
59
+ "spent" => spent, "tokens" => cap },
60
+ conversion_gate: { "passed" => false, "reason" => "budget_exceeded" },
61
+ criterion_sha: nil
62
+ )
63
+ return parked
64
+ end
65
+
66
+ skill = { "name" => candidate.name, "description" => candidate.description,
67
+ "body" => candidate.body, "triggers" => candidate.triggers }
68
+ eval_report = @gate.score(agent_id: candidate.agent, skill: skill,
69
+ run_id: candidate.run_id)
70
+ conversion_report = @conversion_gate.call(tenant: command.meta[:tenant],
71
+ agent: candidate.agent)
72
+ criterion_sha = @criterion ? @criterion.sha : nil
73
+
74
+ # A conversion REFUSAL (missing data — :no_frozen_baseline etc.) is NOT
75
+ # a candidate failure: it parks the candidate at gated with both
76
+ # reports and the named reason — the page shows the ruler's hole.
77
+ # Only a conversion FAILURE (current worse than baseline) rejects.
78
+ if refused?(conversion_report)
79
+ gated = @harvest_store.attach_gate(candidate.id,
80
+ eval_gate: eval_report.to_h,
81
+ conversion_gate: conversion_report.to_h,
82
+ criterion_sha: criterion_sha)
83
+ emit(:harvest_gated, candidate, gated, eval_report, conversion_report)
84
+ return gated
85
+ end
86
+
87
+ passed = eval_report.passed && conversion_report.passed
88
+ @harvest_store.attach_gate(candidate.id,
89
+ eval_gate: eval_report.to_h,
90
+ conversion_gate: conversion_report.to_h,
91
+ criterion_sha: criterion_sha)
92
+ result = if passed
93
+ @harvest_store.mark_awaiting(candidate.id)
94
+ else
95
+ reason = eval_report.passed ? conversion_report.reason : eval_report.reason
96
+ @harvest_store.mark_rejected(candidate.id, operator: "gate", note: reason)
97
+ end
98
+ emit(:harvest_gated, candidate, result, eval_report, conversion_report)
99
+ result
100
+ end
101
+
102
+ private
103
+
104
+ # The D6 refusals: the ruler is MISSING, not moving — the candidate
105
+ # parks. Anything else with passed: false is a real failure.
106
+ def refused?(report)
107
+ !report.passed && REFUSAL_REASONS.include?(report.reason)
108
+ end
109
+
110
+ REFUSAL_REASONS = %i[no_criterion no_funnel no_frozen_baseline metric_mismatch no_fold
111
+ baseline_span_short no_baseline_rate budget_exceeded].freeze
112
+
113
+ def emit(type, candidate, result, eval_report, conversion_report)
114
+ @event_stream.emit(Insika::Event.new(
115
+ type: type,
116
+ # ids and verdicts, never the skill body
117
+ data: { run_id: candidate.run_id, candidate_id: candidate.id,
118
+ agent: candidate.agent,
119
+ eval_passed: eval_report.passed,
120
+ conversion_passed: conversion_report.passed,
121
+ reason: verdict_reason(result, eval_report, conversion_report) },
122
+ meta: { at: Time.now.utc.iso8601 }
123
+ ))
124
+ end
125
+
126
+ # The refusal/failure reason as an id the operator stream can act on —
127
+ # the symbol, not the human note.
128
+ def verdict_reason(result, eval_report, conversion_report)
129
+ return nil if result.status == "awaiting_approval"
130
+
131
+ return eval_report.reason unless eval_report.passed
132
+ return conversion_report.reason if conversion_report.reason
133
+
134
+ result.decision && result.decision["note"]
135
+ end
136
+ end
137
+ end
138
+ end
@@ -4,21 +4,21 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # Control command (RFC-0013 phase C): takes a CANDIDATE for a completed
7
+ # Control command: takes a CANDIDATE for a completed
8
8
  # refinement run, validates it against the agent's write allowlist, and scores it
9
9
  # by actually running it — clone the agent, apply the edits to the clone, replay
10
- # the golden set, compare to the accepted baseline (§3.5).
10
+ # the golden set, compare to the accepted baseline.
11
11
  #
12
12
  # Synchronous like `run_refinement`, and for the same reason: it is operator-paced
13
13
  # work with a human waiting on the answer. It is NOT cheap — the replay is a real
14
14
  # conversation per golden case — so it is fired deliberately, never on a timer
15
- # (D8: the engine has no scheduler and this RFC does not add one).
15
+ # (the engine has no scheduler and this design does not add one).
16
16
  #
17
17
  # Payload:
18
18
  # run_id (required) a run in :completed — the evidence the candidate answers
19
19
  # candidate { proposer?, rationale?, edits: [ {file, op, anchor?, before, after,
20
20
  # addresses?} ] } — or omit it and pass `propose: true` to have the
21
- # configured model(s) write one from the run's findings (§3.4).
21
+ # configured model(s) write one from the run's findings.
22
22
  # propose truthy — write the candidate with the agent's proposer PANEL.
23
23
  # tolerance Float — overrides the configured judge-score tolerance for this gate
24
24
  #
@@ -30,14 +30,14 @@ module Insika
30
30
  # forgot the candidate should get an error, not a bill.
31
31
  #
32
32
  # Two refusals happen BEFORE anything is cloned, because both mean the operator
33
- # has not actually enabled this: an agent still in `mode: report` (§3.8 — writing
33
+ # has not actually enabled this: an agent still in `mode: report` (writing
34
34
  # is opt-in and an absent config is report-only), and a candidate whose every edit
35
35
  # was dropped (stale, off-allowlist, over budget). Neither is worth a provider bill.
36
36
  #
37
- # Phase D (§3.9) makes the proposal a PANEL and the run's spend a BUDGET. The
37
+ # makes the proposal a PANEL and the run's spend a BUDGET. The
38
38
  # command's shape does not change: N models write N candidates, the gate scores
39
39
  # each, and the best survivor is the one proposal a human is shown. A deployment
40
- # with one proposer and no budget behaves exactly as phase C did.
40
+ # with one proposer and no budget behaves exactly as did.
41
41
  class GateRefinement
42
42
  WRITE_MODES = %w[propose auto_apply].freeze
43
43
 
@@ -49,7 +49,7 @@ module Insika
49
49
 
50
50
  # proposer_factory: ->(refinement_config) { [Refinement::Proposer] | Proposer | nil }.
51
51
  # Optional — a deployment with none can still gate a candidate that arrives from
52
- # the API, which is exactly what phase C shipped before the proposer existed.
52
+ # the API, which is exactly what shipped before the proposer existed.
53
53
  # resolver: the :resolve_refinement handler, used ONLY for `mode: auto_apply`.
54
54
  # Reusing it rather than writing files here is what keeps auto-apply honest: it
55
55
  # goes through the same staleness re-check, the same versioned write and the
@@ -125,7 +125,7 @@ module Insika
125
125
  end
126
126
 
127
127
  # Editing an agent's instructions is opt-in, explicitly, per agent — the whole
128
- # point of §3.1 is that the reachable surface is small and declared. An absent
128
+ # point of is that the reachable surface is small and declared. An absent
129
129
  # or `report` config is not "not configured yet", it is a NO.
130
130
  def require_write_mode!(config, agent_id)
131
131
  mode = Coercion.presence(config["mode"]) || "report"
@@ -148,7 +148,7 @@ module Insika
148
148
  raise ArgumentError, "run #{run.id} is #{run.status}, expected completed"
149
149
  end
150
150
 
151
- # The model(s) write the candidates (§3.4/§3.9). Each is shown the run's findings
151
+ # The model(s) write the candidates. Each is shown the run's findings
152
152
  # and the CURRENT content of the allowlisted files only — the same allowlist the
153
153
  # builder then enforces, so a proposal cannot even name a file it may not touch.
154
154
  #
@@ -194,7 +194,7 @@ module Insika
194
194
  allow
195
195
  end
196
196
 
197
- # `mode: auto_apply` (§3.6, D2) — the ONE path where a prompt changes with no
197
+ # `mode: auto_apply` — the ONE path where a prompt changes with no
198
198
  # human in the loop. Off by default and deliberately narrow: it needs the mode,
199
199
  # a gate PASS with zero regressions, and a diff under `auto_apply_max_edits`.
200
200
  # Everything else parks at :awaiting_approval, which is the product.
@@ -232,7 +232,7 @@ module Insika
232
232
  end
233
233
 
234
234
  # Counts and ids only, never file content: these events reach the operator
235
- # stream and the same rule the findings follow applies here (§5).
235
+ # stream and the same rule the findings follow applies here.
236
236
  def emit(type, run, **data)
237
237
  @event_stream.emit(Insika::Event.new(
238
238
  type: type,
@@ -4,7 +4,7 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # Control command: LIVE MCP ingestion (Phase 7, Step E). Receives the NAME of
7
+ # Control command: LIVE MCP ingestion. Receives the NAME of
8
8
  # an MCP instance, delegates to McpToolIngestor (discovers via an injectable client
9
9
  # -> builds the manifest -> reuses :import_tools: batch upsert + hot reload)
10
10
  # and returns the per-tool report in the shape of :import_tools, plus
@@ -4,18 +4,18 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # BATCH ingestion of data-tools from a manifest (Phase 7, Step B).
7
+ # BATCH ingestion of data-tools from a manifest.
8
8
  # Normalizes each tool (defaults + envelope adapter + endpoint→url + secret/
9
9
  # env) via ToolManifest, UPSERTs it into the ToolStore, and RELOADS the overlay +
10
- # catalog ONCE at the end — takes effect without a restart (NF3). Idempotent
10
+ # catalog ONCE at the end — takes effect without a restart. Idempotent
11
11
  # (re-importing reconciles). Injects the deployment's `{{secret.*}}`/`{{env.*}}`
12
- # resolvers (D6/open-q2): the secret NEVER comes in the manifest.
12
+ # resolvers: the secret NEVER comes in the manifest.
13
13
  #
14
14
  # ISOLATED PARTIAL FAILURE (R4): a malformed tool (invalid envelope, missing
15
15
  # endpoint, unconfigured secret, collision with a code tool, invalid url)
16
16
  # does NOT bring down the batch — it becomes an entry in `errors[]`. Only a STRUCTURAL error in the
17
17
  # manifest (defaults/tools of the wrong type) raises (transport -> 422).
18
- # Per-tool report in the shape of the Phase 6 pack importer.
18
+ # Per-tool report in the shape of the pack importer.
19
19
  # -> { version, created: [names], updated: [names], errors: [{tool,error}] }
20
20
  class ImportTools
21
21
  def initialize(tool_store:, registry:, tool_catalog:, event_stream:, secrets: ENV, env: ENV)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # WS1: issues a PER-TENANT token (multi_tenant mode). Operator-only BY
6
+ # CONSTRUCTION: the edge refuses a tenant principal on POST /v1/commands
7
+ # (403), and an internal command stamped with a tenant is refused here —
8
+ # a tenant can never mint credentials. The plaintext token is the response
9
+ # and exists nowhere else; the store keeps only its hash. -> Issue.to_h.
10
+ class IssueTenantToken
11
+ def initialize(token_store:, event_stream:)
12
+ @token_store = token_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
18
+
19
+ tenant_id = Insika::Coercion.presence(
20
+ command.payload[:tenant_id] || command.payload["tenant_id"]
21
+ )
22
+ raise Insika::ValidationError, "tenant_id is required" if tenant_id.nil?
23
+
24
+ label = command.payload[:label] || command.payload["label"] || "default"
25
+ issue = @token_store.issue(tenant_id: tenant_id, label: label)
26
+ emit(issue.id, tenant_id)
27
+ { token: issue.token, id: issue.id, tenant_id: tenant_id, label: label.to_s }
28
+ end
29
+
30
+ private
31
+
32
+ def emit(token_id, tenant_id)
33
+ @event_stream.emit(Insika::Event.new(
34
+ type: :tenant_token_issued,
35
+ data: { token_id: token_id, tenant_id: tenant_id },
36
+ meta: { at: Time.now.utc.iso8601 }
37
+ ))
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # C7 — turn complete shadow pairs into judged pairs, using the judges the
8
+ # operator ALREADY configured (settings["evals"], through JudgePanel — the
9
+ # one builder the eval CLI and the refinement gate use too). Never automatic:
10
+ # a Studio button or the CLI dispatches it, and it states its cost
11
+ # (2 provider calls per judge per pair, both orders — position-bias
12
+ # rule) before it runs.
13
+ #
14
+ # Refusals are visible, a fake judge is not: no configured judges, or fewer
15
+ # models than the criterion demands, is a ValidationError — an unjudged
16
+ # window reads :invalid, where a window judged by judges nobody chose reads
17
+ # as evidence.
18
+ class JudgeShadowPairs
19
+ DEFAULT_LIMIT = 50
20
+ DEFAULT_EXPIRE_HOURS = 24
21
+
22
+ def initialize(shadow_pairs:, settings_store:, criterion:, llm: nil, event_stream: nil)
23
+ @shadow_pairs = shadow_pairs
24
+ @settings_store = settings_store
25
+ @criterion = criterion
26
+ @llm = llm
27
+ @event_stream = event_stream
28
+ end
29
+
30
+ # payload: { limit: Integer = 50, agent: String|nil, expire_after_hours: Integer|nil }
31
+ # -> { judged:, skipped:, expired:, failed:, models: [..] }
32
+ def call(command)
33
+ payload = command.payload
34
+ limit = begin
35
+ Integer(payload[:limit] || payload["limit"] || DEFAULT_LIMIT)
36
+ rescue ArgumentError
37
+ raise ValidationError, "limit must be an integer"
38
+ end
39
+ agent = Coercion.presence(payload[:agent] || payload["agent"])
40
+ expire_hours = begin
41
+ Integer(payload[:expire_after_hours] || payload["expire_after_hours"] || DEFAULT_EXPIRE_HOURS)
42
+ rescue ArgumentError
43
+ raise ValidationError, "expire_after_hours must be an integer"
44
+ end
45
+
46
+ # Judges BEFORE the expire: a refusal (no judges, too few models) must
47
+ # leave every pair untouched — expiring first would stamp :incomplete on
48
+ # pairs and that is terminal evidence against a click that judged nothing.
49
+ panel = Insika::Evals::JudgePanel.pairwise(settings, llm: @llm)
50
+ raise ValidationError, "no judges configured in settings['evals'] — refusing to judge without judges" if panel.nil?
51
+
52
+ pairwise, models = panel
53
+ minimum = @criterion.rule.min_judge_models
54
+ distinct = models.uniq
55
+ if distinct.length < minimum
56
+ raise ValidationError, "the criterion requires at least #{minimum} distinct judge models; " \
57
+ "#{distinct.length} configured (#{distinct.join(', ')})"
58
+ end
59
+
60
+ # A mirror that dropped a half stops looking like pending work.
61
+ expired = @shadow_pairs.expire(older_than: Time.now.utc - (expire_hours * 3600))
62
+
63
+ judged = 0
64
+ failed = 0
65
+ @shadow_pairs.unjudged(limit: limit, agent: agent).each do |pair|
66
+ if judge_pair(pair, pairwise, models)
67
+ judged += 1
68
+ else
69
+ failed += 1
70
+ end
71
+ end
72
+ skipped = @shadow_pairs.unjudged(agent: agent).length
73
+
74
+ emit_summary(judged: judged, skipped: skipped, expired: expired, failed: failed, models: models)
75
+ { judged: judged, skipped: skipped, expired: expired, failed: failed, models: models }
76
+ end
77
+
78
+ private
79
+
80
+ def settings
81
+ (@settings_store.get || {})["evals"] || {}
82
+ end
83
+
84
+ # -> true when the pair was judged. A raising judge leaves the pair
85
+ # :complete (retryable on the next press) and never aborts the batch; an
86
+ # empty side is recorded :unknown — visible, never a preference. The rescue
87
+ # is broad on purpose: a provider outage raises the transport's own error
88
+ # class (RubyLLM::Error, an HTTP status as StandardError), NOT an
89
+ # Insika::Error — and one broken provider must not sink the rest of the
90
+ # batch against this method's own contract.
91
+ def judge_pair(pair, pairwise, models)
92
+ verdict = pairwise.compare_texts(
93
+ ours: Insika::Parity.transcript(pair.inbound, pair.insika_reply),
94
+ theirs: Insika::Parity.transcript(pair.inbound, pair.incumbent_reply)
95
+ )
96
+ verdict ||= Insika::Evals::Pairwise::Verdict.new(
97
+ outcome: "unknown", reason: "a side of the pair was empty", vs: "agent",
98
+ judges: [], order_dependent: false
99
+ )
100
+ blob = verdict.to_h.merge("judged_at" => Time.now.utc.iso8601, "models" => models)
101
+ @shadow_pairs.record_verdict(pair.id, verdict: blob)
102
+ @event_stream&.emit(Insika::Event.new(
103
+ type: :shadow_judged,
104
+ data: { pair_id: pair.id, agent: pair.agent, outcome: blob[:outcome] },
105
+ meta: { at: Time.now.utc.iso8601 }
106
+ ))
107
+ true
108
+ rescue StandardError
109
+ false
110
+ end
111
+
112
+ def emit_summary(judged:, skipped:, expired:, failed:, models:)
113
+ return unless @event_stream
114
+
115
+ @event_stream.emit(Insika::Event.new(
116
+ type: :shadow_judge_batch,
117
+ data: { judged: judged, skipped: skipped, expired: expired,
118
+ failed: failed, models: models },
119
+ meta: { at: Time.now.utc.iso8601 }
120
+ ))
121
+ end
122
+ end
123
+ end
124
+ end
@@ -7,10 +7,15 @@ module Insika
7
7
  # Control command: forgets (removes) a fact from the
8
8
  # agent's memory. Idempotent: forgetting something that doesn't exist is not an error
9
9
  # (`existed: false`). Scoped by `tenant`. -> { existed: bool }.
10
+ #
11
+ # with an `audit_store:` collaborator, appends a content-free
12
+ # audit line (old_hash only — the deleted value's digest, never the
13
+ # value). Optional: nil = no audit.
10
14
  class MemoryForgetFact
11
- def initialize(memory_store:, event_stream:)
15
+ def initialize(memory_store:, event_stream:, audit_store: nil)
12
16
  @memory_store = memory_store
13
17
  @event_stream = event_stream
18
+ @audit_store = audit_store
14
19
  end
15
20
 
16
21
  def call(command)
@@ -19,14 +24,25 @@ module Insika
19
24
  raise Insika::ValidationError, "key is required" if key.nil?
20
25
 
21
26
  tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
22
- existed = @memory_store.forget_fact(tenant: tenant, key: key)
27
+ customer = AgentPayload.presence(p[:customer])
28
+ operator = AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator"
29
+
30
+ scope = @memory_store.cell_for(tenant, customer)
31
+ old = @memory_store.get_fact(tenant: tenant, key: key, customer: customer)
32
+ existed = @memory_store.forget_fact(tenant: tenant, key: key, customer: customer)
33
+ @audit_store&.record(
34
+ cell: scope, action: "forget", actor: operator, key: key,
35
+ tenant: tenant, customer: customer,
36
+ old_hash: old && MemoryAuditStore.digest(old.value)
37
+ )
23
38
  @event_stream.emit(Insika::Event.new(
24
39
  type: :memory_fact_forgotten,
25
- data: { tenant: tenant, key: key, existed: existed },
40
+ data: { tenant: tenant, key: key, customer: customer,
41
+ existed: existed }.compact,
26
42
  meta: { at: Time.now.utc.iso8601 }
27
43
  ))
28
44
  { existed: existed }
29
45
  end
30
46
  end
31
47
  end
32
- end
48
+ end
@@ -9,10 +9,16 @@ module Insika
9
9
  # from WITHIN the turn (the `remember` tool); this Command is the HTTP surface the
10
10
  # Studio uses to edit facts directly. Scoped by `tenant` (nil = _default).
11
11
  # Synchronous; does not create a Task. -> Fact.
12
+ #
13
+ # the OPERATOR surface — every write stamps `origin: "operator"`
14
+ # (the `remember` tool keeps "engine") and, with an `audit_store:`
15
+ # collaborator, appends a content-free audit line (old/new digests). The
16
+ # audit is optional: nil = no audit (specs, minimal graphs).
12
17
  class MemoryPutFact
13
- def initialize(memory_store:, event_stream:)
18
+ def initialize(memory_store:, event_stream:, audit_store: nil)
14
19
  @memory_store = memory_store
15
20
  @event_stream = event_stream
21
+ @audit_store = audit_store
16
22
  end
17
23
 
18
24
  def call(command)
@@ -22,14 +28,27 @@ module Insika
22
28
  raise Insika::ValidationError, "value is required" if p[:value].nil?
23
29
 
24
30
  tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
25
- fact = @memory_store.put_fact(tenant: tenant, key: key, value: p[:value])
31
+ customer = AgentPayload.presence(p[:customer])
32
+ operator = AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator"
33
+
34
+ scope = @memory_store.cell_for(tenant, customer)
35
+ old = @memory_store.get_fact(tenant: tenant, key: key, customer: customer)
36
+ fact = @memory_store.put_fact(tenant: tenant, key: key, value: p[:value],
37
+ customer: customer, origin: "operator",
38
+ expires_at: AgentPayload.presence(p[:expires_at]))
39
+ @audit_store&.record(
40
+ cell: scope, action: "put", actor: operator, key: key,
41
+ tenant: tenant, customer: customer,
42
+ old_hash: old && MemoryAuditStore.digest(old.value),
43
+ new_hash: MemoryAuditStore.digest(fact.value)
44
+ )
26
45
  @event_stream.emit(Insika::Event.new(
27
46
  type: :memory_fact_put,
28
- data: { tenant: tenant, key: key },
47
+ data: { tenant: tenant, key: key, customer: customer }.compact,
29
48
  meta: { at: Time.now.utc.iso8601 }
30
49
  ))
31
50
  fact
32
51
  end
33
52
  end
34
53
  end
35
- end
54
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # — the human's answer, the ONLY path that lands a
8
+ # skill. Snapshot FIRST (the pre-promotion state), then the two existing
9
+ # write commands, then the append-only log row (D8). A promotion row's
10
+ # `rolled_back_at` makes the log the single ledger; re-promotion after a
11
+ # rollback is a NEW row with a NEW snapshot. The candidate flips to
12
+ # `promoted` ONLY after the writes land (record-after).
13
+ class PromoteHarvest
14
+ def initialize(harvest_store:, skill_store:, skill_catalog:, profile_source:,
15
+ criterion:, conversion_gate:, event_stream:,
16
+ write_skill: nil, set_skill_agents: nil)
17
+ @harvest_store = harvest_store
18
+ @skill_store = skill_store
19
+ @skill_catalog = skill_catalog
20
+ @profiles = ProfileSource.coerce(profile_source)
21
+ @criterion = criterion
22
+ @conversion_gate = conversion_gate
23
+ @event_stream = event_stream
24
+ @write_skill = write_skill ||
25
+ Insika::Commands::WriteSkill.new(skill_store: skill_store,
26
+ skill_catalog: skill_catalog,
27
+ event_stream: event_stream)
28
+ @set_skill_agents = set_skill_agents ||
29
+ Insika::Commands::SetSkillAgents.new(profile_source: @profiles,
30
+ event_stream: event_stream)
31
+ end
32
+
33
+ # payload: { candidate_id:, operator?, note? }
34
+ # operator from payload || command.meta[:operator] || "operator"
35
+ # -> Candidate (promoted | gated — the conversion re-check moved, D8-bis)
36
+ def call(command)
37
+ p = AgentPayload.symbolize(command.payload)
38
+ candidate_id = AgentPayload.presence(p[:candidate_id])
39
+ raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
40
+
41
+ candidate = @harvest_store.find_candidate(candidate_id) ||
42
+ (raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
43
+ operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
44
+
45
+ # D8-bis — the conversion ruler is RE-READ at the moment the skill
46
+ # lands (cheap: a fold read). A dip below the frozen baseline since
47
+ # gating PARKS the candidate at gated with the current numbers — the
48
+ # operator re-decides alertly. The eval gate is NOT re-run (expensive,
49
+ # and the skill body is not anchored to instruction files — nothing
50
+ # drifted to re-validate). A base graph without a conversion gate
51
+ # refuses with the named reason — never passes, never crashes.
52
+ current = if @conversion_gate
53
+ @conversion_gate.call(tenant: command.meta[:tenant], agent: candidate.agent)
54
+ else
55
+ Insika::Harvest::ConversionGate::Result.new(
56
+ passed: false, reason: :no_conversion_gate_wired, metric: nil, window: nil,
57
+ current: nil, baseline: nil, threshold: nil, snapshot_ref: nil
58
+ )
59
+ end
60
+ return @harvest_store.recheck_conversion(candidate.id, conversion_gate: current.to_h) unless current.passed
61
+
62
+ # The criterion must STILL be the frozen one (D8-bis): a criterion that
63
+ # changed between boot and promotion is a criterion nobody froze.
64
+ unless @criterion && @criterion.sha == candidate.criterion_sha
65
+ raise Insika::ValidationError,
66
+ "the harvest criterion changed since boot (#{candidate.criterion_sha.inspect} -> " \
67
+ "#{@criterion&.sha.inspect}) — re-gate the candidate against the new frozen file"
68
+ end
69
+
70
+ # Snapshot first, then the writes, then the log row, then the flip.
71
+ existed = @skill_store.get(candidate.name, agent: candidate.agent)
72
+ # D8 + the review fix: the snapshot records EVERY agent the skill was
73
+ # enabled for BEFORE this promotion (SetSkillAgents' vocabulary). A
74
+ # homonymous skill allowed by B must stay allowed when A promotes —
75
+ # and the rollback restores exactly this set, B included.
76
+ enabled_for = agents_with_skill(candidate.name)
77
+ snapshot = @harvest_store.create_snapshot(
78
+ agent: candidate.agent, skill: candidate.name, content: existed,
79
+ existed: !existed.nil?,
80
+ enabled_for: enabled_for
81
+ )
82
+
83
+ @write_skill.call(Insika::Command.build(:write_skill,
84
+ { name: candidate.name, content: candidate.body,
85
+ agent: candidate.agent }))
86
+ # ENABLE for the candidate's agent WITHOUT disabling anyone else who
87
+ # already had it: the wanted set is the pre-promotion holders plus the
88
+ # candidate.
89
+ @set_skill_agents.call(Insika::Command.build(:set_skill_agents,
90
+ { name: candidate.name,
91
+ agent_ids: enabled_for | [candidate.agent] }))
92
+
93
+ promotion = @harvest_store.append_promotion(
94
+ id: SecureRandom.uuid, agent: candidate.agent, skill: candidate.name,
95
+ origin: candidate.origin, eval_ref: "cand:#{candidate.id}",
96
+ conversion_ref: candidate.conversion_gate && candidate.conversion_gate["snapshot_ref"],
97
+ approver: operator, snapshot_ref: snapshot.id,
98
+ criterion_sha: candidate.criterion_sha
99
+ )
100
+ @harvest_store.mark_promoted(candidate.id, promotion_ref: promotion.id)
101
+
102
+ emit(:skill_promoted, agent: candidate.agent, skill: candidate.name,
103
+ candidate_id: candidate.id, snapshot_ref: snapshot.id,
104
+ promotion_ref: promotion.id, approver: operator)
105
+ @harvest_store.find_candidate(candidate.id)
106
+ end
107
+
108
+ private
109
+
110
+ def profile_of(agent_id)
111
+ @profiles.fetch(agent_id) ||
112
+ (raise Insika::NotFoundError, "agent '#{agent_id}' not configured")
113
+ end
114
+
115
+ # Every agent whose allowlist currently names the skill — the pre-
116
+ # promotion holder set (an agent with skills=nil has ALL, so it holds
117
+ # every name and is not "removable"; SetSkillAgents skips it).
118
+ def agents_with_skill(name)
119
+ @profiles.all.select { |p| Array(p.skills).include?(name) }.map(&:id)
120
+ end
121
+
122
+ def emit(type, **data)
123
+ # ids and refs only — the skill body never enters the stream (D7).
124
+ @event_stream.emit(Insika::Event.new(
125
+ type: type, data: data, meta: { at: Time.now.utc.iso8601 }
126
+ ))
127
+ end
128
+ end
129
+ end
130
+ end