@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/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 { runRound, startRound, foldRound } from './controller.ts'
27
- import type { ControllerConfig, ControllerDecision, LoopState, RoundResult, RolesConfig } from './controller.ts'
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 five are ROOT-level services (goal / working-directory / agent-preset-registry
39
- // are top-level loader entries) — static inject cannot pend at boot.
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
- - NEW task → create a goal and launch a task agent: call cybernetics with action 'start' (non-blocking) or
96
- 'round' (blocking), a short title, and a brief — the COMPLETE work order extracted from the request
97
- (background, paths, constraints, acceptance criteria). The task agent is an independent subagent with NO
98
- conversation history; the brief must be detailed enough for it to finish the work unaided. One session
99
- supports multiple concurrent goals; each returns its own goalId.
100
- - CORRECTION to an existing task → decide: interrupt (action 'cancel' on that goalId, then start a fresh
101
- round whose brief carries the correction) or wait (collect the running round, then start a corrective
102
- round addressing it).
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, title: args.title, objective, round: 0, doneSummary: '',
256
- subgoal: args.subgoal ?? args.title, role: 'default', status: 'paused',
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.run.dispose()
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 controller = new AbortController()
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 { result, decision } = await runRound(
342
- engine, parent, root, loop, controllerConfig, { objective }, exec.signal,
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. */