@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/dist/tui.js +493 -247
- package/package.json +7 -4
- package/src/agents.ts +77 -6
- package/src/approve-tui.ts +124 -0
- package/src/hooks.ts +15 -3
- package/src/masked-input.tsx +123 -0
- package/src/panels.tsx +7 -2
- package/src/policy.ts +28 -4
- package/src/pr.ts +17 -13
- package/src/research.ts +9 -2
- package/src/rpc-def.ts +30 -9
- package/src/rpc.ts +40 -12
- package/src/runtime.ts +22 -5
- package/src/seats.ts +28 -1
- package/src/server.ts +384 -245
- package/src/tools.ts +5 -1
- package/src/tui.tsx +61 -8
package/src/rpc.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Server side of the TUI previews.
|
|
2
|
-
//
|
|
3
|
-
// the TUI previews, then
|
|
4
|
-
//
|
|
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
|
|
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 !==
|
|
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
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
|