@mars.liu/dsh-cybernetics 0.2.0 → 0.6.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/README.md +40 -2
- package/cordis.patch.yml +18 -1
- package/docs/protocol.md +219 -3
- package/lib/client.js +231 -13
- package/package.json +13 -1
- package/src/controller.ts +113 -3
- package/src/executor.ts +414 -0
- package/src/index.ts +610 -42
- package/src/projection.ts +763 -0
- package/src/upstream.d.ts +22 -0
- package/workflows/round.js +101 -1
- package/test/v03-offline.mjs +0 -203
- package/tsconfig.json +0 -16
package/src/index.ts
CHANGED
|
@@ -22,9 +22,17 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { randomUUID } from 'node:crypto'
|
|
25
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
26
|
+
import { join } from 'node:path'
|
|
25
27
|
import { fileURLToPath } from 'node:url'
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
+
import {
|
|
29
|
+
STATE_DIR, createGoal, foldRound, runRound, startRound, supersedeGoal, writeRevisionLog,
|
|
30
|
+
} from './controller.ts'
|
|
31
|
+
import type {
|
|
32
|
+
ControllerConfig, ControllerDecision, LoopState, ResumableReason, RoundResult, RolesConfig,
|
|
33
|
+
} from './controller.ts'
|
|
34
|
+
import { startTeamRound, teamExecutorAvailable, teamExecutorUnavailableError } from './executor.ts'
|
|
35
|
+
import { cyberneticsGoalsProjection, goalsFromBoard, publishGoals, setPublishDebugRoot } from './projection.ts'
|
|
28
36
|
|
|
29
37
|
export const name = 'dsh-cybernetics'
|
|
30
38
|
|
|
@@ -34,9 +42,14 @@ export const inject = [
|
|
|
34
42
|
'goals',
|
|
35
43
|
'workingDirectory',
|
|
36
44
|
'agentPresets',
|
|
45
|
+
'agents',
|
|
46
|
+
'sessionProjections',
|
|
37
47
|
] as const
|
|
38
|
-
// All
|
|
39
|
-
// are top-level loader entries) — static
|
|
48
|
+
// All seven are ROOT-level services (goal / working-directory / agent-preset-registry
|
|
49
|
+
// / agents / session-projection registry are top-level loader entries) — static
|
|
50
|
+
// inject cannot pend at boot. `sessionProjections` is the registry dsh-goal
|
|
51
|
+
// itself injects at root; this plugin registers the `cyberneticsGoals` unit
|
|
52
|
+
// there so every goal on the board reaches the「目标」tab (v0.6).
|
|
40
53
|
// `workflowEngine` is different: it is published inside each Agent's preset
|
|
41
54
|
// mount (the `delegation` group with isolate workflowEngine: true), invisible
|
|
42
55
|
// to the root scope. It is resolved per call via
|
|
@@ -55,6 +68,27 @@ export interface Config extends ControllerConfig {
|
|
|
55
68
|
implicitTrigger?: boolean
|
|
56
69
|
/** Per-role sub-agent model/provider overrides. */
|
|
57
70
|
roles?: RolesConfig
|
|
71
|
+
/**
|
|
72
|
+
* Hard orchestration gate (default false): hide execution tools from
|
|
73
|
+
* TOP-LEVEL session agents at prompt-assembly time, so the session agent
|
|
74
|
+
* structurally cannot do task work itself — every task must go through a
|
|
75
|
+
* cybernetics goal + task agent. Task/verify/triage subagents (never in
|
|
76
|
+
* `agents.roots()`) are unaffected and keep full tool access.
|
|
77
|
+
*/
|
|
78
|
+
hardGate?: boolean
|
|
79
|
+
/** Tool names hidden from gated agents (default ['bash', 'edit', 'write']). */
|
|
80
|
+
gatedTools?: string[]
|
|
81
|
+
/**
|
|
82
|
+
* Gate semantics: 'denylist' hides `gatedTools`; 'allowlist' keeps ONLY
|
|
83
|
+
* `gateAllowlist` (default DEFAULT_GATE_ALLOWLIST) plus the cybernetics
|
|
84
|
+
* tool itself — the robust stance against the long tail of mutation tools
|
|
85
|
+
* (farm_*, taskboard_*, knowledge_save, schedule_*, ...).
|
|
86
|
+
*/
|
|
87
|
+
gateMode?: 'denylist' | 'allowlist'
|
|
88
|
+
/** Allowlist-mode survival set; defaults to DEFAULT_GATE_ALLOWLIST. */
|
|
89
|
+
gateAllowlist?: string[]
|
|
90
|
+
/** Extra tool names unioned onto the allowlist (default or gateAllowlist). */
|
|
91
|
+
gateAllowlistExtra?: string[]
|
|
58
92
|
}
|
|
59
93
|
|
|
60
94
|
// No `Config` export: this cordis requires a schemastery schema there// (Config.validate), which a zero-dependency plugin cannot import. The raw
|
|
@@ -80,9 +114,170 @@ interface RunningRound {
|
|
|
80
114
|
config: ControllerConfig
|
|
81
115
|
run: { result: Promise<any>; cancel(reason?: string): void; dispose(): Promise<void> }
|
|
82
116
|
settled?: { stopReason: string; value?: unknown; error?: unknown }
|
|
117
|
+
/** Set once the run has been disposed (cancel-by-revision and collect can race). */
|
|
118
|
+
disposed?: boolean
|
|
83
119
|
}
|
|
84
120
|
const running = new Map<string, RunningRound>()
|
|
85
121
|
|
|
122
|
+
/** Dispose a run at most once; disposal is best-effort observability cleanup. */
|
|
123
|
+
async function disposeOnce(handle: RunningRound): Promise<void> {
|
|
124
|
+
if (handle.disposed) return
|
|
125
|
+
handle.disposed = true
|
|
126
|
+
try {
|
|
127
|
+
await handle.run.dispose()
|
|
128
|
+
} catch {
|
|
129
|
+
// An already-settled or torn-down run must not fail the calling action.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- v0.6 board persistence / rehydrate -------------------------------------
|
|
134
|
+
// The `boards` map above is process memory, and the `cyberneticsGoals`
|
|
135
|
+
// projection only replays session events written by SOME process. After a host
|
|
136
|
+
// restart the map is empty and — when the previous process never published (or
|
|
137
|
+
// the log was not yet replayed) — so is the tab: every historical goal seems to
|
|
138
|
+
// have vanished and the user can no longer track goals separately. Each
|
|
139
|
+
// session's board therefore snapshots itself next to the round logs at
|
|
140
|
+
// `<workspace>/.cybernetics/board-<sessionId>.json`, and the first tool call of
|
|
141
|
+
// a session with an empty memory board restores it and republishes once.
|
|
142
|
+
//
|
|
143
|
+
// Persistence is durability, never workflow control: both directions swallow
|
|
144
|
+
// their errors (a read-only workspace still runs rounds; a corrupt snapshot
|
|
145
|
+
// degrades to a fresh board instead of failing the call).
|
|
146
|
+
|
|
147
|
+
/** Snapshot format version; a mismatch is treated as "no snapshot". */
|
|
148
|
+
export const BOARD_FILE_VERSION = 1
|
|
149
|
+
|
|
150
|
+
/** On-disk board snapshot: the controller's whole goal board for one session. */
|
|
151
|
+
export interface BoardFile {
|
|
152
|
+
version: number
|
|
153
|
+
sessionId: string
|
|
154
|
+
/** Epoch milliseconds of the snapshot write. */
|
|
155
|
+
savedAt: number
|
|
156
|
+
goals: LoopState[]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const BOARD_STATUSES: ReadonlySet<string> = new Set(['running', 'paused', 'met', 'cancelled', 'superseded'])
|
|
160
|
+
const PAUSE_REASONS: ReadonlySet<string> = new Set([
|
|
161
|
+
'goal-questionable', 'human-input', 'round-cap', 'failed', 'no-goal',
|
|
162
|
+
])
|
|
163
|
+
const UNSAFE_FILENAME = /[^a-zA-Z0-9._-]/g
|
|
164
|
+
|
|
165
|
+
/** Snapshot file name for one session (ids are slugged: never a path escape). */
|
|
166
|
+
export function boardFileName(sessionId: string): string {
|
|
167
|
+
return `board-${sessionId.replace(UNSAFE_FILENAME, '_')}.json`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Absolute snapshot path under the workspace root. */
|
|
171
|
+
export function boardFilePath(root: string, sessionId: string): string {
|
|
172
|
+
return join(root, STATE_DIR, boardFileName(sessionId))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Normalize one persisted goal back into a LoopState. Anything that is not a
|
|
177
|
+
* usable goal record (no goalId, wrong shape) is dropped, so a partially
|
|
178
|
+
* hand-edited or half-written snapshot still yields a usable board.
|
|
179
|
+
*/
|
|
180
|
+
function restoreLoop(raw: unknown): LoopState | undefined {
|
|
181
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
|
182
|
+
const g = raw as Record<string, unknown>
|
|
183
|
+
if (typeof g.goalId !== 'string' || g.goalId.length === 0) return undefined
|
|
184
|
+
const loop: LoopState = {
|
|
185
|
+
goalId: g.goalId,
|
|
186
|
+
round: typeof g.round === 'number' && Number.isFinite(g.round) ? g.round : 0,
|
|
187
|
+
doneSummary: typeof g.doneSummary === 'string' ? g.doneSummary : '',
|
|
188
|
+
subgoal: typeof g.subgoal === 'string' ? g.subgoal : '',
|
|
189
|
+
role: typeof g.role === 'string' && g.role.length > 0 ? g.role : 'default',
|
|
190
|
+
}
|
|
191
|
+
if (typeof g.title === 'string') loop.title = g.title
|
|
192
|
+
if (typeof g.objective === 'string') loop.objective = g.objective
|
|
193
|
+
if (typeof g.brief === 'string') loop.brief = g.brief
|
|
194
|
+
if (g.depth === 'quick' || g.depth === 'full') loop.depth = g.depth
|
|
195
|
+
if (g.executor === 'ephemeral' || g.executor === 'persistent') loop.executor = g.executor
|
|
196
|
+
if (typeof g.memberName === 'string' && g.memberName.length > 0) loop.memberName = g.memberName
|
|
197
|
+
if (typeof g.status === 'string' && BOARD_STATUSES.has(g.status)) loop.status = g.status as LoopState['status']
|
|
198
|
+
if (g.paused !== null && typeof g.paused === 'object' && !Array.isArray(g.paused)) {
|
|
199
|
+
const p = g.paused as Record<string, unknown>
|
|
200
|
+
if (typeof p.reason === 'string' && PAUSE_REASONS.has(p.reason) && typeof p.note === 'string') {
|
|
201
|
+
loop.paused = { reason: p.reason as ResumableReason, note: p.note }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (typeof g.parentGoalId === 'string' && g.parentGoalId.length > 0) loop.parentGoalId = g.parentGoalId
|
|
205
|
+
if (typeof g.supersededBy === 'string' && g.supersededBy.length > 0) loop.supersededBy = g.supersededBy
|
|
206
|
+
if (Array.isArray(g.revisions)) {
|
|
207
|
+
loop.revisions = []
|
|
208
|
+
for (const entry of g.revisions) {
|
|
209
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
210
|
+
const r = entry as Record<string, unknown>
|
|
211
|
+
loop.revisions.push({
|
|
212
|
+
version: typeof r.version === 'number' && Number.isFinite(r.version) ? r.version : 0,
|
|
213
|
+
reason: typeof r.reason === 'string' ? r.reason : '',
|
|
214
|
+
at: typeof r.at === 'number' && Number.isFinite(r.at) ? r.at : 0,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return loop
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read one session's board snapshot. Missing file, unreadable file, invalid
|
|
223
|
+
* JSON, a different format version or a foreign session id all mean "no
|
|
224
|
+
* history" — this function never throws.
|
|
225
|
+
*/
|
|
226
|
+
export async function loadBoardFile(root: string, sessionId: string): Promise<LoopState[] | undefined> {
|
|
227
|
+
let text: string
|
|
228
|
+
try {
|
|
229
|
+
text = await readFile(boardFilePath(root, sessionId), 'utf8')
|
|
230
|
+
} catch {
|
|
231
|
+
return undefined // no snapshot: a genuinely fresh session
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(text) as Partial<BoardFile> | null
|
|
235
|
+
if (parsed === null || typeof parsed !== 'object') return undefined
|
|
236
|
+
if (parsed.version !== BOARD_FILE_VERSION) return undefined
|
|
237
|
+
if (typeof parsed.sessionId === 'string' && parsed.sessionId !== sessionId) return undefined
|
|
238
|
+
if (!Array.isArray(parsed.goals)) return undefined
|
|
239
|
+
const out: LoopState[] = []
|
|
240
|
+
const seen = new Set<string>()
|
|
241
|
+
for (const raw of parsed.goals) {
|
|
242
|
+
const loop = restoreLoop(raw)
|
|
243
|
+
if (loop === undefined || loop.goalId === undefined || seen.has(loop.goalId)) continue
|
|
244
|
+
seen.add(loop.goalId)
|
|
245
|
+
out.push(loop)
|
|
246
|
+
}
|
|
247
|
+
return out
|
|
248
|
+
} catch {
|
|
249
|
+
return undefined // corrupt snapshot: behave like an empty board
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Atomically snapshot one session's board (temp file + rename, so a concurrent
|
|
255
|
+
* reader never sees a half-written JSON). Returns the snapshot path, or
|
|
256
|
+
* undefined when the write failed — never throws.
|
|
257
|
+
*/
|
|
258
|
+
export async function saveBoardFile(
|
|
259
|
+
root: string,
|
|
260
|
+
sessionId: string,
|
|
261
|
+
loops: Iterable<LoopState>,
|
|
262
|
+
): Promise<string | undefined> {
|
|
263
|
+
const file = boardFilePath(root, sessionId)
|
|
264
|
+
const payload: BoardFile = {
|
|
265
|
+
version: BOARD_FILE_VERSION,
|
|
266
|
+
sessionId,
|
|
267
|
+
savedAt: Date.now(),
|
|
268
|
+
goals: [...loops],
|
|
269
|
+
}
|
|
270
|
+
const tmp = `${file}.${process.pid}-${randomUUID().slice(0, 8)}.tmp`
|
|
271
|
+
try {
|
|
272
|
+
await mkdir(join(root, STATE_DIR), { recursive: true })
|
|
273
|
+
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
274
|
+
await rename(tmp, file)
|
|
275
|
+
return file
|
|
276
|
+
} catch {
|
|
277
|
+
return undefined
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
86
281
|
/** Minimal required-field validation the omitted dsh-tools wrapper would do. */
|
|
87
282
|
function requireFields(args: Record<string, unknown>, names: string[]): void {
|
|
88
283
|
for (const n of names) {
|
|
@@ -92,14 +287,29 @@ function requireFields(args: Record<string, unknown>, names: string[]): void {
|
|
|
92
287
|
|
|
93
288
|
const IMPLICIT_GUIDANCE = `Goal-driven orchestration (cybernetics). You are the SESSION agent: you never execute task work
|
|
94
289
|
yourself — you manage goals and the task agents that execute them. Classify each user input:
|
|
95
|
-
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
290
|
+
- TRIVIAL READ-ONLY QUESTION (a fact your read tools answer directly, no computation) → answer inline, no goal.
|
|
291
|
+
- LIGHTWEIGHT ANALYSIS (a bounded query/lookup needing tool work but no multi-step planning) → create a goal
|
|
292
|
+
with depth 'quick': one task-agent round, feed the result back directly.
|
|
293
|
+
- COMPLEX ANALYSIS (data fetching to disk, datasets, statistics, multi-step query planning) and ANY
|
|
294
|
+
WORLD-CHANGING TASK → create a goal with depth 'full' (default): the work-verify loop must confirm the
|
|
295
|
+
result before you deliver it. Never shortcut a change or a complex analysis through a quick round.
|
|
296
|
+
- For goals: call cybernetics with action 'start' (non-blocking) or 'round' (blocking), a short title, and
|
|
297
|
+
a brief — the COMPLETE work order extracted from the request (background, paths, constraints, acceptance
|
|
298
|
+
criteria). The task agent is an independent subagent with NO conversation history; the brief must be
|
|
299
|
+
detailed enough for it to finish the work unaided. One session supports multiple concurrent goals.
|
|
300
|
+
- EXECUTOR (new): pass executor 'persistent' for goals whose rounds should accumulate context across
|
|
301
|
+
iterations (multi-turn analysis by a durable teammate — the member keeps its identity and findings
|
|
302
|
+
while you keep the dialogue). Default 'ephemeral' runs each round as fresh workflow subagents, which
|
|
303
|
+
is right for one-shot work and changes. Persistent rounds still verify (full depth) through ephemeral
|
|
304
|
+
verifiers, so verification never inherits the task narrative.
|
|
305
|
+
- CORRECTION to an existing task → an APPEND-ONLY goal revision: call action 'revise' with the old goalId, a
|
|
306
|
+
required reason, the new objective when the goal itself changed (omit it to inherit), and optionally a new
|
|
307
|
+
title / depth / executor. 'revise' creates a NEW goal (parentGoalId = old goal) and starts its first round;
|
|
308
|
+
the old goal keeps its final state as 'superseded'. Pass abortPrevious: true only when the session agent
|
|
309
|
+
decides the old goal's in-flight task agent must be interrupted (default: let it finish; the abort reuses
|
|
310
|
+
the 'cancel' path, and 'cancel' on its own still stops a round without revising the goal).
|
|
311
|
+
NEVER edit a goal's objective in place — 'revise' is the only goal-change verb, and only after the user
|
|
312
|
+
has confirmed the change in conversation.
|
|
103
313
|
- FEEDBACK arrives as the tool result ('round') or, for async rounds, as a queued notification followed by
|
|
104
314
|
action 'collect'. Then choose: summarize the outcome to the user; start a verification round; or start a
|
|
105
315
|
corrective round whose brief is built from the verdict gaps. On decision 'continue', launch the next
|
|
@@ -108,15 +318,146 @@ yourself — you manage goals and the task agents that execute them. Classify ea
|
|
|
108
318
|
'round-cap'/'failed' = report status. Never mark a goal complete yourself; completion is the user's act.
|
|
109
319
|
Use action 'list' to see every goal on this session's board with its status, round and subgoal.`
|
|
110
320
|
|
|
321
|
+
/** Tool names hidden from top-level session agents when the hard gate is on. */
|
|
322
|
+
const DEFAULT_GATED_TOOLS = ['bash', 'edit', 'write']
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Allowlist-mode survival set for top-level orchestrator agents: pure reads,
|
|
326
|
+
* conversation, presentation and goal/orchestration verbs. Anything that
|
|
327
|
+
* changes the world — files, services, boards, knowledge, schedules, docs,
|
|
328
|
+
* other agents' work — is absent, so the session agent must delegate it to a
|
|
329
|
+
* cybernetics task agent. The cybernetics tool itself is always re-added.
|
|
330
|
+
*/
|
|
331
|
+
const DEFAULT_GATE_ALLOWLIST = [
|
|
332
|
+
// local reads & search
|
|
333
|
+
'read', 'read_image', 'read_pdf', 'glob', 'grep', 'working_directory',
|
|
334
|
+
// web reads
|
|
335
|
+
'web_fetch', 'web_search', 'websearch',
|
|
336
|
+
// conversation & presentation
|
|
337
|
+
'ask_user_question', 'skill', 'todo_write',
|
|
338
|
+
'render_ui', 'validate_dsh_ui', 'visualize', 'ui_present', 'present', 'sidebar_open',
|
|
339
|
+
// goal verbs (orchestrator's own)
|
|
340
|
+
'create_goal', 'update_goal', 'get_goal',
|
|
341
|
+
// knowledge reads
|
|
342
|
+
'knowledge_query', 'knowledge_get', 'knowledge_search', 'knowledge_graph',
|
|
343
|
+
'knowledge_status', 'knowledge_stats', 'knowledge_chart',
|
|
344
|
+
// photostory (read-only plugin)
|
|
345
|
+
'photostory_search', 'photostory_search_text', 'photostory_photo_get',
|
|
346
|
+
'photostory_photo_faces', 'photostory_person_get', 'photostory_person_query',
|
|
347
|
+
'photostory_person_search', 'photostory_tags', 'photostory_stats',
|
|
348
|
+
'photostory_status', 'photostory_zdisk_list', 'photostory_zdisk_file',
|
|
349
|
+
'photostory_download',
|
|
350
|
+
// service/board reads
|
|
351
|
+
'farm_status', 'farm_logs', 'taskboard_list', 'taskboard_get', 'taskboard_comments',
|
|
352
|
+
// team/session reads
|
|
353
|
+
'list_agents', 'wait_agent', 'team_task_list', 'team_task_get',
|
|
354
|
+
'graft_read', 'graft_search', 'graft_sessions', 'dsh_im_group_history', 'dsh_im_group_file',
|
|
355
|
+
// misc reads
|
|
356
|
+
'schedule_list', 'job_list', 'job_output', 'audit_status',
|
|
357
|
+
'config_list_snapshots', 'uno_read', 'uno_status',
|
|
358
|
+
'univer_api', 'univer_inspect', 'univer_status', 'univer_lint', 'univer_resources',
|
|
359
|
+
'browser_tabs', 'browser_snapshot', 'browser_console', 'browser_network',
|
|
360
|
+
'browser_screenshot', 'browser_navigate', 'browser_scroll',
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
const HARD_GATE_GUIDANCE = `
|
|
364
|
+
HARD GATE ACTIVE: your tool list is restricted to reading, searching, conversation, presentation and
|
|
365
|
+
goal/orchestration verbs — every tool that CHANGES the world (edit/write/bash, service lifecycle,
|
|
366
|
+
board/knowledge/schedule writes, spawning other workers, ...) is STRUCTURALLY unavailable to you.
|
|
367
|
+
Answering a read-only question directly is fine and needs no goal; but the moment the user asks for a
|
|
368
|
+
change — however small — that is your signal to delegate: create a cybernetics goal (action 'start'
|
|
369
|
+
with title+brief) and let a task agent execute it. Never tell the user a change is "too small for the
|
|
370
|
+
loop": the loop is the only way changes get done. If you look for a tool to perform a change and do
|
|
371
|
+
not find it, that is the gate working as intended — delegate instead.`
|
|
372
|
+
|
|
111
373
|
export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
112
374
|
const toolName = config.toolName ?? 'cybernetics'
|
|
375
|
+
const hardGate = config.hardGate === true
|
|
376
|
+
const disposers: Array<() => void> = []
|
|
377
|
+
|
|
378
|
+
// v0.6: register the multi-goal session projection, so every goal on the
|
|
379
|
+
// session board — not just dsh-goal's single focus slot — reaches the
|
|
380
|
+
// 「目标 / Goal」tab. The registration rides this plugin's fiber: unloading
|
|
381
|
+
// the plugin removes the key and the tab falls back to the `goal` face.
|
|
382
|
+
// Guarded because the offline harness (and any host without the projection
|
|
383
|
+
// seam) builds a ctx without `sessionProjections`.
|
|
384
|
+
const projections = ctx.sessionProjections as
|
|
385
|
+
| { register?: (definition: unknown) => () => void }
|
|
386
|
+
| undefined
|
|
387
|
+
if (projections !== undefined && typeof projections.register === 'function') {
|
|
388
|
+
const projectionDisposer = projections.register(cyberneticsGoalsProjection())
|
|
389
|
+
if (typeof projectionDisposer === 'function') disposers.push(projectionDisposer)
|
|
390
|
+
}
|
|
113
391
|
|
|
114
392
|
if (config.implicitTrigger !== false) {
|
|
115
393
|
ctx.systemPrompt.section({
|
|
116
394
|
name: `tool:${toolName}`,
|
|
117
395
|
order: ctx.systemPrompt.getSectionOrder('TOOL_WORKFLOW'),
|
|
118
|
-
text: IMPLICIT_GUIDANCE,
|
|
396
|
+
text: IMPLICIT_GUIDANCE + (hardGate ? HARD_GATE_GUIDANCE : ''),
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (hardGate) {
|
|
401
|
+
// The hard gate: a system-prompt/assemble waterfall listener that strips
|
|
402
|
+
// execution tools from TOP-LEVEL agents' assemblies. Workflow subagents
|
|
403
|
+
// (task/verify/triage) and team teammates are never in agents.roots(),
|
|
404
|
+
// so their assemblies pass through untouched with full tool access.
|
|
405
|
+
const allowlistMode = config.gateMode === 'allowlist'
|
|
406
|
+
const keep = new Set([
|
|
407
|
+
...(config.gateAllowlist ?? DEFAULT_GATE_ALLOWLIST),
|
|
408
|
+
...(config.gateAllowlistExtra ?? []),
|
|
409
|
+
toolName,
|
|
410
|
+
])
|
|
411
|
+
const gated = new Set(config.gatedTools ?? DEFAULT_GATED_TOOLS)
|
|
412
|
+
const listenerDisposer = ctx.on('system-prompt/assemble', (assembly: any, context: any, next: () => unknown) => {
|
|
413
|
+
const scope = context?.scope
|
|
414
|
+
if (!scope) return next()
|
|
415
|
+
let roots: unknown[] = []
|
|
416
|
+
try { roots = ctx.agents?.roots?.() ?? [] } catch { roots = [] }
|
|
417
|
+
const isRoot = roots.some((r: any) =>
|
|
418
|
+
r === scope || r?.id === scope || (r?.id && r.id === scope?.id))
|
|
419
|
+
// Fallback for agents the registry does not list as roots (a resumed
|
|
420
|
+
// session's agent, for example): a session with no parentSession is a
|
|
421
|
+
// top-level session and belongs on the orchestrator side of the gate.
|
|
422
|
+
// Delegated children (workflow subagents, team members) carry
|
|
423
|
+
// `origin: subagent` + `parentSession` and keep their full tool set.
|
|
424
|
+
const parentSession = (scope as any)?.session?.header?.parentSession
|
|
425
|
+
const isSessionRoot = parentSession === undefined
|
|
426
|
+
if (!isRoot && !isSessionRoot) return next()
|
|
427
|
+
if (Array.isArray(assembly.tools)) {
|
|
428
|
+
assembly.tools = allowlistMode
|
|
429
|
+
? assembly.tools.filter((t: any) => keep.has(t?.name))
|
|
430
|
+
: assembly.tools.filter((t: any) => !gated.has(t?.name))
|
|
431
|
+
}
|
|
432
|
+
return next()
|
|
433
|
+
})
|
|
434
|
+
disposers.push(() => listenerDisposer?.())
|
|
435
|
+
|
|
436
|
+
// Execution-layer gate (the assembly filter above only hides names; the
|
|
437
|
+
// host resolves any registered tool by name, so an unadvertised call must
|
|
438
|
+
// be denied here too). Same root/child rule: a session with no
|
|
439
|
+
// parentSession is orchestrated by the gate; delegated children pass.
|
|
440
|
+
const isGatedAgent = (agent: any): boolean => {
|
|
441
|
+
if (agent === undefined || agent === null || typeof agent !== 'object') return false
|
|
442
|
+
let roots: unknown[] = []
|
|
443
|
+
try { roots = ctx.agents?.roots?.() ?? [] } catch { roots = [] }
|
|
444
|
+
if (roots.some((r: any) => r === agent || r?.id === agent || (r?.id && r.id === agent?.id))) return true
|
|
445
|
+
return agent?.session?.header?.parentSession === undefined
|
|
446
|
+
}
|
|
447
|
+
const preDisposer = ctx.on('tools/pre-execute', (exec: any, next: () => Promise<unknown>) => {
|
|
448
|
+
const name = exec?.name
|
|
449
|
+
if (typeof name !== 'string') return next()
|
|
450
|
+
if (!isGatedAgent(exec?.agent)) return next()
|
|
451
|
+
const blocked = allowlistMode ? !keep.has(name) : gated.has(name)
|
|
452
|
+
if (!blocked) return next()
|
|
453
|
+
return Promise.resolve({
|
|
454
|
+
kind: 'deny',
|
|
455
|
+
reason:
|
|
456
|
+
`cybernetics hard gate: tool "${name}" is not available to the session agent. ` +
|
|
457
|
+
'Delegate the change: create a cybernetics goal (action "start" with title+brief) and let a task agent execute it.',
|
|
458
|
+
})
|
|
119
459
|
})
|
|
460
|
+
disposers.push(() => preDisposer?.())
|
|
120
461
|
}
|
|
121
462
|
|
|
122
463
|
const disposer = (ctx.tools as { register: (d: unknown) => () => void }).register({
|
|
@@ -130,14 +471,17 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
130
471
|
'(use when a user correction must interrupt the current task agent); list shows every goal on this ' +
|
|
131
472
|
'session board. New task: pass title + brief (a complete, self-contained work order — the task agent ' +
|
|
132
473
|
'has NO conversation history) to create a goal; one session supports multiple concurrent goals. ' +
|
|
474
|
+
'revise is the ONLY goal-change verb and it is append-only: it creates a NEW goal (parentGoalId = old ' +
|
|
475
|
+
'goal, inherited depth/executor/role/objective unless overridden), starts that goal\'s first round, and ' +
|
|
476
|
+
'marks the old goal superseded (supersededBy = new goal) — it never rewrites the objective in place. ' +
|
|
133
477
|
'Existing goal: pass its goalId. The controller never marks a goal complete — that is the user\'s act.',
|
|
134
478
|
parameters: {
|
|
135
479
|
type: 'object',
|
|
136
480
|
properties: {
|
|
137
481
|
action: {
|
|
138
482
|
type: 'string',
|
|
139
|
-
enum: ['round', 'start', 'collect', 'cancel', 'list'],
|
|
140
|
-
description: 'round=同步执行一轮(默认)| start=后台启动一轮并立即返回 | collect=折叠已完成的后台轮 | cancel=中止正在运行的轮(修正场景)| list=查看本会话全部 goal。',
|
|
483
|
+
enum: ['round', 'start', 'collect', 'cancel', 'revise', 'list'],
|
|
484
|
+
description: 'round=同步执行一轮(默认)| start=后台启动一轮并立即返回 | collect=折叠已完成的后台轮 | cancel=中止正在运行的轮(修正场景)| revise=追加式修正:为新目标建新 goal 并启动第 1 轮,旧 goal 置 superseded | list=查看本会话全部 goal。',
|
|
141
485
|
},
|
|
142
486
|
goalId: {
|
|
143
487
|
type: 'string',
|
|
@@ -157,10 +501,36 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
157
501
|
type: 'string',
|
|
158
502
|
description: '本轮子目标;缺省沿用上轮决策(首轮取自 goal 本身)。',
|
|
159
503
|
},
|
|
504
|
+
depth: {
|
|
505
|
+
type: 'string',
|
|
506
|
+
enum: ['quick', 'full'],
|
|
507
|
+
description:
|
|
508
|
+
'本轮深度:quick=轻量分析,只跑 task agent 单 agent 一轮出结果直接反馈(跳过 verify/triage);' +
|
|
509
|
+
'full=完整工作-验证循环(默认)——复杂分析(取数/下载/统计/多步查询规划)与一切变更类任务必须用 full。',
|
|
510
|
+
},
|
|
160
511
|
role: {
|
|
161
512
|
type: 'string',
|
|
162
513
|
description: '本轮 task agent 的角色(对应部署配置 roles 表,如 frontend/backend/writer);缺省 default。',
|
|
163
514
|
},
|
|
515
|
+
reason: {
|
|
516
|
+
type: 'string',
|
|
517
|
+
description:
|
|
518
|
+
'revise 必填:修订原因(写进 .cybernetics/revisions-<新goalId>.jsonl 审计,并向用户解释为什么换目标)。',
|
|
519
|
+
},
|
|
520
|
+
objective: {
|
|
521
|
+
type: 'string',
|
|
522
|
+
description: 'revise:新 goal 的目标文本;缺省继承旧 goal 的 objective(用于「目标本身被校准」的修订)。',
|
|
523
|
+
},
|
|
524
|
+
abortPrevious: {
|
|
525
|
+
type: 'boolean',
|
|
526
|
+
description:
|
|
527
|
+
'revise:是否中止旧 goal 正在运行的 task agent 轮(默认 false,让它跑完;true 时复用 cancel 路径)。',
|
|
528
|
+
},
|
|
529
|
+
executor: {
|
|
530
|
+
type: 'string',
|
|
531
|
+
enum: ['ephemeral', 'persistent'],
|
|
532
|
+
description: 'revise:新 goal 的执行者基质,缺省继承旧 goal(当前均为 ephemeral)。',
|
|
533
|
+
},
|
|
164
534
|
},
|
|
165
535
|
},
|
|
166
536
|
output: {
|
|
@@ -168,6 +538,9 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
168
538
|
type: 'object',
|
|
169
539
|
properties: {
|
|
170
540
|
goalId: { type: 'string' },
|
|
541
|
+
previousGoalId: { type: 'string' },
|
|
542
|
+
revised: { type: 'boolean' },
|
|
543
|
+
revision: { type: 'object' },
|
|
171
544
|
round: { type: 'number' },
|
|
172
545
|
decision: { type: 'object' },
|
|
173
546
|
result: { type: 'object' },
|
|
@@ -179,6 +552,15 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
179
552
|
`${g.goalId} [${g.status}${g.running ? '/running' : ''}] R${g.round} ${g.title ?? ''} — ${g.subgoal}`)
|
|
180
553
|
return [{ type: 'text', text: lines.length ? lines.join('\n') : '(goal 板为空)' }]
|
|
181
554
|
}
|
|
555
|
+
if (value.revised) {
|
|
556
|
+
return [{
|
|
557
|
+
type: 'text',
|
|
558
|
+
text:
|
|
559
|
+
`goal ${value.previousGoalId} 已置 superseded → 新 goal ${value.goalId}` +
|
|
560
|
+
`(修订自 ${value.previousGoalId}${value.aborted ? ',旧轮已中止' : ''});` +
|
|
561
|
+
`新 goal 第 ${value.round} 轮已后台启动,完成后将通知本会话`,
|
|
562
|
+
}]
|
|
563
|
+
}
|
|
182
564
|
if (value.started) return [{ type: 'text', text: `goal ${value.goalId} 第 ${value.round} 轮已后台启动,完成后将通知本会话` }]
|
|
183
565
|
if (value.pending) return [{ type: 'text', text: `goal ${value.goalId} 第 ${value.round} 轮仍在运行` }]
|
|
184
566
|
if (value.cancelled) return [{ type: 'text', text: `goal ${value.goalId} 第 ${value.round} 轮已请求中止:${value.cancelled}` }]
|
|
@@ -190,12 +572,17 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
190
572
|
},
|
|
191
573
|
},
|
|
192
574
|
async execute(args: {
|
|
193
|
-
action?: 'round' | 'start' | 'collect' | 'cancel' | 'list'
|
|
575
|
+
action?: 'round' | 'start' | 'collect' | 'cancel' | 'revise' | 'list'
|
|
194
576
|
goalId?: string
|
|
195
577
|
title?: string
|
|
196
578
|
brief?: string
|
|
197
579
|
subgoal?: string
|
|
580
|
+
depth?: 'quick' | 'full'
|
|
198
581
|
role?: string
|
|
582
|
+
reason?: string
|
|
583
|
+
objective?: string
|
|
584
|
+
abortPrevious?: boolean
|
|
585
|
+
executor?: 'ephemeral' | 'persistent'
|
|
199
586
|
}, exec: any) {
|
|
200
587
|
requireFields(args as Record<string, unknown>, [])
|
|
201
588
|
const goals = ctx.goals
|
|
@@ -207,15 +594,55 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
207
594
|
const root = workingDirectory.get(parent.session)
|
|
208
595
|
const action = args.action ?? 'round'
|
|
209
596
|
|
|
597
|
+
const sessionId = typeof parent.session?.id === 'string' && parent.session.id.length > 0
|
|
598
|
+
? parent.session.id
|
|
599
|
+
: undefined
|
|
210
600
|
const board = boards.get(parent.session) ?? new Map<string, LoopState>()
|
|
211
601
|
boards.set(parent.session, board)
|
|
212
602
|
|
|
603
|
+
// v0.6 write point: publish the session's WHOLE board as one
|
|
604
|
+
// `cybernetics/goals` event, so the「目标」tab tracks every goal of this
|
|
605
|
+
// session (dsh-goal's single-slot `goal` projection cannot). Idempotent:
|
|
606
|
+
// republishing an unchanged board appends nothing and never throws.
|
|
607
|
+
// Then snapshot the same board to `<workspace>/.cybernetics/board-<id>.json`
|
|
608
|
+
// so a restarted controller can rehydrate it (see loadBoardFile).
|
|
609
|
+
const publishBoard = async (): Promise<void> => {
|
|
610
|
+
// The workspace root is authoritative here (the working-directory
|
|
611
|
+
// service resolved it); hand it to the projection so the one-shot
|
|
612
|
+
// host-copy resolution record lands in `<workspace>/.cybernetics/`
|
|
613
|
+
// even if a session header carries no cwd.
|
|
614
|
+
if (typeof root === 'string' && root.length > 0) setPublishDebugRoot(root)
|
|
615
|
+
publishGoals(parent.session, goalsFromBoard(board.values()))
|
|
616
|
+
if (sessionId !== undefined && typeof root === 'string' && root.length > 0) {
|
|
617
|
+
await saveBoardFile(root, sessionId, board.values())
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// v0.6 read point: first touch of a session after a host restart (empty
|
|
622
|
+
// memory board). Restore the snapshot and republish it once, so the tab
|
|
623
|
+
// shows the recovered history immediately instead of waiting for the next
|
|
624
|
+
// round. A missing/corrupt/foreign snapshot leaves the board empty.
|
|
625
|
+
if (board.size === 0 && sessionId !== undefined && typeof root === 'string' && root.length > 0) {
|
|
626
|
+
const restored = await loadBoardFile(root, sessionId)
|
|
627
|
+
if (restored !== undefined && restored.length > 0) {
|
|
628
|
+
for (const restoredLoop of restored) {
|
|
629
|
+
if (restoredLoop.goalId !== undefined) board.set(restoredLoop.goalId, restoredLoop)
|
|
630
|
+
}
|
|
631
|
+
await publishBoard()
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
213
635
|
// 'list' needs no goal resolution and no workflow engine.
|
|
214
636
|
if (action === 'list') {
|
|
215
637
|
return {
|
|
216
638
|
board: [...board.values()].map((g) => ({
|
|
217
|
-
goalId: g.goalId, title: g.title, status: g.status ?? 'paused',
|
|
639
|
+
goalId: g.goalId, title: g.title, objective: g.objective, status: g.status ?? 'paused',
|
|
218
640
|
running: running.has(g.goalId!), round: g.round, subgoal: g.subgoal,
|
|
641
|
+
depth: g.depth ?? 'full',
|
|
642
|
+
executor: g.executor ?? 'ephemeral',
|
|
643
|
+
...(g.memberName ? { memberName: g.memberName } : {}),
|
|
644
|
+
...(g.parentGoalId ? { parentGoalId: g.parentGoalId } : {}),
|
|
645
|
+
...(g.supersededBy ? { supersededBy: g.supersededBy } : {}),
|
|
219
646
|
})),
|
|
220
647
|
}
|
|
221
648
|
}
|
|
@@ -234,6 +661,8 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
234
661
|
|
|
235
662
|
// --- Resolve the target loop for this call ------------------------------
|
|
236
663
|
// Priority: explicit goalId > new goal (title) > active dsh-goal focus.
|
|
664
|
+
// 'revise' is excluded from the title branch: there `title` names the NEW
|
|
665
|
+
// goal's title while the call targets the OLD goal.
|
|
237
666
|
let loop: LoopState | undefined
|
|
238
667
|
if (args.goalId) {
|
|
239
668
|
loop = board.get(args.goalId)
|
|
@@ -246,16 +675,21 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
246
675
|
board.set(loop.goalId!, loop)
|
|
247
676
|
}
|
|
248
677
|
if (!loop) throw new Error(`cybernetics: goal "${args.goalId}" not on this session's board (try action "list")`)
|
|
249
|
-
} else if (args.title) {
|
|
678
|
+
} else if (args.title && action !== 'revise') {
|
|
250
679
|
// New task: create a registry goal. dsh-goal stays single-slot; when it
|
|
251
680
|
// is free, focus the new goal into it (GUI tab + round-driver pickup).
|
|
252
681
|
const goalId = `cyb-${randomUUID().slice(0, 8)}`
|
|
253
682
|
const objective = args.brief ?? args.title
|
|
254
|
-
loop = {
|
|
255
|
-
goalId,
|
|
256
|
-
|
|
257
|
-
|
|
683
|
+
loop = createGoal(undefined, {
|
|
684
|
+
goalId,
|
|
685
|
+
title: args.title,
|
|
686
|
+
objective,
|
|
687
|
+
...(args.subgoal ? { subgoal: args.subgoal } : {}),
|
|
688
|
+
})
|
|
258
689
|
board.set(goalId, loop)
|
|
690
|
+
// Goal creation is a projection write point: the tab gains the goal
|
|
691
|
+
// immediately, before the first round settles.
|
|
692
|
+
await publishBoard()
|
|
259
693
|
if (!goalView || goalView.phase !== 'active') {
|
|
260
694
|
try { goals.create(parent, { objective: `${args.title}\n\n${objective}` }) } catch { /* focus sync is best-effort */ }
|
|
261
695
|
}
|
|
@@ -283,8 +717,14 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
283
717
|
// Per-call overrides.
|
|
284
718
|
if (args.subgoal) loop.subgoal = args.subgoal
|
|
285
719
|
if (args.brief) loop.brief = args.brief
|
|
720
|
+
if (args.depth) loop.depth = args.depth
|
|
721
|
+
if (args.executor) loop.executor = args.executor
|
|
286
722
|
if (args.role) loop.role = args.role
|
|
287
723
|
|
|
724
|
+
// A goal adopted from the dsh-goal focus slot joins the board here; make
|
|
725
|
+
// it visible in the tab even when this call changes nothing else.
|
|
726
|
+
await publishBoard()
|
|
727
|
+
|
|
288
728
|
const controllerConfig: ControllerConfig = {
|
|
289
729
|
roundScriptPath: config.roundScriptPath ?? fileURLToPath(new URL('../workflows/round.js', import.meta.url)),
|
|
290
730
|
roundsPerSubgoal: config.roundsPerSubgoal,
|
|
@@ -292,12 +732,140 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
292
732
|
}
|
|
293
733
|
const objective = loop.objective ?? goalView?.objective ?? loop.title ?? ''
|
|
294
734
|
|
|
735
|
+
/**
|
|
736
|
+
* Start one round on the goal's chosen substrate. Ephemeral (default) is
|
|
737
|
+
* the v0.5 workflow run; persistent uses an Agent Teams teammate that
|
|
738
|
+
* survives across rounds. Both return the same run-like handle, so the
|
|
739
|
+
* async/blocking/cancel paths below stay substrate-agnostic.
|
|
740
|
+
*/
|
|
741
|
+
const startAnyRound = async (target: LoopState, signal?: AbortSignal): Promise<any> => {
|
|
742
|
+
if (target.executor === 'persistent') {
|
|
743
|
+
// Soft probe: never read ctx.agentTeams directly here — Cordis throws
|
|
744
|
+
// on an un-injected service property, which would surface as an
|
|
745
|
+
// internal proxy error instead of "unsupported, use ephemeral".
|
|
746
|
+
if (!teamExecutorAvailable(ctx)) throw teamExecutorUnavailableError()
|
|
747
|
+
return startTeamRound(ctx, parent, target, controllerConfig, signal)
|
|
748
|
+
}
|
|
749
|
+
const started = await startRound(engine, parent, root, target, controllerConfig, { objective: target.objective ?? '' }, signal)
|
|
750
|
+
return {
|
|
751
|
+
round: started.round,
|
|
752
|
+
result: started.run.result,
|
|
753
|
+
cancel: (reason?: string) => started.run.cancel(reason),
|
|
754
|
+
dispose: () => started.run.dispose(),
|
|
755
|
+
run: started.run,
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Start one goal's round in the background (action 'start' and `revise`
|
|
761
|
+
* share this): install the handle, then queue the completion notice. The
|
|
762
|
+
* goal's status is already 'running' when this is called.
|
|
763
|
+
*/
|
|
764
|
+
const launchBackground = async (target: LoopState): Promise<{ round: number }> => {
|
|
765
|
+
const controller = new AbortController()
|
|
766
|
+
if (exec.signal) {
|
|
767
|
+
if (exec.signal.aborted) controller.abort()
|
|
768
|
+
else exec.signal.addEventListener('abort', () => controller.abort(), { once: true })
|
|
769
|
+
}
|
|
770
|
+
const started: any = await startAnyRound(target, controller.signal)
|
|
771
|
+
const round = started.round as number
|
|
772
|
+
const handle: RunningRound = { goalId: target.goalId!, round, parent, root, loop: target, config: controllerConfig, run: started }
|
|
773
|
+
running.set(target.goalId!, handle)
|
|
774
|
+
started.result.then(async (settled: any) => {
|
|
775
|
+
handle.settled = settled
|
|
776
|
+
// Superseded goal (revise with abortPrevious: false): the successor
|
|
777
|
+
// carries the work from here — no collect notice, and release the run
|
|
778
|
+
// so a finished-but-replaced round cannot leak the handle.
|
|
779
|
+
if (target.supersededBy !== undefined) {
|
|
780
|
+
if (running.get(target.goalId!) === handle) {
|
|
781
|
+
running.delete(target.goalId!)
|
|
782
|
+
await disposeOnce(handle)
|
|
783
|
+
}
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
notifyRoundSettled(parent, target, round)
|
|
787
|
+
}).catch(() => { /* result never rejects by contract; belt and braces */ })
|
|
788
|
+
return { round }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// --- revise (append-only goal model) ------------------------------------
|
|
792
|
+
// A goal change NEVER rewrites the objective in place: it creates a
|
|
793
|
+
// successor goal (parentGoalId = old), starts that goal's first round, and
|
|
794
|
+
// freezes the old goal as superseded (supersededBy = new). The session
|
|
795
|
+
// agent decides whether the old goal's in-flight task agent is aborted.
|
|
796
|
+
if (action === 'revise') {
|
|
797
|
+
const previous = loop
|
|
798
|
+
const reason = typeof args.reason === 'string' ? args.reason.trim() : ''
|
|
799
|
+
if (reason.length === 0) {
|
|
800
|
+
throw new Error('cybernetics: action "revise" requires a non-empty "reason" — record why the goal changed')
|
|
801
|
+
}
|
|
802
|
+
if (previous.supersededBy !== undefined) {
|
|
803
|
+
throw new Error(
|
|
804
|
+
`cybernetics: goal "${previous.goalId}" was already superseded by "${previous.supersededBy}" — ` +
|
|
805
|
+
'revise the successor goal instead of forking the lineage',
|
|
806
|
+
)
|
|
807
|
+
}
|
|
808
|
+
const abortPrevious = args.abortPrevious === true
|
|
809
|
+
const previousHandle = running.get(previous.goalId!)
|
|
810
|
+
let aborted: string | undefined
|
|
811
|
+
if (abortPrevious && previousHandle) {
|
|
812
|
+
previousHandle.run.cancel(args.brief ?? `goal 被修订取代:${reason}`)
|
|
813
|
+
running.delete(previous.goalId!)
|
|
814
|
+
await disposeOnce(previousHandle)
|
|
815
|
+
aborted = '旧 goal 的在跑轮已中止'
|
|
816
|
+
}
|
|
817
|
+
const newGoalId = `cyb-${randomUUID().slice(0, 8)}`
|
|
818
|
+
const next = createGoal(previous, {
|
|
819
|
+
goalId: newGoalId,
|
|
820
|
+
...(args.title ? { title: args.title } : {}),
|
|
821
|
+
...(args.objective ? { objective: args.objective } : {}),
|
|
822
|
+
...(args.depth ? { depth: args.depth } : {}),
|
|
823
|
+
...(args.executor ? { executor: args.executor } : {}),
|
|
824
|
+
...(args.role ? { role: args.role } : {}),
|
|
825
|
+
subgoal: args.objective ?? previous.objective ?? previous.title ?? newGoalId,
|
|
826
|
+
})
|
|
827
|
+
// A revision inherits the previous work order unless a corrected brief
|
|
828
|
+
// is supplied; the round-1 task agent reads `next.brief` verbatim.
|
|
829
|
+
if (args.brief ?? previous.brief) next.brief = args.brief ?? previous.brief
|
|
830
|
+
board.set(newGoalId, next)
|
|
831
|
+
supersedeGoal(previous, newGoalId)
|
|
832
|
+
await publishBoard()
|
|
833
|
+
const at = Date.now()
|
|
834
|
+
let revisionLog: string | undefined
|
|
835
|
+
try {
|
|
836
|
+
revisionLog = await writeRevisionLog(root, newGoalId, { from: previous.goalId!, to: newGoalId, reason, at })
|
|
837
|
+
} catch {
|
|
838
|
+
// Audit is observability: a read-only workspace must not break the
|
|
839
|
+
// revision (the lineage fields on the board still carry the change).
|
|
840
|
+
}
|
|
841
|
+
const { round } = await launchBackground(next)
|
|
842
|
+
return {
|
|
843
|
+
goalId: newGoalId,
|
|
844
|
+
previousGoalId: previous.goalId,
|
|
845
|
+
revised: true,
|
|
846
|
+
round,
|
|
847
|
+
started: true,
|
|
848
|
+
...(aborted ? { aborted } : {}),
|
|
849
|
+
revision: { from: previous.goalId, to: newGoalId, reason, at },
|
|
850
|
+
...(revisionLog ? { revisionLog } : {}),
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// A superseded goal is frozen history: only the successor may be driven.
|
|
855
|
+
if (loop.supersededBy !== undefined) {
|
|
856
|
+
throw new Error(
|
|
857
|
+
`cybernetics: goal "${loop.goalId}" is superseded by "${loop.supersededBy}" — ` +
|
|
858
|
+
'drive the successor goal (see action "list")',
|
|
859
|
+
)
|
|
860
|
+
}
|
|
861
|
+
|
|
295
862
|
// --- cancel -------------------------------------------------------------
|
|
296
863
|
if (action === 'cancel') {
|
|
297
864
|
const h = running.get(loop.goalId!)
|
|
298
865
|
if (!h) return { goalId: loop.goalId, cancelled: '该 goal 当前没有运行中的轮' }
|
|
299
866
|
loop.status = 'cancelled'
|
|
300
867
|
h.run.cancel(args.brief ?? 'session agent 决定终止(用户修正)')
|
|
868
|
+
await publishBoard()
|
|
301
869
|
return { goalId: loop.goalId, round: h.round, cancelled: '已请求中止;完成通知到达后请 collect 折叠结果' }
|
|
302
870
|
}
|
|
303
871
|
|
|
@@ -309,7 +877,9 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
309
877
|
const { result, decision } = await foldRound(h.root, h.round, h.settled, h.loop, h.config)
|
|
310
878
|
applyDecision(h.loop, h.round, result, decision)
|
|
311
879
|
running.delete(loop.goalId!)
|
|
312
|
-
await h
|
|
880
|
+
await disposeOnce(h)
|
|
881
|
+
// Round settlement decision applied — republish (round / status / subgoal).
|
|
882
|
+
await publishBoard()
|
|
313
883
|
return { goalId: loop.goalId, round: h.round, decision, result }
|
|
314
884
|
}
|
|
315
885
|
|
|
@@ -318,35 +888,33 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
318
888
|
throw new Error(`cybernetics: goal "${loop.goalId}" already has a round in flight — collect or cancel it first`)
|
|
319
889
|
}
|
|
320
890
|
loop.status = 'running'
|
|
891
|
+
// In-flight visibility: the blocking 'round' path can run for minutes, so
|
|
892
|
+
// publish before awaiting it (the async path needs no second publish —
|
|
893
|
+
// the state did not change).
|
|
894
|
+
await publishBoard()
|
|
321
895
|
|
|
322
896
|
if (action === 'start') {
|
|
323
|
-
const
|
|
324
|
-
if (exec.signal) {
|
|
325
|
-
if (exec.signal.aborted) controller.abort()
|
|
326
|
-
else exec.signal.addEventListener('abort', () => controller.abort(), { once: true })
|
|
327
|
-
}
|
|
328
|
-
const { round, run } = await startRound(
|
|
329
|
-
engine, parent, root, loop, controllerConfig, { objective }, controller.signal,
|
|
330
|
-
)
|
|
331
|
-
const handle: RunningRound = { goalId: loop.goalId!, round, parent, root, loop, config: controllerConfig, run }
|
|
332
|
-
running.set(loop.goalId!, handle)
|
|
333
|
-
run.result.then((settled: any) => {
|
|
334
|
-
handle.settled = settled
|
|
335
|
-
notifyRoundSettled(parent, loop, round)
|
|
336
|
-
}).catch(() => { /* result never rejects by contract; belt and braces */ })
|
|
897
|
+
const { round } = await launchBackground(loop)
|
|
337
898
|
return { goalId: loop.goalId, round, started: true }
|
|
338
899
|
}
|
|
339
900
|
|
|
340
|
-
// Blocking round (default).
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
901
|
+
// Blocking round (default). Both substrates expose {round, result, dispose}.
|
|
902
|
+
const handle: any = await startAnyRound(loop, exec.signal)
|
|
903
|
+
let settled: any
|
|
904
|
+
try {
|
|
905
|
+
settled = await handle.result
|
|
906
|
+
} finally {
|
|
907
|
+
await handle.dispose()
|
|
908
|
+
}
|
|
909
|
+
const { result, decision } = await foldRound(root, handle.round, settled, loop, controllerConfig)
|
|
344
910
|
applyDecision(loop, result.round, result, decision)
|
|
911
|
+
// Round settlement decision applied — republish (round / status / subgoal).
|
|
912
|
+
await publishBoard()
|
|
345
913
|
return { goalId: loop.goalId, round: result.round, decision, result }
|
|
346
914
|
},
|
|
347
915
|
})
|
|
348
916
|
|
|
349
|
-
return () => disposer()
|
|
917
|
+
return () => { disposer(); disposers.forEach((d) => d()) }
|
|
350
918
|
}
|
|
351
919
|
|
|
352
920
|
/** Fold a controller decision back into the loop's durable-ish state. */
|