@heretek-ai/epistemic-swarm 1.6.0 → 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/package.json +2 -2
- package/src/agents.ts +24 -3
- package/src/hooks.ts +13 -1
- package/src/policy.ts +16 -4
- package/src/pr.ts +7 -3
- package/src/runtime.ts +9 -4
- package/src/seats.ts +28 -1
- package/src/server.ts +356 -297
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heretek-ai/epistemic-swarm",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"esRelease": {
|
|
5
5
|
"order": 3
|
|
6
6
|
},
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"test": "bun test"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@heretek-ai/es-core": "
|
|
47
|
+
"@heretek-ai/es-core": "2.3.0",
|
|
48
48
|
"@opencode/plugin": "2.0.24"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
package/src/agents.ts
CHANGED
|
@@ -48,11 +48,32 @@ export function permissionRules(spec: AgentSpec, mcpServers: readonly string[]):
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export function modelFor(spec: AgentSpec, options: PluginOptions) {
|
|
51
|
-
const ref = options.models?.agents?.[spec.id] ?? options.models?.[spec.tier]
|
|
51
|
+
const ref = options.models?.agents?.[spec.id] ?? roleModel(spec.id, options) ?? options.models?.[spec.tier]
|
|
52
52
|
if (!ref) return undefined
|
|
53
53
|
return parseModelRef(ref)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Heterogeneous-model role keys (#170): the seat's models.<role> key, so enforcement and reality agree. */
|
|
57
|
+
function roleModel(agentId: string, options: PluginOptions): string | undefined {
|
|
58
|
+
const role = ROLE_MODEL_KEY[agentId]
|
|
59
|
+
return role ? options.models?.[role] : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ROLE_MODEL_KEY: Record<string, "programmer" | "qa" | "auditor"> = {
|
|
63
|
+
"es-programmer": "programmer",
|
|
64
|
+
"es-qa-functional": "qa",
|
|
65
|
+
"es-qa-adversarial": "qa",
|
|
66
|
+
"es-auditor-thesis": "auditor",
|
|
67
|
+
"es-auditor-antithesis": "auditor",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The config key a seat's model resolved from (for remediation text). */
|
|
71
|
+
function modelKey(spec: AgentSpec, options: PluginOptions): string {
|
|
72
|
+
if (options.models?.agents?.[spec.id] !== undefined) return `models.agents.${spec.id}`
|
|
73
|
+
if (roleModel(spec.id, options) !== undefined) return `models.${ROLE_MODEL_KEY[spec.id]!}`
|
|
74
|
+
return `models.${spec.tier}`
|
|
75
|
+
}
|
|
76
|
+
|
|
56
77
|
/** A configured model ref the host does not have: the seat is refused at launch (#96). */
|
|
57
78
|
export interface ModelProblem {
|
|
58
79
|
readonly agent: string
|
|
@@ -80,9 +101,9 @@ export function modelProblems(
|
|
|
80
101
|
): ModelProblem[] {
|
|
81
102
|
const problems: ModelProblem[] = []
|
|
82
103
|
for (const spec of specs) {
|
|
83
|
-
const ref = options.models?.agents?.[spec.id] ?? options.models?.[spec.tier]
|
|
104
|
+
const ref = options.models?.agents?.[spec.id] ?? roleModel(spec.id, options) ?? options.models?.[spec.tier]
|
|
84
105
|
if (ref === undefined) continue
|
|
85
|
-
const key =
|
|
106
|
+
const key = modelKey(spec, options)
|
|
86
107
|
const parsed = parseModelRef(ref)
|
|
87
108
|
if (parsed === undefined || !exists(parsed.providerID, parsed.id))
|
|
88
109
|
problems.push({
|
package/src/hooks.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// "ask" defers to the host permission prompt)
|
|
6
6
|
// PostToolUse(Failure)← tool.execute.after (feedback/context appended, output replaced)
|
|
7
7
|
// UserPromptSubmit ← session prompt admission (block rejects, context appended)
|
|
8
|
-
// SessionStart ← first prompt of a session (startup)
|
|
8
|
+
// SessionStart ← first prompt of a session (startup), a forked session
|
|
9
|
+
// (fork), and after compaction (compact). Resume/clear
|
|
10
|
+
// have no host bus events, so they stay intentionally
|
|
11
|
+
// unsupported (the engine notices unmatched sources).
|
|
9
12
|
// PermissionRequest ← permission.evaluate when the host would ask
|
|
10
13
|
// x-es.ShellPreExec ← shell.create.before
|
|
11
14
|
// Stop (emulated) ← session.execution.succeeded: a block re-prompts the
|
|
@@ -252,6 +255,15 @@ export async function createHookBridge(
|
|
|
252
255
|
)
|
|
253
256
|
return
|
|
254
257
|
}
|
|
258
|
+
if (event.type === "session.forked" && engine.has("SessionStart")) {
|
|
259
|
+
// A forked session is a new session inheriting its parent's history
|
|
260
|
+
// (#289): run the SessionStart hooks filtered to "fork" now, and mark
|
|
261
|
+
// the fork started so its first prompt does not refire "startup".
|
|
262
|
+
const agent = await agentOf(sessionID)
|
|
263
|
+
stash(sessionContext, sessionID, (await engine.sessionStart(base(sessionID, agent), "fork")).additionalContext)
|
|
264
|
+
startedSessions.add(sessionID)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
255
267
|
if (event.type !== "session.execution.succeeded" || !engine.has("Stop")) return
|
|
256
268
|
const count = continuations.get(sessionID) ?? 0
|
|
257
269
|
if (count >= STOP_CAP) {
|
package/src/policy.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { Error as ToolError } from "@opencode/plugin/promise/tool"
|
|
23
23
|
import { HOST_WEB_TOOLS } from "./agents.ts"
|
|
24
24
|
import type { Runtime } from "./runtime.ts"
|
|
25
|
-
import type
|
|
25
|
+
import { type SeatTracker, seatModelRefusal } from "./seats.ts"
|
|
26
26
|
|
|
27
27
|
const WRITE_TOOLS = new Set(["write", "edit"])
|
|
28
28
|
|
|
@@ -85,17 +85,24 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
|
|
|
85
85
|
if (event.tool === "subagent" && typeof input.agent === "string") {
|
|
86
86
|
const problem = runtime.modelProblems?.get(input.agent)
|
|
87
87
|
if (problem) deny(problem.message)
|
|
88
|
+
// Heterogeneous models (#170): a programmer/QA seat that would share
|
|
89
|
+
// weights with the other side never launches.
|
|
90
|
+
const refusal = seatModelRefusal(input.agent, runtime.config?.models)
|
|
91
|
+
if (refusal) deny(`Seat "${input.agent}" cannot launch: ${refusal}`)
|
|
88
92
|
}
|
|
89
93
|
if (seat && event.tool === "subagent") guardSeatLaunch({ agent: event.agent, input }, seats)
|
|
90
94
|
if (seat && event.sessionID) await seats?.onTool(event.sessionID, event.agent, event.tool)
|
|
91
|
-
|
|
95
|
+
// STOP halts seats, es_* tools, and seat launches whoever fires them (#227):
|
|
96
|
+
// a user agent must not route around the kill-switch via `subagent`.
|
|
97
|
+
if (seat || event.tool.startsWith("es_") || event.tool === "subagent") {
|
|
92
98
|
const stop = await checkStopFile(runtime.root)
|
|
93
99
|
if (stop.stopped)
|
|
94
100
|
deny(`The factory is stopped (.factory/STOP: ${stop.reason}). A human must remove it and resume.`)
|
|
95
101
|
}
|
|
96
102
|
// A halted run (spend ceiling, runtime cap, drift, STOP) stops every seat
|
|
97
103
|
// tool except es_status, so no seat keeps spending through its own es_* tools.
|
|
98
|
-
|
|
104
|
+
// Seat launches count as seat tools whoever fires them (#227).
|
|
105
|
+
if ((seat || event.tool === "subagent") && event.tool !== "es_status") {
|
|
99
106
|
const state = await runtime.factory.read().catch(() => undefined)
|
|
100
107
|
if (state?.stage === "HALTED")
|
|
101
108
|
deny(`The factory is halted: ${state.halt?.reason}. Stop and report; a human must resume.`)
|
|
@@ -174,7 +181,10 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
|
|
|
174
181
|
? patchTargets(input.patchText)
|
|
175
182
|
: []
|
|
176
183
|
if (!files.length) return
|
|
177
|
-
|
|
184
|
+
// The post-edit diagnostics are a language-server channel (#277.2): seats
|
|
185
|
+
// scoped to `lsp: "none"` never get them (their tool rules already hide
|
|
186
|
+
// `lsp_*`). Navigating seats and the user's own agents keep the channel.
|
|
187
|
+
if (runtime.options.lspAfterEdit !== false && agentSpec(event.agent)?.lsp !== "none") {
|
|
178
188
|
const lines: string[] = []
|
|
179
189
|
for (const file of files) {
|
|
180
190
|
const absolute = path.resolve(runtime.root, file)
|
|
@@ -222,6 +232,8 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
|
|
|
222
232
|
}) => {
|
|
223
233
|
const seat = seatOf(event.agent)
|
|
224
234
|
const context = await runtime.policy()
|
|
235
|
+
// The host's patch tool asks for `edit` too, once, with every target of
|
|
236
|
+
// the patch as a resource: there is no `patch` action to branch on (#220).
|
|
225
237
|
if (event.action === "edit" || event.action === "read") {
|
|
226
238
|
const decisions = event.resources.map((resource) =>
|
|
227
239
|
event.action === "edit"
|
package/src/pr.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Factory release PR opener backed by the GitHub CLI. Every factory push
|
|
2
|
+
// path goes through here: it pushes only the run's `factory/` integration
|
|
3
|
+
// branch — `branch === base` or a non-`factory/` branch is refused before
|
|
4
|
+
// any push — and opens a draft PR for a human to review and merge. (Other
|
|
5
|
+
// push paths exist outside the factory: `es improve --open-pr` pushes
|
|
6
|
+
// `improve/*` branches on its own human-run path; phase fast-forwards and
|
|
7
|
+
// fleet landings never touch the base branch; only a human merges.)
|
|
4
8
|
import type { PrOpener } from "@heretek-ai/es-core"
|
|
5
9
|
import { buildPrArgs, git, run } from "@heretek-ai/es-core"
|
|
6
10
|
|
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,
|
|
@@ -68,6 +69,9 @@ export async function createRuntime(root: string, parsed: ParsedOptions): Promis
|
|
|
68
69
|
...(config.searchProvider ? { searchProvider: config.searchProvider } : {}),
|
|
69
70
|
}
|
|
70
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.
|
|
71
75
|
const lsp = new LspManager({ root, stateDir })
|
|
72
76
|
const factory = new Factory(root, {
|
|
73
77
|
gates: gateRunner({ stateDir }),
|
|
@@ -76,6 +80,7 @@ export async function createRuntime(root: string, parsed: ParsedOptions): Promis
|
|
|
76
80
|
researchDepth: config.research.depth,
|
|
77
81
|
...(config.domainPack ? { domainPack: config.domainPack } : {}),
|
|
78
82
|
...(config.audit.phase === "required" ? { auditPhase: "required" as const } : {}),
|
|
83
|
+
...(config.models ? { models: config.models } : {}),
|
|
79
84
|
})
|
|
80
85
|
return {
|
|
81
86
|
root,
|
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
|
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Epistemic Swarm for OpenCode v2 (server plugin). Additive: registers our
|
|
2
2
|
// agents, tools, skills, commands, hooks and RPC through runtime transforms;
|
|
3
|
-
// writes no
|
|
3
|
+
// setup writes no project-tree files and no config files (the engine key goes
|
|
4
|
+
// to the masked state dir; spend and seat tracking later write
|
|
5
|
+
// .factory/runtime/* while sessions run). It never touches built-in agents or
|
|
6
|
+
// defaults.
|
|
4
7
|
import { readdir, readFile } from "node:fs/promises"
|
|
5
8
|
import path from "node:path"
|
|
6
9
|
import {
|
|
@@ -27,7 +30,7 @@ import { createPolicyHooks } from "./policy.ts"
|
|
|
27
30
|
import { createWebCache, PendingSearches, registerWebsearch } from "./research.ts"
|
|
28
31
|
import { createRpcHandlers } from "./rpc.ts"
|
|
29
32
|
import { EsRpc } from "./rpc-def.ts"
|
|
30
|
-
import { createRuntime, parseOptions } from "./runtime.ts"
|
|
33
|
+
import { createRuntime, parseOptions, type Runtime } from "./runtime.ts"
|
|
31
34
|
import { createSeatTracker } from "./seats.ts"
|
|
32
35
|
import { createSessionHooks, createSpendTracker } from "./session.ts"
|
|
33
36
|
import { registerTools } from "./tools.ts"
|
|
@@ -47,6 +50,346 @@ export function parseDeepResearchArgs(text: string): DeepResearchArgs {
|
|
|
47
50
|
return { query, ceiling }
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
/** The session surface slash commands use (the host's `ctx.session`; stubbed in tests). */
|
|
54
|
+
export interface CommandSession {
|
|
55
|
+
switchAgent(sessionID: string, agent: string): Promise<unknown>
|
|
56
|
+
sendPrompt(sessionID: string, prompt: Record<string, unknown>, text: string, delivery: unknown): Promise<unknown>
|
|
57
|
+
synthetic(sessionID: string, text: string): Promise<unknown>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One slash command: its name, description and host-independent body. */
|
|
61
|
+
export interface CommandSpec {
|
|
62
|
+
readonly name: string
|
|
63
|
+
readonly description: string
|
|
64
|
+
readonly execute: (args: {
|
|
65
|
+
readonly sessionID: string
|
|
66
|
+
readonly prompt: { readonly text?: string } & Record<string, unknown>
|
|
67
|
+
readonly delivery?: unknown
|
|
68
|
+
}) => Promise<void>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The hook-bridge surface slash commands read (status for `/hooks`). */
|
|
72
|
+
export interface CommandBridge {
|
|
73
|
+
readonly engine: {
|
|
74
|
+
refresh(): Promise<void>
|
|
75
|
+
status(): {
|
|
76
|
+
readonly handlers: number
|
|
77
|
+
readonly projectHandlers: number
|
|
78
|
+
readonly trusted: boolean
|
|
79
|
+
readonly projectLines: readonly string[]
|
|
80
|
+
readonly diagnostics: readonly string[]
|
|
81
|
+
}
|
|
82
|
+
readonly recent: readonly string[]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every slash command the plugin registers, as data: the host loop below
|
|
88
|
+
* adds each one, and tests invoke each body against a stub session to prove
|
|
89
|
+
* routing (seat/verb per command) and name uniqueness (one `research`).
|
|
90
|
+
*/
|
|
91
|
+
export function esCommandSpecs(
|
|
92
|
+
io: CommandSession,
|
|
93
|
+
runtime: Runtime,
|
|
94
|
+
bridge: CommandBridge,
|
|
95
|
+
runners: { readonly runGates?: typeof runGates; readonly git?: typeof git } = {},
|
|
96
|
+
): CommandSpec[] {
|
|
97
|
+
const specs: CommandSpec[] = []
|
|
98
|
+
const gates = runners.runGates ?? runGates
|
|
99
|
+
const gitStatus = runners.git ?? git
|
|
100
|
+
specs.push({
|
|
101
|
+
name: "grill",
|
|
102
|
+
description: "Grill an idea until the design and spend ceiling are settled (Epistemic Swarm)",
|
|
103
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
104
|
+
await io.switchAgent(sessionID, "grill")
|
|
105
|
+
await io.sendPrompt(
|
|
106
|
+
sessionID,
|
|
107
|
+
prompt,
|
|
108
|
+
prompt.text?.trim() ? `Grill me on: ${prompt.text}` : "Start grilling me on my idea.",
|
|
109
|
+
delivery,
|
|
110
|
+
)
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
specs.push({
|
|
114
|
+
name: "brainstorm",
|
|
115
|
+
description: "Fan out divergent lenses on an idea and return a diversified shortlist (Epistemic Swarm)",
|
|
116
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
117
|
+
await io.switchAgent(sessionID, "brainstormer")
|
|
118
|
+
await io.sendPrompt(
|
|
119
|
+
sessionID,
|
|
120
|
+
prompt,
|
|
121
|
+
prompt.text?.trim()
|
|
122
|
+
? `Brainstorm: ${prompt.text}`
|
|
123
|
+
: "Ask me for the idea to brainstorm (one sentence), then run the lens fan-out.",
|
|
124
|
+
delivery,
|
|
125
|
+
)
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
specs.push({
|
|
129
|
+
name: "harvest",
|
|
130
|
+
description: "Tear down competitor projects: licences, feature matrix, clean-room specs (Epistemic Swarm)",
|
|
131
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
132
|
+
await io.switchAgent(sessionID, "harvester")
|
|
133
|
+
await io.sendPrompt(
|
|
134
|
+
sessionID,
|
|
135
|
+
prompt,
|
|
136
|
+
prompt.text?.trim()
|
|
137
|
+
? `Darkharvest: ${prompt.text}`
|
|
138
|
+
: "Ask me for the teardown objective and the candidate projects, then run the harvest.",
|
|
139
|
+
delivery,
|
|
140
|
+
)
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
specs.push({
|
|
144
|
+
name: "design",
|
|
145
|
+
description: "Interview me into a design system: tokens, contrast gates, style guide (Epistemic Swarm)",
|
|
146
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
147
|
+
await io.switchAgent(sessionID, "designer")
|
|
148
|
+
await io.sendPrompt(
|
|
149
|
+
sessionID,
|
|
150
|
+
prompt,
|
|
151
|
+
prompt.text?.trim()
|
|
152
|
+
? `Run the design interview. Product: ${prompt.text}`
|
|
153
|
+
: "Ask me for the product name and start the design interview, one question at a time.",
|
|
154
|
+
delivery,
|
|
155
|
+
)
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
specs.push({
|
|
159
|
+
name: "scout",
|
|
160
|
+
description: "Find and vet open-source candidates for a feature: licenses, maintenance, CVEs (Epistemic Swarm)",
|
|
161
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
162
|
+
await io.switchAgent(sessionID, "scout")
|
|
163
|
+
await io.sendPrompt(
|
|
164
|
+
sessionID,
|
|
165
|
+
prompt,
|
|
166
|
+
prompt.text?.trim()
|
|
167
|
+
? `Scout: ${prompt.text}`
|
|
168
|
+
: "Ask me for the feature to scout and our license posture, then plan the candidates.",
|
|
169
|
+
delivery,
|
|
170
|
+
)
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
specs.push({
|
|
174
|
+
name: "research",
|
|
175
|
+
// One name, one body (#274): a leading `deep` runs the adversarial deep
|
|
176
|
+
// flow (switching to the deep-researcher); anything else reads the
|
|
177
|
+
// research stage (report, audit coverage, cached sources).
|
|
178
|
+
description:
|
|
179
|
+
"Deep research to a grounded report, or the research stage: report, audit coverage and cached sources (Epistemic Swarm)",
|
|
180
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
181
|
+
const say = async (text: string) => {
|
|
182
|
+
await io.synthetic(sessionID, text)
|
|
183
|
+
}
|
|
184
|
+
const text = prompt.text?.trim() ?? ""
|
|
185
|
+
if (text.split(/\s+/)[0]?.toLowerCase() !== "deep") {
|
|
186
|
+
const layout = factoryLayout(runtime.root)
|
|
187
|
+
const report = await readFile(layout.researchReport, "utf8").catch(() => undefined)
|
|
188
|
+
const coverage = await readJson<{
|
|
189
|
+
claims?: number
|
|
190
|
+
grounded?: number
|
|
191
|
+
untagged?: number
|
|
192
|
+
ungrounded?: number
|
|
193
|
+
malformed?: number
|
|
194
|
+
sources?: number
|
|
195
|
+
passed?: boolean
|
|
196
|
+
}>(path.join(layout.research, "coverage.json")).catch(() => undefined)
|
|
197
|
+
const sources = (await readdir(researchSourcesDir(runtime.root)).catch(() => [])).filter((file) =>
|
|
198
|
+
file.endsWith(".md"),
|
|
199
|
+
)
|
|
200
|
+
const lines = [
|
|
201
|
+
report === undefined
|
|
202
|
+
? "No research report yet (.factory/research/REPORT.md)."
|
|
203
|
+
: `Report: ${report.length} characters in .factory/research/REPORT.md.`,
|
|
204
|
+
coverage
|
|
205
|
+
? `Audit: ${coverage.grounded ?? 0}/${coverage.claims ?? 0} claims grounded (untagged ${coverage.untagged ?? 0}, ungrounded ${coverage.ungrounded ?? 0}, malformed ${coverage.malformed ?? 0}); cited sources ${coverage.sources ?? 0}; passed ${coverage.passed ? "yes" : "no"}.`
|
|
206
|
+
: "No coverage.json yet (es_research_complete writes it).",
|
|
207
|
+
`Cached sources: ${sources.length}.`,
|
|
208
|
+
]
|
|
209
|
+
return say(lines.join("\n"))
|
|
210
|
+
}
|
|
211
|
+
const parsed = parseDeepResearchArgs(text)
|
|
212
|
+
if ("error" in parsed && parsed.error === "usage") {
|
|
213
|
+
return say("Usage: /research deep <question> --max-usd N")
|
|
214
|
+
}
|
|
215
|
+
if ("error" in parsed) {
|
|
216
|
+
return say(
|
|
217
|
+
"Deep research needs a spend ceiling you set: /research deep <question> --max-usd N. Seats halt when the spend reaches it.",
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const { query, ceiling } = parsed
|
|
221
|
+
const existing = await runtime.factory.read().catch(() => undefined)
|
|
222
|
+
if (existing && existing.mode !== "research") {
|
|
223
|
+
return say(
|
|
224
|
+
"This project holds a build run. Start deep research in a directory without one (`es research deep <question> --output <dir> --max-usd N` at a terminal).",
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
if (existing?.stage === "HALTED") {
|
|
228
|
+
return say(
|
|
229
|
+
"This research run is halted; resume it first (`es factory resume` at a terminal, or /es-resume to preview).",
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
if (existing?.stage === "DONE") {
|
|
233
|
+
return say(
|
|
234
|
+
"This research run is done. For a new question, start one in a fresh directory (`es research deep <question> --output <dir> --max-usd N` at a terminal).",
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
if (!existing) {
|
|
238
|
+
try {
|
|
239
|
+
// The human typed /research deep: beginning the run is their action.
|
|
240
|
+
await runtime.factory.beginResearchRun({ objective: query, ceilingUSD: Number(ceiling) }, "human:tui")
|
|
241
|
+
} catch (error) {
|
|
242
|
+
return say(`Could not start the research run: ${error instanceof Error ? error.message : String(error)}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
await io.switchAgent(sessionID, "deep-researcher")
|
|
246
|
+
await io.sendPrompt(
|
|
247
|
+
sessionID,
|
|
248
|
+
prompt,
|
|
249
|
+
`${await runtime.factory.summary()}\nDeep research runs adversarially, with no human answering questions mid-flow; follow your Flow through es_research_complete.`,
|
|
250
|
+
delivery,
|
|
251
|
+
)
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
specs.push({
|
|
255
|
+
name: "audit",
|
|
256
|
+
description: "Open a code audit of the active phase or a path and run the auditor pair (Epistemic Swarm)",
|
|
257
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
258
|
+
const target = prompt.text?.trim()
|
|
259
|
+
const say = async (text: string) => {
|
|
260
|
+
await io.synthetic(sessionID, text)
|
|
261
|
+
}
|
|
262
|
+
if (!target) return say("Usage: /audit <active phase id | path in the project>")
|
|
263
|
+
const state = await runtime.factory.read().catch(() => undefined)
|
|
264
|
+
if (state?.spendCeilingUSD === undefined)
|
|
265
|
+
return say(
|
|
266
|
+
"Audits run inside a factory run with a spend ceiling. Start one at a terminal with `es audit <target> --max-usd N`, or run /grill first.",
|
|
267
|
+
)
|
|
268
|
+
let opened: Awaited<ReturnType<typeof runtime.factory.openAudit>>
|
|
269
|
+
try {
|
|
270
|
+
// The human typed /audit: opening it is their action.
|
|
271
|
+
opened = await runtime.factory.openAudit(
|
|
272
|
+
"human:tui",
|
|
273
|
+
parseAuditTarget(
|
|
274
|
+
target,
|
|
275
|
+
state.phases.map((phase) => phase.id),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return say(`Could not open the audit: ${error instanceof Error ? error.message : String(error)}`)
|
|
280
|
+
}
|
|
281
|
+
const { audit } = opened
|
|
282
|
+
await io.switchAgent(sessionID, "factory")
|
|
283
|
+
await io.sendPrompt(
|
|
284
|
+
sessionID,
|
|
285
|
+
prompt,
|
|
286
|
+
`${await runtime.factory.summary()}\nThe human opened code audit ${audit.id} (round ${audit.round}) on ${describeTarget(audit.target)}. Launch es-auditor-thesis and es-auditor-antithesis in parallel with the audit id and target; if they split, have es-manager break the tie (es_tiebreak with audit). Report the verdicts and the report path when the audit settles.`,
|
|
287
|
+
delivery,
|
|
288
|
+
)
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
specs.push({
|
|
292
|
+
name: "factory",
|
|
293
|
+
description: "Drive the build factory from its current stage (Epistemic Swarm)",
|
|
294
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
295
|
+
const state = await runtime.factory.read()
|
|
296
|
+
if (!state) {
|
|
297
|
+
await io.synthetic(sessionID, "No factory run here yet — run /grill first.")
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
await io.switchAgent(sessionID, "factory")
|
|
301
|
+
await io.sendPrompt(
|
|
302
|
+
sessionID,
|
|
303
|
+
prompt,
|
|
304
|
+
`${await runtime.factory.summary(state)}\nContinue the factory run from its current stage.${prompt.text?.trim() ? `\nHuman note: ${prompt.text}` : ""}`,
|
|
305
|
+
delivery,
|
|
306
|
+
)
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
specs.push({
|
|
310
|
+
name: "gates",
|
|
311
|
+
description: "Run the mechanical gates on changed files (Epistemic Swarm)",
|
|
312
|
+
execute: async ({ sessionID }) => {
|
|
313
|
+
const changed = (await gitStatus(runtime.root, ["status", "--porcelain=v1", "-uall"], { allowFail: true })).stdout
|
|
314
|
+
.split("\n")
|
|
315
|
+
.filter(Boolean)
|
|
316
|
+
.map((line) => line.slice(3).split(" -> ").at(-1)!)
|
|
317
|
+
const report = await gates({
|
|
318
|
+
root: runtime.root,
|
|
319
|
+
scope: changed.length ? "touched" : "full",
|
|
320
|
+
touched: changed,
|
|
321
|
+
stateDir: runtime.stateDir,
|
|
322
|
+
})
|
|
323
|
+
await io.synthetic(sessionID, formatReport(report))
|
|
324
|
+
},
|
|
325
|
+
})
|
|
326
|
+
specs.push({
|
|
327
|
+
name: "lsp",
|
|
328
|
+
description: "Language servers: configured, available and running (Epistemic Swarm)",
|
|
329
|
+
execute: async ({ sessionID }) => {
|
|
330
|
+
const status = await runtime.lsp.status()
|
|
331
|
+
const settings = await runtime.lsp.settings()
|
|
332
|
+
const lines = [status.enabled ? "LSP is on." : "LSP is disabled (lsp: false)."]
|
|
333
|
+
for (const server of settings.servers) {
|
|
334
|
+
const command = await runtime.lsp.command(server, runtime.root)
|
|
335
|
+
const running = status.running.filter((item) => item.id === server.id)
|
|
336
|
+
lines.push(
|
|
337
|
+
`${server.id} [${server.extensions.join(" ")}]: ${Array.isArray(command) ? `available (${command.join(" ")})` : command.unavailable}${running.length ? ` · running for ${running.length} root(s)` : ""}`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
lines.push(...status.diagnostics.map((item) => `! ${item}`))
|
|
341
|
+
await io.synthetic(sessionID, lines.join("\n"))
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
specs.push({
|
|
345
|
+
name: "hooks",
|
|
346
|
+
description: "Show the hook bridge: sources, handlers, trust and diagnostics (Epistemic Swarm)",
|
|
347
|
+
execute: async ({ sessionID }) => {
|
|
348
|
+
await bridge.engine.refresh()
|
|
349
|
+
const status = bridge.engine.status()
|
|
350
|
+
const trust =
|
|
351
|
+
status.projectHandlers === 0
|
|
352
|
+
? "nothing to trust"
|
|
353
|
+
: status.trusted
|
|
354
|
+
? "trusted"
|
|
355
|
+
: "NOT trusted: run /es-trust or `es trust`"
|
|
356
|
+
const lines = [
|
|
357
|
+
`Hook bridge: ${status.handlers} handler(s), ${status.projectHandlers} from the project (${trust}).`,
|
|
358
|
+
...status.projectLines,
|
|
359
|
+
...status.diagnostics.map((item) => `! ${item}`),
|
|
360
|
+
...bridge.engine.recent.slice(-10).map((item) => `recent error: ${item}`),
|
|
361
|
+
"Stop hooks are emulated after the turn ends (advisory for plain chat).",
|
|
362
|
+
]
|
|
363
|
+
await io.synthetic(sessionID, lines.join("\n"))
|
|
364
|
+
},
|
|
365
|
+
})
|
|
366
|
+
specs.push({
|
|
367
|
+
name: "status",
|
|
368
|
+
description: "Show the factory state (Epistemic Swarm)",
|
|
369
|
+
execute: async ({ sessionID }) => {
|
|
370
|
+
const state = await runtime.factory.read()
|
|
371
|
+
const liveness = await readLiveness(runtime.root, state)
|
|
372
|
+
const text = [await runtime.factory.summary(state), ...livenessLines(state, liveness)].join("\n")
|
|
373
|
+
await io.synthetic(sessionID, text)
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
specs.push({
|
|
377
|
+
name: "config",
|
|
378
|
+
description: "Show the effective Epistemic Swarm config and where it came from (Epistemic Swarm)",
|
|
379
|
+
execute: async ({ sessionID }) => {
|
|
380
|
+
const lines = [
|
|
381
|
+
runtime.configSources.length
|
|
382
|
+
? `Layers: ${runtime.configSources.join(" → ")} → plugin options`
|
|
383
|
+
: "Layers: defaults only (no config files)",
|
|
384
|
+
...runtime.warnings.map((warning) => `Warning: ${warning}`),
|
|
385
|
+
JSON.stringify(runtime.config, null, 2),
|
|
386
|
+
]
|
|
387
|
+
await io.synthetic(sessionID, lines.join("\n"))
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
return specs
|
|
391
|
+
}
|
|
392
|
+
|
|
50
393
|
export default Plugin.define({
|
|
51
394
|
id: PLUGIN_ID,
|
|
52
395
|
async setup(ctx) {
|
|
@@ -183,301 +526,13 @@ export default Plugin.define({
|
|
|
183
526
|
}) as any)
|
|
184
527
|
|
|
185
528
|
await ctx.command.transform((editor) => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
sessionID,
|
|
194
|
-
text: prompt.text?.trim() ? `Grill me on: ${prompt.text}` : "Start grilling me on my idea.",
|
|
195
|
-
delivery,
|
|
196
|
-
} as any)
|
|
197
|
-
},
|
|
198
|
-
})
|
|
199
|
-
editor.add({
|
|
200
|
-
name: "brainstorm",
|
|
201
|
-
description: "Fan out divergent lenses on an idea and return a diversified shortlist (Epistemic Swarm)",
|
|
202
|
-
execute: async ({ sessionID, prompt, delivery }) => {
|
|
203
|
-
await ctx.session.switchAgent({ sessionID, agent: "brainstormer" } as any)
|
|
204
|
-
await ctx.session.prompt({
|
|
205
|
-
...prompt,
|
|
206
|
-
sessionID,
|
|
207
|
-
text: prompt.text?.trim()
|
|
208
|
-
? `Brainstorm: ${prompt.text}`
|
|
209
|
-
: "Ask me for the idea to brainstorm (one sentence), then run the lens fan-out.",
|
|
210
|
-
delivery,
|
|
211
|
-
} as any)
|
|
212
|
-
},
|
|
213
|
-
})
|
|
214
|
-
editor.add({
|
|
215
|
-
name: "harvest",
|
|
216
|
-
description: "Tear down competitor projects: licences, feature matrix, clean-room specs (Epistemic Swarm)",
|
|
217
|
-
execute: async ({ sessionID, prompt, delivery }) => {
|
|
218
|
-
await ctx.session.switchAgent({ sessionID, agent: "harvester" } as any)
|
|
219
|
-
await ctx.session.prompt({
|
|
220
|
-
...prompt,
|
|
221
|
-
sessionID,
|
|
222
|
-
text: prompt.text?.trim()
|
|
223
|
-
? `Darkharvest: ${prompt.text}`
|
|
224
|
-
: "Ask me for the teardown objective and the candidate projects, then run the harvest.",
|
|
225
|
-
delivery,
|
|
226
|
-
} as any)
|
|
227
|
-
},
|
|
228
|
-
})
|
|
229
|
-
editor.add({
|
|
230
|
-
name: "design",
|
|
231
|
-
description: "Interview me into a design system: tokens, contrast gates, style guide (Epistemic Swarm)",
|
|
232
|
-
execute: async ({ sessionID, prompt, delivery }) => {
|
|
233
|
-
await ctx.session.switchAgent({ sessionID, agent: "designer" } as any)
|
|
234
|
-
await ctx.session.prompt({
|
|
235
|
-
...prompt,
|
|
236
|
-
sessionID,
|
|
237
|
-
text: prompt.text?.trim()
|
|
238
|
-
? `Run the design interview. Product: ${prompt.text}`
|
|
239
|
-
: "Ask me for the product name and start the design interview, one question at a time.",
|
|
240
|
-
delivery,
|
|
241
|
-
} as any)
|
|
242
|
-
},
|
|
243
|
-
})
|
|
244
|
-
editor.add({
|
|
245
|
-
name: "scout",
|
|
246
|
-
description: "Find and vet open-source candidates for a feature: licenses, maintenance, CVEs (Epistemic Swarm)",
|
|
247
|
-
execute: async ({ sessionID, prompt, delivery }) => {
|
|
248
|
-
await ctx.session.switchAgent({ sessionID, agent: "scout" } as any)
|
|
249
|
-
await ctx.session.prompt({
|
|
250
|
-
...prompt,
|
|
251
|
-
sessionID,
|
|
252
|
-
text: prompt.text?.trim()
|
|
253
|
-
? `Scout: ${prompt.text}`
|
|
254
|
-
: "Ask me for the feature to scout and our license posture, then plan the candidates.",
|
|
255
|
-
delivery,
|
|
256
|
-
} as any)
|
|
257
|
-
},
|
|
258
|
-
})
|
|
259
|
-
editor.add({
|
|
260
|
-
name: "research",
|
|
261
|
-
description: "Adversarial deep research to a grounded report: thesis, antithesis, synthesis (Epistemic Swarm)",
|
|
262
|
-
execute: async ({ sessionID, prompt, delivery }) => {
|
|
263
|
-
const say = async (text: string) => {
|
|
264
|
-
await ctx.session.synthetic({ sessionID, text } as any)
|
|
265
|
-
}
|
|
266
|
-
const text = prompt.text?.trim() ?? ""
|
|
267
|
-
const parsed = parseDeepResearchArgs(text)
|
|
268
|
-
if ("error" in parsed && parsed.error === "usage") {
|
|
269
|
-
return say("Usage: /research deep <question> --max-usd N")
|
|
270
|
-
}
|
|
271
|
-
if ("error" in parsed) {
|
|
272
|
-
return say(
|
|
273
|
-
"Deep research needs a spend ceiling you set: /research deep <question> --max-usd N. Seats halt when the spend reaches it.",
|
|
274
|
-
)
|
|
275
|
-
}
|
|
276
|
-
const { query, ceiling } = parsed
|
|
277
|
-
const existing = await runtime.factory.read().catch(() => undefined)
|
|
278
|
-
if (existing && existing.mode !== "research") {
|
|
279
|
-
return say(
|
|
280
|
-
"This project holds a build run. Start deep research in a directory without one (`es research deep <question> --output <dir> --max-usd N` at a terminal).",
|
|
281
|
-
)
|
|
282
|
-
}
|
|
283
|
-
if (existing?.stage === "HALTED") {
|
|
284
|
-
return say(
|
|
285
|
-
"This research run is halted; resume it first (`es factory resume` at a terminal, or /es-resume to preview).",
|
|
286
|
-
)
|
|
287
|
-
}
|
|
288
|
-
if (existing?.stage === "DONE") {
|
|
289
|
-
return say(
|
|
290
|
-
"This research run is done. For a new question, start one in a fresh directory (`es research deep <question> --output <dir> --max-usd N` at a terminal).",
|
|
291
|
-
)
|
|
292
|
-
}
|
|
293
|
-
if (!existing) {
|
|
294
|
-
try {
|
|
295
|
-
// The human typed /research deep: beginning the run is their action.
|
|
296
|
-
await runtime.factory.beginResearchRun({ objective: query, ceilingUSD: Number(ceiling) }, "human:tui")
|
|
297
|
-
} catch (error) {
|
|
298
|
-
return say(`Could not start the research run: ${error instanceof Error ? error.message : String(error)}`)
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
await ctx.session.switchAgent({ sessionID, agent: "deep-researcher" } as any)
|
|
302
|
-
await ctx.session.prompt({
|
|
303
|
-
...prompt,
|
|
304
|
-
sessionID,
|
|
305
|
-
// The coordinator flow is the deep-researcher seat's system prompt
|
|
306
|
-
// (single-sourced in core); this only adds the run state.
|
|
307
|
-
text: `${await runtime.factory.summary()}\nDeep research runs adversarially, with no human answering questions mid-flow; follow your Flow through es_research_complete.`,
|
|
308
|
-
delivery,
|
|
309
|
-
} as any)
|
|
310
|
-
},
|
|
311
|
-
})
|
|
312
|
-
editor.add({
|
|
313
|
-
name: "audit",
|
|
314
|
-
description: "Open a code audit of the active phase or a path and run the auditor pair (Epistemic Swarm)",
|
|
315
|
-
execute: async ({ sessionID, prompt, delivery }) => {
|
|
316
|
-
const target = prompt.text?.trim()
|
|
317
|
-
const say = async (text: string) => {
|
|
318
|
-
await ctx.session.synthetic({ sessionID, text } as any)
|
|
319
|
-
}
|
|
320
|
-
if (!target) return say("Usage: /audit <active phase id | path in the project>")
|
|
321
|
-
const state = await runtime.factory.read().catch(() => undefined)
|
|
322
|
-
if (state?.spendCeilingUSD === undefined)
|
|
323
|
-
return say(
|
|
324
|
-
"Audits run inside a factory run with a spend ceiling. Start one at a terminal with `es audit <target> --max-usd N`, or run /grill first.",
|
|
325
|
-
)
|
|
326
|
-
let opened: Awaited<ReturnType<typeof runtime.factory.openAudit>>
|
|
327
|
-
try {
|
|
328
|
-
// The human typed /audit: opening it is their action.
|
|
329
|
-
opened = await runtime.factory.openAudit(
|
|
330
|
-
"human:tui",
|
|
331
|
-
parseAuditTarget(
|
|
332
|
-
target,
|
|
333
|
-
state.phases.map((phase) => phase.id),
|
|
334
|
-
),
|
|
335
|
-
)
|
|
336
|
-
} catch (error) {
|
|
337
|
-
return say(`Could not open the audit: ${error instanceof Error ? error.message : String(error)}`)
|
|
338
|
-
}
|
|
339
|
-
const { audit } = opened
|
|
340
|
-
await ctx.session.switchAgent({ sessionID, agent: "factory" } as any)
|
|
341
|
-
await ctx.session.prompt({
|
|
342
|
-
...prompt,
|
|
343
|
-
sessionID,
|
|
344
|
-
text: `${await runtime.factory.summary()}\nThe human opened code audit ${audit.id} (round ${audit.round}) on ${describeTarget(audit.target)}. Launch es-auditor-thesis and es-auditor-antithesis in parallel with the audit id and target; if they split, have es-manager break the tie (es_tiebreak with audit). Report the verdicts and the report path when the audit settles.`,
|
|
345
|
-
delivery,
|
|
346
|
-
} as any)
|
|
347
|
-
},
|
|
348
|
-
})
|
|
349
|
-
editor.add({
|
|
350
|
-
name: "factory",
|
|
351
|
-
description: "Drive the build factory from its current stage (Epistemic Swarm)",
|
|
352
|
-
execute: async ({ sessionID, prompt, delivery }) => {
|
|
353
|
-
const state = await runtime.factory.read()
|
|
354
|
-
if (!state) {
|
|
355
|
-
await ctx.session.synthetic({
|
|
356
|
-
sessionID,
|
|
357
|
-
text: "No factory run here yet — run /grill first.",
|
|
358
|
-
} as any)
|
|
359
|
-
return
|
|
360
|
-
}
|
|
361
|
-
await ctx.session.switchAgent({ sessionID, agent: "factory" } as any)
|
|
362
|
-
await ctx.session.prompt({
|
|
363
|
-
...prompt,
|
|
364
|
-
sessionID,
|
|
365
|
-
text: `${await runtime.factory.summary(state)}\nContinue the factory run from its current stage.${prompt.text?.trim() ? `\nHuman note: ${prompt.text}` : ""}`,
|
|
366
|
-
delivery,
|
|
367
|
-
} as any)
|
|
368
|
-
},
|
|
369
|
-
})
|
|
370
|
-
editor.add({
|
|
371
|
-
name: "gates",
|
|
372
|
-
description: "Run the mechanical gates on changed files (Epistemic Swarm)",
|
|
373
|
-
execute: async ({ sessionID }) => {
|
|
374
|
-
const changed = (await git(runtime.root, ["status", "--porcelain=v1", "-uall"], { allowFail: true })).stdout
|
|
375
|
-
.split("\n")
|
|
376
|
-
.filter(Boolean)
|
|
377
|
-
.map((line) => line.slice(3).split(" -> ").at(-1)!)
|
|
378
|
-
const report = await runGates({
|
|
379
|
-
root: runtime.root,
|
|
380
|
-
scope: changed.length ? "touched" : "full",
|
|
381
|
-
touched: changed,
|
|
382
|
-
stateDir: runtime.stateDir,
|
|
383
|
-
})
|
|
384
|
-
await ctx.session.synthetic({ sessionID, text: formatReport(report) } as any)
|
|
385
|
-
},
|
|
386
|
-
})
|
|
387
|
-
editor.add({
|
|
388
|
-
name: "lsp",
|
|
389
|
-
description: "Language servers: configured, available and running (Epistemic Swarm)",
|
|
390
|
-
execute: async ({ sessionID }) => {
|
|
391
|
-
const status = await runtime.lsp.status()
|
|
392
|
-
const settings = await runtime.lsp.settings()
|
|
393
|
-
const lines = [status.enabled ? "LSP is on." : "LSP is disabled (lsp: false)."]
|
|
394
|
-
for (const server of settings.servers) {
|
|
395
|
-
const command = await runtime.lsp.command(server, runtime.root)
|
|
396
|
-
const running = status.running.filter((item) => item.id === server.id)
|
|
397
|
-
lines.push(
|
|
398
|
-
`${server.id} [${server.extensions.join(" ")}]: ${Array.isArray(command) ? `available (${command.join(" ")})` : command.unavailable}${running.length ? ` · running for ${running.length} root(s)` : ""}`,
|
|
399
|
-
)
|
|
400
|
-
}
|
|
401
|
-
lines.push(...status.diagnostics.map((item) => `! ${item}`))
|
|
402
|
-
await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
|
|
403
|
-
},
|
|
404
|
-
})
|
|
405
|
-
editor.add({
|
|
406
|
-
name: "hooks",
|
|
407
|
-
description: "Show the hook bridge: sources, handlers, trust and diagnostics (Epistemic Swarm)",
|
|
408
|
-
execute: async ({ sessionID }) => {
|
|
409
|
-
await bridge.engine.refresh()
|
|
410
|
-
const status = bridge.engine.status()
|
|
411
|
-
const trust =
|
|
412
|
-
status.projectHandlers === 0
|
|
413
|
-
? "nothing to trust"
|
|
414
|
-
: status.trusted
|
|
415
|
-
? "trusted"
|
|
416
|
-
: "NOT trusted: run /es-trust or `es trust`"
|
|
417
|
-
const lines = [
|
|
418
|
-
`Hook bridge: ${status.handlers} handler(s), ${status.projectHandlers} from the project (${trust}).`,
|
|
419
|
-
...status.projectLines,
|
|
420
|
-
...status.diagnostics.map((item) => `! ${item}`),
|
|
421
|
-
...bridge.engine.recent.slice(-10).map((item) => `recent error: ${item}`),
|
|
422
|
-
"Stop hooks are emulated after the turn ends (advisory for plain chat).",
|
|
423
|
-
]
|
|
424
|
-
await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
|
|
425
|
-
},
|
|
426
|
-
})
|
|
427
|
-
editor.add({
|
|
428
|
-
name: "status",
|
|
429
|
-
description: "Show the factory state (Epistemic Swarm)",
|
|
430
|
-
execute: async ({ sessionID }) => {
|
|
431
|
-
const state = await runtime.factory.read()
|
|
432
|
-
const liveness = await readLiveness(runtime.root, state)
|
|
433
|
-
const text = [await runtime.factory.summary(state), ...livenessLines(state, liveness)].join("\n")
|
|
434
|
-
await ctx.session.synthetic({ sessionID, text } as any)
|
|
435
|
-
},
|
|
436
|
-
})
|
|
437
|
-
editor.add({
|
|
438
|
-
name: "config",
|
|
439
|
-
description: "Show the effective Epistemic Swarm config and where it came from (Epistemic Swarm)",
|
|
440
|
-
execute: async ({ sessionID }) => {
|
|
441
|
-
const lines = [
|
|
442
|
-
runtime.configSources.length
|
|
443
|
-
? `Layers: ${runtime.configSources.join(" → ")} → plugin options`
|
|
444
|
-
: "Layers: defaults only (no config files)",
|
|
445
|
-
...runtime.warnings.map((warning) => `Warning: ${warning}`),
|
|
446
|
-
JSON.stringify(runtime.config, null, 2),
|
|
447
|
-
]
|
|
448
|
-
await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
|
|
449
|
-
},
|
|
450
|
-
})
|
|
451
|
-
editor.add({
|
|
452
|
-
name: "research",
|
|
453
|
-
description: "Research stage: report, audit coverage and cached sources (Epistemic Swarm)",
|
|
454
|
-
execute: async ({ sessionID }) => {
|
|
455
|
-
const layout = factoryLayout(runtime.root)
|
|
456
|
-
const report = await readFile(layout.researchReport, "utf8").catch(() => undefined)
|
|
457
|
-
const coverage = await readJson<{
|
|
458
|
-
claims?: number
|
|
459
|
-
grounded?: number
|
|
460
|
-
untagged?: number
|
|
461
|
-
ungrounded?: number
|
|
462
|
-
malformed?: number
|
|
463
|
-
sources?: number
|
|
464
|
-
passed?: boolean
|
|
465
|
-
}>(path.join(layout.research, "coverage.json")).catch(() => undefined)
|
|
466
|
-
const sources = (await readdir(researchSourcesDir(runtime.root)).catch(() => [])).filter((file) =>
|
|
467
|
-
file.endsWith(".md"),
|
|
468
|
-
)
|
|
469
|
-
const lines = [
|
|
470
|
-
report === undefined
|
|
471
|
-
? "No research report yet (.factory/research/REPORT.md)."
|
|
472
|
-
: `Report: ${report.length} characters in .factory/research/REPORT.md.`,
|
|
473
|
-
coverage
|
|
474
|
-
? `Audit: ${coverage.grounded ?? 0}/${coverage.claims ?? 0} claims grounded (untagged ${coverage.untagged ?? 0}, ungrounded ${coverage.ungrounded ?? 0}, malformed ${coverage.malformed ?? 0}); cited sources ${coverage.sources ?? 0}; passed ${coverage.passed ? "yes" : "no"}.`
|
|
475
|
-
: "No coverage.json yet (es_research_complete writes it).",
|
|
476
|
-
`Cached sources: ${sources.length}.`,
|
|
477
|
-
]
|
|
478
|
-
await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
|
|
479
|
-
},
|
|
480
|
-
})
|
|
529
|
+
const io: CommandSession = {
|
|
530
|
+
switchAgent: (sessionID, agent) => ctx.session.switchAgent({ sessionID, agent } as any),
|
|
531
|
+
sendPrompt: (sessionID, prompt, text, delivery) =>
|
|
532
|
+
ctx.session.prompt({ ...prompt, sessionID, text, delivery } as any),
|
|
533
|
+
synthetic: (sessionID, text) => ctx.session.synthetic({ sessionID, text } as any),
|
|
534
|
+
}
|
|
535
|
+
for (const spec of esCommandSpecs(io, runtime, bridge as unknown as CommandBridge)) editor.add(spec as any)
|
|
481
536
|
})
|
|
482
537
|
|
|
483
538
|
// Spend: per-step host cost for factory-agent sessions (estimated from tokens when cost is 0).
|
|
@@ -495,6 +550,10 @@ export default Plugin.define({
|
|
|
495
550
|
// stream closed on unload
|
|
496
551
|
}
|
|
497
552
|
})()
|
|
553
|
+
// Language-server lifecycle (#275.1): servers stop here on plugin unload;
|
|
554
|
+
// between sessions only the manager's idle sweeper (600 s default) reaps
|
|
555
|
+
// them, so a long-lived server outlives its session by design. The running
|
|
556
|
+
// set stays visible through `/lsp` and the LSP panel.
|
|
498
557
|
return async () => {
|
|
499
558
|
abort.abort()
|
|
500
559
|
if (queued) clearTimeout(queued)
|