@heretek-ai/epistemic-swarm 1.1.4 → 2.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.
package/src/rpc.ts CHANGED
@@ -1,7 +1,9 @@
1
- // Server side of the TUI previews. Human-only mutations (approve, trust,
2
- // resume) happen at a terminal with the passphrase-sealed key, never via RPC:
3
- // the TUI previews, then points at `es approve`, `es trust`, `es factory
4
- // resume`. Only lspInstall still mutates via a preview token.
1
+ // Server side of the TUI previews. Approvals complete in the TUI process
2
+ // (masked dialog, in-process signing, ADR 0002); trust and resume sign at a
3
+ // terminal with the passphrase-sealed key. The TUI previews, then either
4
+ // signs itself or points at `es trust` / `es factory resume`. No approve,
5
+ // trust or resume call ever crosses RPC: only lspInstall still mutates via
6
+ // a preview token.
5
7
  import { randomBytes } from "node:crypto"
6
8
  import {
7
9
  type ApprovalStage,
@@ -19,6 +21,7 @@ import {
19
21
  headline,
20
22
  installServer,
21
23
  isTrusted,
24
+ listBrainstormRuns,
22
25
  loadGatesConfig,
23
26
  readFrontier,
24
27
  readIdeas,
@@ -54,14 +57,14 @@ export function createRpcHandlers(
54
57
  tickets.set(token, { kind, subject: hashJson(subject), expires: Date.now() + TTL_MS })
55
58
  return token
56
59
  }
57
- const redeem = (token: string, kind: string, subject: unknown) => {
60
+ const redeemHash = (token: string, kind: string, subjectHash: string) => {
58
61
  const ticket = tickets.get(token)
59
62
  tickets.delete(token)
60
63
  if (!ticket || ticket.kind !== kind || ticket.expires < Date.now())
61
64
  throw new Error("The confirmation expired or does not match; preview again.")
62
- if (ticket.subject !== hashJson(subject))
63
- throw new Error("What you confirmed changed since the preview; preview again.")
65
+ if (ticket.subject !== subjectHash) throw new Error("What you confirmed changed since the preview; preview again.")
64
66
  }
67
+ const redeem = (token: string, kind: string, subject: unknown) => redeemHash(token, kind, hashJson(subject))
65
68
  const refused = (context: any, error: unknown) =>
66
69
  context.error("refused", error instanceof Error ? error.message : String(error), {
67
70
  reason: error instanceof Error ? error.message : String(error),
@@ -181,14 +184,19 @@ export function createRpcHandlers(
181
184
  }
182
185
  },
183
186
  brainstormState: async () => {
184
- const plan = await readPlan(runtime.root).catch(() => undefined)
185
- if (!plan) return { active: false }
186
- const ideas = await readIdeas(runtime.root).catch(() => [])
187
- const scores = await readScores(runtime.root).catch(() => [])
188
- const result = await readResult(runtime.root).catch(() => undefined)
187
+ const runs = await listBrainstormRuns(runtime.root).catch(() => [] as string[])
188
+ // The panel shows the default run, else the latest; every run stays one CLI call away.
189
+ const run = runs.includes("default") ? "default" : runs[runs.length - 1]
190
+ const plan = run ? await readPlan(runtime.root, run).catch(() => undefined) : undefined
191
+ if (!plan || !run) return { active: false, runs }
192
+ const ideas = await readIdeas(runtime.root, run).catch(() => [])
193
+ const scores = await readScores(runtime.root, run).catch(() => [])
194
+ const result = await readResult(runtime.root, run).catch(() => undefined)
189
195
  const survivors = ideas.filter((idea) => !idea.duplicateOf)
190
196
  return {
191
197
  active: true,
198
+ run,
199
+ runs,
192
200
  brief: plan.brief.idea,
193
201
  lenses: plan.lenses,
194
202
  ideas: survivors.length,
@@ -221,6 +229,9 @@ export function createRpcHandlers(
221
229
  ],
222
230
  problems: [],
223
231
  token: issue(`approve:${stage}`, subject.subject),
232
+ // ADR 0002 I3: the TUI signs only this subject; approveStage refuses a mismatch.
233
+ subjectHash: hashJson(subject.subject),
234
+ issuedAt: Date.now(),
224
235
  }
225
236
  } catch (error) {
226
237
  return {
@@ -231,6 +242,23 @@ export function createRpcHandlers(
231
242
  }
232
243
  }
233
244
  },
245
+ /**
246
+ * Validate-only redeem of an approval preview ticket (ADR 0002 I3): the
247
+ * TUI calls this exactly once before signing in-process. It enforces the
248
+ * 120 s TTL and single-use and checks the subject hash, but signs
249
+ * nothing — there is still no approve RPC (#49).
250
+ */
251
+ redeemApproval: async (input: unknown, context: any) => {
252
+ const { stage, token, subjectHash } = input as { stage: ApprovalStage; token: unknown; subjectHash: unknown }
253
+ try {
254
+ if (typeof token !== "string" || typeof subjectHash !== "string")
255
+ throw new Error("The confirmation expired or does not match; preview again.")
256
+ redeemHash(token, `approve:${stage}`, subjectHash)
257
+ return { ok: true }
258
+ } catch (error) {
259
+ return refused(context, error)
260
+ }
261
+ },
234
262
  previewTrust: async () => {
235
263
  const subject = await trustSubject()
236
264
  const trusted =
package/src/runtime.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // Per-location runtime shared by the plugin's hooks, tools, commands and RPC.
2
- // Options are layered: the canonical config (global file → project file) with
3
- // plugin options as the most specific override. Plugin options are config
4
- // keys validated by the same strict schema; unknown keys are ignored with a
5
- // warning the TUI shows once.
2
+ // The server only wires option layering (parseOptions → createRuntime); the
3
+ // merge itself lives in core (loadEsConfig/mergeConfig): the canonical config
4
+ // (global file → project file) with plugin options as the most specific
5
+ // override. Plugin options are config keys validated by the same strict
6
+ // schema; unknown keys are ignored with a warning the TUI shows once.
6
7
  import {
7
8
  stateDir as defaultStateDir,
8
9
  type EsConfig,
@@ -13,6 +14,7 @@ import {
13
14
  loadEsConfig,
14
15
  type PolicyContext,
15
16
  } from "@heretek-ai/es-core"
17
+ import type { ModelProblem } from "./agents.ts"
16
18
  import { ghPrOpener } from "./pr.ts"
17
19
 
18
20
  /** Plugin options from opencode.json, split into config keys and the rest. */
@@ -47,6 +49,8 @@ export interface Runtime {
47
49
  readonly stateDir: string
48
50
  readonly factory: Factory
49
51
  readonly lsp: LspManager
52
+ /** Seats whose configured model is missing on the host (fail-closed, #96). */
53
+ readonly modelProblems: Map<string, ModelProblem>
50
54
  policy(): Promise<PolicyContext>
51
55
  }
52
56
 
@@ -65,6 +69,9 @@ export async function createRuntime(root: string, parsed: ParsedOptions): Promis
65
69
  ...(config.searchProvider ? { searchProvider: config.searchProvider } : {}),
66
70
  }
67
71
  const stateDir = options.stateDir ?? defaultStateDir()
72
+ // One manager per project (#275.1): servers start lazily per (server, root)
73
+ // and are reaped by the idle sweeper between sessions, stopping only on
74
+ // plugin unload (see server.ts) — long-lived servers outlive sessions.
68
75
  const lsp = new LspManager({ root, stateDir })
69
76
  const factory = new Factory(root, {
70
77
  gates: gateRunner({ stateDir }),
@@ -73,6 +80,7 @@ export async function createRuntime(root: string, parsed: ParsedOptions): Promis
73
80
  researchDepth: config.research.depth,
74
81
  ...(config.domainPack ? { domainPack: config.domainPack } : {}),
75
82
  ...(config.audit.phase === "required" ? { auditPhase: "required" as const } : {}),
83
+ ...(config.models ? { models: config.models } : {}),
76
84
  })
77
85
  return {
78
86
  root,
@@ -83,9 +91,18 @@ export async function createRuntime(root: string, parsed: ParsedOptions): Promis
83
91
  stateDir,
84
92
  factory,
85
93
  lsp,
94
+ modelProblems: new Map(),
86
95
  async policy() {
87
96
  const worktree = await factory.activeWorktree().catch(() => undefined)
88
- return { root, stateDir, ...(worktree ? { worktree } : {}) }
97
+ // S1.7: the mode-aware REPORT.md grant needs the sealed run mode. Read
98
+ // it from the sealed run state; when it cannot be read (no run, a
99
+ // missing or forged seal) leave it absent so both writers stay denied
100
+ // (fail closed, trust/policy.ts).
101
+ const runMode = await factory
102
+ .read()
103
+ .then((state) => state?.mode)
104
+ .catch(() => undefined)
105
+ return { root, stateDir, ...(worktree ? { worktree } : {}), ...(runMode ? { runMode } : {}) }
89
106
  },
90
107
  }
91
108
  }
package/src/seats.ts CHANGED
@@ -3,7 +3,14 @@
3
3
  // tool and last activity in .factory/runtime/seats.json. es_status, `es
4
4
  // status`, the dashboard and the headless driver read it; the foreground guard
5
5
  // and the continuation ask it what is running right now.
6
- import { agentSpec, type SeatRecord, updateSeats } from "@heretek-ai/es-core"
6
+ import {
7
+ agentSpec,
8
+ assertModelDiversity,
9
+ ModelDiversityError,
10
+ type SeatModelMap,
11
+ type SeatRecord,
12
+ updateSeats,
13
+ } from "@heretek-ai/es-core"
7
14
  import type { Runtime } from "./runtime.ts"
8
15
 
9
16
  /** Activity-only updates (tool calls, steps) are persisted at most this often per seat. */
@@ -29,6 +36,26 @@ export interface SeatTracker {
29
36
  hasRunningChildren(parentID: string): boolean
30
37
  }
31
38
 
39
+ /** Seats covered by the heterogeneous-model policy (#170): the programmer and both QA seats. */
40
+ const DIVERSE_SEATS: ReadonlySet<string> = new Set(["es-programmer", "es-qa-functional", "es-qa-adversarial"])
41
+
42
+ /**
43
+ * Refusal text when launching `target` would run the programmer and QA on the
44
+ * same model provider (shared weights share blind spots); undefined when the
45
+ * target is not a build seat or diversity holds. Unverified configs (unset
46
+ * seats) launch unless models.requireDiversity is set.
47
+ */
48
+ export function seatModelRefusal(target: string, models: SeatModelMap | undefined): string | undefined {
49
+ if (!DIVERSE_SEATS.has(target)) return undefined
50
+ try {
51
+ assertModelDiversity(models)
52
+ return undefined
53
+ } catch (error) {
54
+ if (error instanceof ModelDiversityError) return error.message
55
+ throw error
56
+ }
57
+ }
58
+
32
59
  /** Factory seats are the registry's subagents; primary agents (factory, grill) are drivers, not seats. */
33
60
  const isSeat = (agent: string | undefined): agent is string => agentSpec(agent)?.mode === "subagent"
34
61