@mars.liu/dsh-cybernetics 0.2.0 → 0.7.8

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,18 @@
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 z from '@deepseek-ai/schemastery'
29
+ import {
30
+ STATE_DIR, createGoal, decideAbort, foldRound, parseAgentModelSpec, runRound, startRound, supersedeGoal, writeRevisionLog,
31
+ } from './controller.ts'
32
+ import type {
33
+ ControllerConfig, ControllerDecision, LoopState, ResumableReason, RoundResult, RolesConfig,
34
+ } from './controller.ts'
35
+ import { startTeamRound, teamExecutorAvailable, teamExecutorUnavailableError } from './executor.ts'
36
+ import { cyberneticsGoalsProjection, goalsFromBoard, publishGoals, setPublishDebugRoot } from './projection.ts'
28
37
 
29
38
  export const name = 'dsh-cybernetics'
30
39
 
@@ -34,9 +43,14 @@ export const inject = [
34
43
  'goals',
35
44
  'workingDirectory',
36
45
  'agentPresets',
46
+ 'agents',
47
+ 'sessionProjections',
37
48
  ] 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.
49
+ // All seven are ROOT-level services (goal / working-directory / agent-preset-registry
50
+ // / agents / session-projection registry are top-level loader entries) — static
51
+ // inject cannot pend at boot. `sessionProjections` is the registry dsh-goal
52
+ // itself injects at root; this plugin registers the `cyberneticsGoals` unit
53
+ // there so every goal on the board reaches the「目标」tab (v0.6).
40
54
  // `workflowEngine` is different: it is published inside each Agent's preset
41
55
  // mount (the `delegation` group with isolate workflowEngine: true), invisible
42
56
  // to the root scope. It is resolved per call via
@@ -55,12 +69,108 @@ export interface Config extends ControllerConfig {
55
69
  implicitTrigger?: boolean
56
70
  /** Per-role sub-agent model/provider overrides. */
57
71
  roles?: RolesConfig
72
+ /** Flat task-agent model override ("provider/model" or bare model); empty = inherit session agent. */
73
+ taskAgentModel?: string
74
+ /** Flat verify-agent model override (same format); empty = inherit session agent. */
75
+ verifyAgentModel?: string
76
+ /**
77
+ * Hard orchestration gate (default false): hide execution tools from
78
+ * TOP-LEVEL session agents at prompt-assembly time, so the session agent
79
+ * structurally cannot do task work itself — every task must go through a
80
+ * cybernetics goal + task agent. Task/verify/triage subagents (never in
81
+ * `agents.roots()`) are unaffected and keep full tool access.
82
+ */
83
+ hardGate?: boolean
84
+ /** Tool names hidden from gated agents (default ['bash', 'edit', 'write']). */
85
+ gatedTools?: string[]
86
+ /**
87
+ * Gate semantics: 'denylist' hides `gatedTools`; 'allowlist' keeps ONLY
88
+ * `gateAllowlist` (default DEFAULT_GATE_ALLOWLIST) plus the cybernetics
89
+ * tool itself — the robust stance against the long tail of mutation tools
90
+ * (farm_*, taskboard_*, knowledge_save, schedule_*, ...).
91
+ */
92
+ gateMode?: 'denylist' | 'allowlist'
93
+ /** Allowlist-mode survival set; defaults to DEFAULT_GATE_ALLOWLIST. */
94
+ gateAllowlist?: string[]
95
+ /** Extra tool names unioned onto the allowlist (default or gateAllowlist). */
96
+ gateAllowlistExtra?: string[]
97
+ /**
98
+ * All-goals mode (v0.7, default false): EVERY user request — including
99
+ * trivial read-only questions and lightweight analysis — must go through a
100
+ * cybernetics goal first; in-place analysis by the session agent is banned.
101
+ * Off (default) keeps the v0.6 implicit-trigger behavior exactly unchanged.
102
+ */
103
+ allGoalsMode?: boolean
58
104
  }
59
105
 
60
- // No `Config` export: this cordis requires a schemastery schema there// (Config.validate), which a zero-dependency plugin cannot import. The raw
61
- // patch config object passes through to apply() unvalidated; fields are
62
- // optional and read defensively (toolName, roundsPerSubgoal, roundScriptPath,
63
- // implicitTrigger, roles).
106
+ // Under the host's 0.1.7 settings model, an exported schemastery `Config`
107
+ // schema IS the plugin entry's settings page: the Web editor derives the
108
+ // plugin config form from it and serves it through the shared configuration
109
+ // forms (see @linxin666/dsh-ssh and @linxin666/dsh-usage for the same
110
+ // pattern). At least one field must be `.volatile()` for the page to be
111
+ // served at all; a volatile edit is committed into the running fiber's
112
+ // reference without a remount and announced via `loader/volatile-update`.
113
+ // allGoalsMode is volatile so the panel toggle is live; hardGate and the
114
+ // gate/roles fields are deliberately non-volatile — they change structural
115
+ // wiring (tool stripping listener, per-role routing), so an edit remounts
116
+ // the row, which is the correct behavior for them.
117
+ export const Config = z.object({
118
+ /** Model-facing tool name. */
119
+ toolName: z.string().default('cybernetics').description('模型侧工具名(缺省 cybernetics)。'),
120
+ /** Inject the implicit goal-loop guidance section. */
121
+ implicitTrigger: z.boolean().default(true).description('注入隐式 goal-loop 系统提示段(缺省开启)。'),
122
+ /** Per-role sub-agent model/provider overrides: role -> {provider, model}. */
123
+ roles: z.dict(z.object({
124
+ provider: z.string(),
125
+ model: z.string(),
126
+ })).description('按角色的子 agent provider/model 覆盖:role -> {provider, model}(如 default/verify/triage/frontend)。'),
127
+ /**
128
+ * Flat task-agent model override ("provider/model" or a bare model name).
129
+ * Empty = inherit the session agent. Merged over roles.default at wiring
130
+ * time (parseAgentModelSpec) — the flat fields are the settings-panel face;
131
+ * roles stays the canonical runtime shape the round script consumes.
132
+ * Volatile (v0.7.7): the host settings form (@deepseek-ai/dsh-settings
133
+ * volatileForm, lib/index.js:122-131) serves ONLY `.volatile()` fields, and
134
+ * its write() refuses any non-volatile path ("Config field ... is not
135
+ * volatile") — a plain field is invisible AND unwritable on the plugins
136
+ * page. Volatile commit keeps the running instance live; the value is read
137
+ * per round start (readConfigField unwrap below), so an edit takes effect
138
+ * on the next goal round without a remount.
139
+ */
140
+ taskAgentModel: z.string().default('').volatile().description('task agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
141
+ /** Flat verify-agent model override; same format, fallback and volatile semantics as taskAgentModel. */
142
+ verifyAgentModel: z.string().default('').volatile().description('verify agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
143
+ /** Hard orchestration gate (default false). */
144
+ hardGate: z.boolean().default(false).description('硬编排门:装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。修改会触发插件重载。'),
145
+ /** Tool names hidden in denylist mode (default DEFAULT_GATED_TOOLS). */
146
+ gatedTools: z.array(z.string()).description('denylist 模式下隐藏的工具名(缺省内置 DEFAULT_GATED_TOOLS)。'),
147
+ /** Gate semantics. */
148
+ gateMode: z.union(['denylist', 'allowlist'] as const).default('denylist').description('门语义:denylist 隐藏 gatedTools;allowlist 只保留 gateAllowlist(+gateAllowlistExtra)与 cybernetics 工具本身。'),
149
+ /** Allowlist-mode survival set (default DEFAULT_GATE_ALLOWLIST). */
150
+ gateAllowlist: z.array(z.string()).description('allowlist 模式生存工具集(缺省内置 DEFAULT_GATE_ALLOWLIST)。'),
151
+ /** Extra tool names unioned onto the allowlist. */
152
+ gateAllowlistExtra: z.array(z.string()).description('并集追加进 allowlist 的额外工具名。'),
153
+ /**
154
+ * All-goals mode (v0.7, default false): every user request must go through
155
+ * a cybernetics goal first. Volatile: the settings-panel toggle commits
156
+ * into the running instance without a remount.
157
+ */
158
+ allGoalsMode: z.boolean().default(false).volatile()
159
+ .description('全目标模式(v0.7,缺省关):所有用户请求(含只读问题)都必须先经 cybernetics goal。开关在面板上保存后即时生效,无需重载。'),
160
+ })
161
+
162
+ /** One resolved config field: a live reference (volatile), or a plain value. */
163
+ type ConfigField<T> = { get(): T } | T
164
+
165
+ /** Read one resolved config field, following the live reference the schema produces for volatile fields. */
166
+ function readConfigField<T>(field: ConfigField<T> | undefined, fallback: T): T {
167
+ if (field === undefined) return fallback
168
+ if (typeof field === 'object' && field !== null && typeof (field as { get?: unknown }).get === 'function') {
169
+ const value = (field as { get(): T }).get()
170
+ return value === undefined ? fallback : value
171
+ }
172
+ return field as T
173
+ }
64
174
 
65
175
  /**
66
176
  * Multi-goal loop registry: session -> goalId -> loop. v0.3 lifts the
@@ -80,9 +190,170 @@ interface RunningRound {
80
190
  config: ControllerConfig
81
191
  run: { result: Promise<any>; cancel(reason?: string): void; dispose(): Promise<void> }
82
192
  settled?: { stopReason: string; value?: unknown; error?: unknown }
193
+ /** Set once the run has been disposed (cancel-by-revision and collect can race). */
194
+ disposed?: boolean
83
195
  }
84
196
  const running = new Map<string, RunningRound>()
85
197
 
198
+ /** Dispose a run at most once; disposal is best-effort observability cleanup. */
199
+ async function disposeOnce(handle: RunningRound): Promise<void> {
200
+ if (handle.disposed) return
201
+ handle.disposed = true
202
+ try {
203
+ await handle.run.dispose()
204
+ } catch {
205
+ // An already-settled or torn-down run must not fail the calling action.
206
+ }
207
+ }
208
+
209
+ // --- v0.6 board persistence / rehydrate -------------------------------------
210
+ // The `boards` map above is process memory, and the `cyberneticsGoals`
211
+ // projection only replays session events written by SOME process. After a host
212
+ // restart the map is empty and — when the previous process never published (or
213
+ // the log was not yet replayed) — so is the tab: every historical goal seems to
214
+ // have vanished and the user can no longer track goals separately. Each
215
+ // session's board therefore snapshots itself next to the round logs at
216
+ // `<workspace>/.cybernetics/board-<sessionId>.json`, and the first tool call of
217
+ // a session with an empty memory board restores it and republishes once.
218
+ //
219
+ // Persistence is durability, never workflow control: both directions swallow
220
+ // their errors (a read-only workspace still runs rounds; a corrupt snapshot
221
+ // degrades to a fresh board instead of failing the call).
222
+
223
+ /** Snapshot format version; a mismatch is treated as "no snapshot". */
224
+ export const BOARD_FILE_VERSION = 1
225
+
226
+ /** On-disk board snapshot: the controller's whole goal board for one session. */
227
+ export interface BoardFile {
228
+ version: number
229
+ sessionId: string
230
+ /** Epoch milliseconds of the snapshot write. */
231
+ savedAt: number
232
+ goals: LoopState[]
233
+ }
234
+
235
+ const BOARD_STATUSES: ReadonlySet<string> = new Set(['running', 'paused', 'met', 'cancelled', 'superseded'])
236
+ const PAUSE_REASONS: ReadonlySet<string> = new Set([
237
+ 'goal-questionable', 'human-input', 'round-cap', 'failed', 'no-goal',
238
+ ])
239
+ const UNSAFE_FILENAME = /[^a-zA-Z0-9._-]/g
240
+
241
+ /** Snapshot file name for one session (ids are slugged: never a path escape). */
242
+ export function boardFileName(sessionId: string): string {
243
+ return `board-${sessionId.replace(UNSAFE_FILENAME, '_')}.json`
244
+ }
245
+
246
+ /** Absolute snapshot path under the workspace root. */
247
+ export function boardFilePath(root: string, sessionId: string): string {
248
+ return join(root, STATE_DIR, boardFileName(sessionId))
249
+ }
250
+
251
+ /**
252
+ * Normalize one persisted goal back into a LoopState. Anything that is not a
253
+ * usable goal record (no goalId, wrong shape) is dropped, so a partially
254
+ * hand-edited or half-written snapshot still yields a usable board.
255
+ */
256
+ function restoreLoop(raw: unknown): LoopState | undefined {
257
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
258
+ const g = raw as Record<string, unknown>
259
+ if (typeof g.goalId !== 'string' || g.goalId.length === 0) return undefined
260
+ const loop: LoopState = {
261
+ goalId: g.goalId,
262
+ round: typeof g.round === 'number' && Number.isFinite(g.round) ? g.round : 0,
263
+ doneSummary: typeof g.doneSummary === 'string' ? g.doneSummary : '',
264
+ subgoal: typeof g.subgoal === 'string' ? g.subgoal : '',
265
+ role: typeof g.role === 'string' && g.role.length > 0 ? g.role : 'default',
266
+ }
267
+ if (typeof g.title === 'string') loop.title = g.title
268
+ if (typeof g.objective === 'string') loop.objective = g.objective
269
+ if (typeof g.brief === 'string') loop.brief = g.brief
270
+ if (g.depth === 'quick' || g.depth === 'full') loop.depth = g.depth
271
+ if (g.executor === 'ephemeral' || g.executor === 'persistent') loop.executor = g.executor
272
+ if (typeof g.memberName === 'string' && g.memberName.length > 0) loop.memberName = g.memberName
273
+ if (typeof g.status === 'string' && BOARD_STATUSES.has(g.status)) loop.status = g.status as LoopState['status']
274
+ if (g.paused !== null && typeof g.paused === 'object' && !Array.isArray(g.paused)) {
275
+ const p = g.paused as Record<string, unknown>
276
+ if (typeof p.reason === 'string' && PAUSE_REASONS.has(p.reason) && typeof p.note === 'string') {
277
+ loop.paused = { reason: p.reason as ResumableReason, note: p.note }
278
+ }
279
+ }
280
+ if (typeof g.parentGoalId === 'string' && g.parentGoalId.length > 0) loop.parentGoalId = g.parentGoalId
281
+ if (typeof g.supersededBy === 'string' && g.supersededBy.length > 0) loop.supersededBy = g.supersededBy
282
+ if (Array.isArray(g.revisions)) {
283
+ loop.revisions = []
284
+ for (const entry of g.revisions) {
285
+ if (entry === null || typeof entry !== 'object') continue
286
+ const r = entry as Record<string, unknown>
287
+ loop.revisions.push({
288
+ version: typeof r.version === 'number' && Number.isFinite(r.version) ? r.version : 0,
289
+ reason: typeof r.reason === 'string' ? r.reason : '',
290
+ at: typeof r.at === 'number' && Number.isFinite(r.at) ? r.at : 0,
291
+ })
292
+ }
293
+ }
294
+ return loop
295
+ }
296
+
297
+ /**
298
+ * Read one session's board snapshot. Missing file, unreadable file, invalid
299
+ * JSON, a different format version or a foreign session id all mean "no
300
+ * history" — this function never throws.
301
+ */
302
+ export async function loadBoardFile(root: string, sessionId: string): Promise<LoopState[] | undefined> {
303
+ let text: string
304
+ try {
305
+ text = await readFile(boardFilePath(root, sessionId), 'utf8')
306
+ } catch {
307
+ return undefined // no snapshot: a genuinely fresh session
308
+ }
309
+ try {
310
+ const parsed = JSON.parse(text) as Partial<BoardFile> | null
311
+ if (parsed === null || typeof parsed !== 'object') return undefined
312
+ if (parsed.version !== BOARD_FILE_VERSION) return undefined
313
+ if (typeof parsed.sessionId === 'string' && parsed.sessionId !== sessionId) return undefined
314
+ if (!Array.isArray(parsed.goals)) return undefined
315
+ const out: LoopState[] = []
316
+ const seen = new Set<string>()
317
+ for (const raw of parsed.goals) {
318
+ const loop = restoreLoop(raw)
319
+ if (loop === undefined || loop.goalId === undefined || seen.has(loop.goalId)) continue
320
+ seen.add(loop.goalId)
321
+ out.push(loop)
322
+ }
323
+ return out
324
+ } catch {
325
+ return undefined // corrupt snapshot: behave like an empty board
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Atomically snapshot one session's board (temp file + rename, so a concurrent
331
+ * reader never sees a half-written JSON). Returns the snapshot path, or
332
+ * undefined when the write failed — never throws.
333
+ */
334
+ export async function saveBoardFile(
335
+ root: string,
336
+ sessionId: string,
337
+ loops: Iterable<LoopState>,
338
+ ): Promise<string | undefined> {
339
+ const file = boardFilePath(root, sessionId)
340
+ const payload: BoardFile = {
341
+ version: BOARD_FILE_VERSION,
342
+ sessionId,
343
+ savedAt: Date.now(),
344
+ goals: [...loops],
345
+ }
346
+ const tmp = `${file}.${process.pid}-${randomUUID().slice(0, 8)}.tmp`
347
+ try {
348
+ await mkdir(join(root, STATE_DIR), { recursive: true })
349
+ await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
350
+ await rename(tmp, file)
351
+ return file
352
+ } catch {
353
+ return undefined
354
+ }
355
+ }
356
+
86
357
  /** Minimal required-field validation the omitted dsh-tools wrapper would do. */
87
358
  function requireFields(args: Record<string, unknown>, names: string[]): void {
88
359
  for (const n of names) {
@@ -92,14 +363,29 @@ function requireFields(args: Record<string, unknown>, names: string[]): void {
92
363
 
93
364
  const IMPLICIT_GUIDANCE = `Goal-driven orchestration (cybernetics). You are the SESSION agent: you never execute task work
94
365
  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).
366
+ - TRIVIAL READ-ONLY QUESTION (a fact your read tools answer directly, no computation) → answer inline, no goal.
367
+ - LIGHTWEIGHT ANALYSIS (a bounded query/lookup needing tool work but no multi-step planning) → create a goal
368
+ with depth 'quick': one task-agent round, feed the result back directly.
369
+ - COMPLEX ANALYSIS (data fetching to disk, datasets, statistics, multi-step query planning) and ANY
370
+ WORLD-CHANGING TASK → create a goal with depth 'full' (default): the work-verify loop must confirm the
371
+ result before you deliver it. Never shortcut a change or a complex analysis through a quick round.
372
+ - For goals: call cybernetics with action 'start' (non-blocking) or 'round' (blocking), a short title, and
373
+ a brief — the COMPLETE work order extracted from the request (background, paths, constraints, acceptance
374
+ criteria). The task agent is an independent subagent with NO conversation history; the brief must be
375
+ detailed enough for it to finish the work unaided. One session supports multiple concurrent goals.
376
+ - EXECUTOR (new): pass executor 'persistent' for goals whose rounds should accumulate context across
377
+ iterations (multi-turn analysis by a durable teammate — the member keeps its identity and findings
378
+ while you keep the dialogue). Default 'ephemeral' runs each round as fresh workflow subagents, which
379
+ is right for one-shot work and changes. Persistent rounds still verify (full depth) through ephemeral
380
+ verifiers, so verification never inherits the task narrative.
381
+ - CORRECTION to an existing task → an APPEND-ONLY goal revision: call action 'revise' with the old goalId, a
382
+ required reason, the new objective when the goal itself changed (omit it to inherit), and optionally a new
383
+ title / depth / executor. 'revise' creates a NEW goal (parentGoalId = old goal) and starts its first round;
384
+ the old goal keeps its final state as 'superseded'. Pass abortPrevious: true only when the session agent
385
+ decides the old goal's in-flight task agent must be interrupted (default: let it finish; the abort reuses
386
+ the 'cancel' path, and 'cancel' on its own still stops a round without revising the goal).
387
+ NEVER edit a goal's objective in place — 'revise' is the only goal-change verb, and only after the user
388
+ has confirmed the change in conversation.
103
389
  - FEEDBACK arrives as the tool result ('round') or, for async rounds, as a queued notification followed by
104
390
  action 'collect'. Then choose: summarize the outcome to the user; start a verification round; or start a
105
391
  corrective round whose brief is built from the verdict gaps. On decision 'continue', launch the next
@@ -108,16 +394,201 @@ yourself — you manage goals and the task agents that execute them. Classify ea
108
394
  'round-cap'/'failed' = report status. Never mark a goal complete yourself; completion is the user's act.
109
395
  Use action 'list' to see every goal on this session's board with its status, round and subgoal.`
110
396
 
397
+ /**
398
+ * v0.7 all-goals mode addendum: turns the implicit trigger from "suggest a
399
+ * goal for substantial tasks" into "every request is a goal". Contains the
400
+ * four routing rules the protocol and tests pin down:
401
+ * (1) everything enters a goal; (2) simple read-only → 'quick' single round;
402
+ * (3) complex analysis / write tasks → progressive multi-round ('full');
403
+ * (4) the revise auto-abort decision tree.
404
+ */
405
+ const ALL_GOALS_GUIDANCE = `
406
+ ALL-GOALS MODE ACTIVE (routing protocol — no exceptions, no in-place analysis):
407
+ - RULE 1 — EVERYTHING ENTERS A GOAL: for ANY user request (question, lookup, analysis, change), first
408
+ create a goal via cybernetics (action 'start' with title + brief), then let the task agent produce the
409
+ answer. NEVER answer a request by doing the task work yourself with read tools — even a one-file lookup
410
+ or a quick calculation goes through the loop. If a change is requested while the hard gate is off, the
411
+ protocol still forbids in-place edits: delegate.
412
+ - RULE 2 — SIMPLE READ-ONLY REQUEST → depth 'quick': one task-agent round, no verify/triage, feed the
413
+ result straight back to the user (bounded lookups, single-file reads, small queries).
414
+ - RULE 3 — COMPLEX ANALYSIS or WRITE TASK → depth 'full', progressive multi-round: launch with action
415
+ 'start' (async), then 'collect' each settled round and advance the subgoal round by round (task →
416
+ verify → triage). Analysis: data fetching to disk, datasets, statistics, multi-step query planning.
417
+ Write tasks additionally rely on the hard gate: the session agent must NEVER execute a change itself —
418
+ delegation through the goal loop is the only path, however small the change.
419
+ - RULE 4 — REVISE DECISION TREE (updating an existing goal stays append-only: a new goal with
420
+ parentGoalId, old goal superseded; 'revise' is the only goal-change verb). Decide abortPrevious by the
421
+ old task agent's state (check with action 'list'):
422
+ (a) old round STILL RUNNING and the revision contradicts it (objective/depth rewritten — conflicting
423
+ goals / write-domain overlap) → let 'revise' auto-abort: do not pass abortPrevious; the plugin
424
+ cancels the old round automatically;
425
+ (b) old round STILL RUNNING but the correction keeps the objective (brief/title/role only) and the old
426
+ work stays valuable / parallelizable / reusable → do not pass abortPrevious; the plugin waits for
427
+ the old round to finish naturally;
428
+ (c) old task agent ALREADY FINISHED (goal not running on the board) → just call 'revise'; no abort is
429
+ needed and none is attempted.
430
+ The chosen branch and its reason are recorded in the .cybernetics/revisions-<newGoalId>.jsonl audit
431
+ line (abort.mode = auto|explicit + basis). Passing abortPrevious explicitly keeps manual control.`
432
+
433
+ /** Tool names hidden from top-level session agents when the hard gate is on. */
434
+ const DEFAULT_GATED_TOOLS = ['bash', 'edit', 'write']
435
+
436
+ /**
437
+ * Allowlist-mode survival set for top-level orchestrator agents: pure reads,
438
+ * conversation, presentation and goal/orchestration verbs. Anything that
439
+ * changes the world — files, services, boards, knowledge, schedules, docs,
440
+ * other agents' work — is absent, so the session agent must delegate it to a
441
+ * cybernetics task agent. The cybernetics tool itself is always re-added.
442
+ */
443
+ const DEFAULT_GATE_ALLOWLIST = [
444
+ // local reads & search
445
+ 'read', 'read_image', 'read_pdf', 'glob', 'grep', 'working_directory',
446
+ // web reads
447
+ 'web_fetch', 'web_search', 'websearch',
448
+ // conversation & presentation
449
+ 'ask_user_question', 'skill', 'todo_write',
450
+ 'render_ui', 'validate_dsh_ui', 'visualize', 'ui_present', 'present', 'sidebar_open',
451
+ // goal verbs (orchestrator's own)
452
+ 'create_goal', 'update_goal', 'get_goal',
453
+ // knowledge reads
454
+ 'knowledge_query', 'knowledge_get', 'knowledge_search', 'knowledge_graph',
455
+ 'knowledge_status', 'knowledge_stats', 'knowledge_chart',
456
+ // photostory (read-only plugin)
457
+ 'photostory_search', 'photostory_search_text', 'photostory_photo_get',
458
+ 'photostory_photo_faces', 'photostory_person_get', 'photostory_person_query',
459
+ 'photostory_person_search', 'photostory_tags', 'photostory_stats',
460
+ 'photostory_status', 'photostory_zdisk_list', 'photostory_zdisk_file',
461
+ 'photostory_download',
462
+ // service/board reads
463
+ 'farm_status', 'farm_logs', 'taskboard_list', 'taskboard_get', 'taskboard_comments',
464
+ // team/session reads
465
+ 'list_agents', 'wait_agent', 'team_task_list', 'team_task_get',
466
+ 'graft_read', 'graft_search', 'graft_sessions', 'dsh_im_group_history', 'dsh_im_group_file',
467
+ // misc reads
468
+ 'schedule_list', 'job_list', 'job_output', 'audit_status',
469
+ 'config_list_snapshots', 'uno_read', 'uno_status',
470
+ 'univer_api', 'univer_inspect', 'univer_status', 'univer_lint', 'univer_resources',
471
+ 'browser_tabs', 'browser_snapshot', 'browser_console', 'browser_network',
472
+ 'browser_screenshot', 'browser_navigate', 'browser_scroll',
473
+ ]
474
+
475
+ const HARD_GATE_GUIDANCE = `
476
+ HARD GATE ACTIVE: your tool list is restricted to reading, searching, conversation, presentation and
477
+ goal/orchestration verbs — every tool that CHANGES the world (edit/write/bash, service lifecycle,
478
+ board/knowledge/schedule writes, spawning other workers, ...) is STRUCTURALLY unavailable to you.
479
+ Answering a read-only question directly is fine and needs no goal; but the moment the user asks for a
480
+ change — however small — that is your signal to delegate: create a cybernetics goal (action 'start'
481
+ with title+brief) and let a task agent execute it. Never tell the user a change is "too small for the
482
+ loop": the loop is the only way changes get done. If you look for a tool to perform a change and do
483
+ not find it, that is the gate working as intended — delegate instead.`
484
+
111
485
  export function apply(ctx: any, config: Partial<Config> = {}) {
112
486
  const toolName = config.toolName ?? 'cybernetics'
487
+ const hardGate = config.hardGate === true
488
+ // allGoalsMode is a volatile field: under the loader's schema resolution it
489
+ // arrives as a live reference whose value the settings panel commits in
490
+ // place (no remount), so read it at use time instead of capturing it once.
491
+ const allGoalsMode = () => readConfigField(config.allGoalsMode, false) === true
492
+ const disposers: Array<() => void> = []
493
+
494
+ // v0.6: register the multi-goal session projection, so every goal on the
495
+ // session board — not just dsh-goal's single focus slot — reaches the
496
+ // 「目标 / Goal」tab. The registration rides this plugin's fiber: unloading
497
+ // the plugin removes the key and the tab falls back to the `goal` face.
498
+ // Guarded because the offline harness (and any host without the projection
499
+ // seam) builds a ctx without `sessionProjections`.
500
+ const projections = ctx.sessionProjections as
501
+ | { register?: (definition: unknown) => () => void }
502
+ | undefined
503
+ if (projections !== undefined && typeof projections.register === 'function') {
504
+ const projectionDisposer = projections.register(cyberneticsGoalsProjection())
505
+ if (typeof projectionDisposer === 'function') disposers.push(projectionDisposer)
506
+ }
113
507
 
114
- if (config.implicitTrigger !== false) {
115
- ctx.systemPrompt.section({
508
+ // The implicit guidance section embeds the volatile allGoalsMode wording,
509
+ // so it must be re-derived when a settings-panel toggle is committed into
510
+ // the running instance (the loader announces loader/volatile-update on this
511
+ // fiber instead of remounting; same re-derive pattern as dsh-ssh's sync()).
512
+ let disposeSection: (() => void) | undefined
513
+ function registerGuidanceSection() {
514
+ if (disposeSection !== undefined) disposeSection()
515
+ disposeSection = undefined
516
+ if (config.implicitTrigger === false) return
517
+ disposeSection = ctx.systemPrompt.section({
116
518
  name: `tool:${toolName}`,
117
519
  order: ctx.systemPrompt.getSectionOrder('TOOL_WORKFLOW'),
118
- text: IMPLICIT_GUIDANCE,
520
+ text: IMPLICIT_GUIDANCE + (allGoalsMode() ? ALL_GOALS_GUIDANCE : '') + (hardGate ? HARD_GATE_GUIDANCE : ''),
119
521
  })
120
522
  }
523
+ registerGuidanceSection()
524
+ disposers.push(() => { if (disposeSection !== undefined) disposeSection() })
525
+ ctx.on?.('loader/volatile-update', () => { registerGuidanceSection() })
526
+
527
+ if (hardGate) {
528
+ // The hard gate: a system-prompt/assemble waterfall listener that strips
529
+ // execution tools from TOP-LEVEL agents' assemblies. Workflow subagents
530
+ // (task/verify/triage) and team teammates are never in agents.roots(),
531
+ // so their assemblies pass through untouched with full tool access.
532
+ const allowlistMode = config.gateMode === 'allowlist'
533
+ // NOTE: schema-resolved config turns an unset z.array field into [] (not
534
+ // undefined), so fall back on length, not on ?? — otherwise the gate
535
+ // would silently keep/gate nothing.
536
+ const keep = new Set([
537
+ ...(config.gateAllowlist?.length ? config.gateAllowlist : DEFAULT_GATE_ALLOWLIST),
538
+ ...(config.gateAllowlistExtra ?? []),
539
+ toolName,
540
+ ])
541
+ const gated = new Set(config.gatedTools?.length ? config.gatedTools : DEFAULT_GATED_TOOLS)
542
+ const listenerDisposer = ctx.on('system-prompt/assemble', (assembly: any, context: any, next: () => unknown) => {
543
+ const scope = context?.scope
544
+ if (!scope) return next()
545
+ let roots: unknown[] = []
546
+ try { roots = ctx.agents?.roots?.() ?? [] } catch { roots = [] }
547
+ const isRoot = roots.some((r: any) =>
548
+ r === scope || r?.id === scope || (r?.id && r.id === scope?.id))
549
+ // Fallback for agents the registry does not list as roots (a resumed
550
+ // session's agent, for example): a session with no parentSession is a
551
+ // top-level session and belongs on the orchestrator side of the gate.
552
+ // Delegated children (workflow subagents, team members) carry
553
+ // `origin: subagent` + `parentSession` and keep their full tool set.
554
+ const parentSession = (scope as any)?.session?.header?.parentSession
555
+ const isSessionRoot = parentSession === undefined
556
+ if (!isRoot && !isSessionRoot) return next()
557
+ if (Array.isArray(assembly.tools)) {
558
+ assembly.tools = allowlistMode
559
+ ? assembly.tools.filter((t: any) => keep.has(t?.name))
560
+ : assembly.tools.filter((t: any) => !gated.has(t?.name))
561
+ }
562
+ return next()
563
+ })
564
+ disposers.push(() => listenerDisposer?.())
565
+
566
+ // Execution-layer gate (the assembly filter above only hides names; the
567
+ // host resolves any registered tool by name, so an unadvertised call must
568
+ // be denied here too). Same root/child rule: a session with no
569
+ // parentSession is orchestrated by the gate; delegated children pass.
570
+ const isGatedAgent = (agent: any): boolean => {
571
+ if (agent === undefined || agent === null || typeof agent !== 'object') return false
572
+ let roots: unknown[] = []
573
+ try { roots = ctx.agents?.roots?.() ?? [] } catch { roots = [] }
574
+ if (roots.some((r: any) => r === agent || r?.id === agent || (r?.id && r.id === agent?.id))) return true
575
+ return agent?.session?.header?.parentSession === undefined
576
+ }
577
+ const preDisposer = ctx.on('tools/pre-execute', (exec: any, next: () => Promise<unknown>) => {
578
+ const name = exec?.name
579
+ if (typeof name !== 'string') return next()
580
+ if (!isGatedAgent(exec?.agent)) return next()
581
+ const blocked = allowlistMode ? !keep.has(name) : gated.has(name)
582
+ if (!blocked) return next()
583
+ return Promise.resolve({
584
+ kind: 'deny',
585
+ reason:
586
+ `cybernetics hard gate: tool "${name}" is not available to the session agent. ` +
587
+ 'Delegate the change: create a cybernetics goal (action "start" with title+brief) and let a task agent execute it.',
588
+ })
589
+ })
590
+ disposers.push(() => preDisposer?.())
591
+ }
121
592
 
122
593
  const disposer = (ctx.tools as { register: (d: unknown) => () => void }).register({
123
594
  name: toolName,
@@ -130,14 +601,17 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
130
601
  '(use when a user correction must interrupt the current task agent); list shows every goal on this ' +
131
602
  'session board. New task: pass title + brief (a complete, self-contained work order — the task agent ' +
132
603
  'has NO conversation history) to create a goal; one session supports multiple concurrent goals. ' +
604
+ 'revise is the ONLY goal-change verb and it is append-only: it creates a NEW goal (parentGoalId = old ' +
605
+ 'goal, inherited depth/executor/role/objective unless overridden), starts that goal\'s first round, and ' +
606
+ 'marks the old goal superseded (supersededBy = new goal) — it never rewrites the objective in place. ' +
133
607
  'Existing goal: pass its goalId. The controller never marks a goal complete — that is the user\'s act.',
134
608
  parameters: {
135
609
  type: 'object',
136
610
  properties: {
137
611
  action: {
138
612
  type: 'string',
139
- enum: ['round', 'start', 'collect', 'cancel', 'list'],
140
- description: 'round=同步执行一轮(默认)| start=后台启动一轮并立即返回 | collect=折叠已完成的后台轮 | cancel=中止正在运行的轮(修正场景)| list=查看本会话全部 goal。',
613
+ enum: ['round', 'start', 'collect', 'cancel', 'revise', 'list'],
614
+ description: 'round=同步执行一轮(默认)| start=后台启动一轮并立即返回 | collect=折叠已完成的后台轮 | cancel=中止正在运行的轮(修正场景)| revise=追加式修正:为新目标建新 goal 并启动第 1 轮,旧 goal 置 superseded | list=查看本会话全部 goal。',
141
615
  },
142
616
  goalId: {
143
617
  type: 'string',
@@ -157,10 +631,37 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
157
631
  type: 'string',
158
632
  description: '本轮子目标;缺省沿用上轮决策(首轮取自 goal 本身)。',
159
633
  },
634
+ depth: {
635
+ type: 'string',
636
+ enum: ['quick', 'full'],
637
+ description:
638
+ '本轮深度:quick=轻量分析,只跑 task agent 单 agent 一轮出结果直接反馈(跳过 verify/triage);' +
639
+ 'full=完整工作-验证循环(默认)——复杂分析(取数/下载/统计/多步查询规划)与一切变更类任务必须用 full。',
640
+ },
160
641
  role: {
161
642
  type: 'string',
162
643
  description: '本轮 task agent 的角色(对应部署配置 roles 表,如 frontend/backend/writer);缺省 default。',
163
644
  },
645
+ reason: {
646
+ type: 'string',
647
+ description:
648
+ 'revise 必填:修订原因(写进 .cybernetics/revisions-<新goalId>.jsonl 审计,并向用户解释为什么换目标)。',
649
+ },
650
+ objective: {
651
+ type: 'string',
652
+ description: 'revise:新 goal 的目标文本;缺省继承旧 goal 的 objective(用于「目标本身被校准」的修订)。',
653
+ },
654
+ abortPrevious: {
655
+ type: 'boolean',
656
+ description:
657
+ 'revise:是否中止旧 goal 正在运行的 task agent 轮。缺省时按决策树自动判断(查 goal 板:旧轮在跑且目标被改写→自动中止;旧轮在跑但目标不变→等待自然结束;旧轮未跑→直启);' +
658
+ '显式传 true/false 保持人工控制(true 复用 cancel 路径)。',
659
+ },
660
+ executor: {
661
+ type: 'string',
662
+ enum: ['ephemeral', 'persistent'],
663
+ description: 'revise:新 goal 的执行者基质,缺省继承旧 goal(当前均为 ephemeral)。',
664
+ },
164
665
  },
165
666
  },
166
667
  output: {
@@ -168,6 +669,10 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
168
669
  type: 'object',
169
670
  properties: {
170
671
  goalId: { type: 'string' },
672
+ previousGoalId: { type: 'string' },
673
+ revised: { type: 'boolean' },
674
+ abort: { type: 'object' },
675
+ revision: { type: 'object' },
171
676
  round: { type: 'number' },
172
677
  decision: { type: 'object' },
173
678
  result: { type: 'object' },
@@ -179,6 +684,15 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
179
684
  `${g.goalId} [${g.status}${g.running ? '/running' : ''}] R${g.round} ${g.title ?? ''} — ${g.subgoal}`)
180
685
  return [{ type: 'text', text: lines.length ? lines.join('\n') : '(goal 板为空)' }]
181
686
  }
687
+ if (value.revised) {
688
+ return [{
689
+ type: 'text',
690
+ text:
691
+ `goal ${value.previousGoalId} 已置 superseded → 新 goal ${value.goalId}` +
692
+ `(修订自 ${value.previousGoalId}${value.aborted ? ',旧轮已中止' : ''});` +
693
+ `新 goal 第 ${value.round} 轮已后台启动,完成后将通知本会话`,
694
+ }]
695
+ }
182
696
  if (value.started) return [{ type: 'text', text: `goal ${value.goalId} 第 ${value.round} 轮已后台启动,完成后将通知本会话` }]
183
697
  if (value.pending) return [{ type: 'text', text: `goal ${value.goalId} 第 ${value.round} 轮仍在运行` }]
184
698
  if (value.cancelled) return [{ type: 'text', text: `goal ${value.goalId} 第 ${value.round} 轮已请求中止:${value.cancelled}` }]
@@ -190,12 +704,17 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
190
704
  },
191
705
  },
192
706
  async execute(args: {
193
- action?: 'round' | 'start' | 'collect' | 'cancel' | 'list'
707
+ action?: 'round' | 'start' | 'collect' | 'cancel' | 'revise' | 'list'
194
708
  goalId?: string
195
709
  title?: string
196
710
  brief?: string
197
711
  subgoal?: string
712
+ depth?: 'quick' | 'full'
198
713
  role?: string
714
+ reason?: string
715
+ objective?: string
716
+ abortPrevious?: boolean
717
+ executor?: 'ephemeral' | 'persistent'
199
718
  }, exec: any) {
200
719
  requireFields(args as Record<string, unknown>, [])
201
720
  const goals = ctx.goals
@@ -207,15 +726,55 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
207
726
  const root = workingDirectory.get(parent.session)
208
727
  const action = args.action ?? 'round'
209
728
 
729
+ const sessionId = typeof parent.session?.id === 'string' && parent.session.id.length > 0
730
+ ? parent.session.id
731
+ : undefined
210
732
  const board = boards.get(parent.session) ?? new Map<string, LoopState>()
211
733
  boards.set(parent.session, board)
212
734
 
735
+ // v0.6 write point: publish the session's WHOLE board as one
736
+ // `cybernetics/goals` event, so the「目标」tab tracks every goal of this
737
+ // session (dsh-goal's single-slot `goal` projection cannot). Idempotent:
738
+ // republishing an unchanged board appends nothing and never throws.
739
+ // Then snapshot the same board to `<workspace>/.cybernetics/board-<id>.json`
740
+ // so a restarted controller can rehydrate it (see loadBoardFile).
741
+ const publishBoard = async (): Promise<void> => {
742
+ // The workspace root is authoritative here (the working-directory
743
+ // service resolved it); hand it to the projection so the one-shot
744
+ // host-copy resolution record lands in `<workspace>/.cybernetics/`
745
+ // even if a session header carries no cwd.
746
+ if (typeof root === 'string' && root.length > 0) setPublishDebugRoot(root)
747
+ publishGoals(parent.session, goalsFromBoard(board.values()))
748
+ if (sessionId !== undefined && typeof root === 'string' && root.length > 0) {
749
+ await saveBoardFile(root, sessionId, board.values())
750
+ }
751
+ }
752
+
753
+ // v0.6 read point: first touch of a session after a host restart (empty
754
+ // memory board). Restore the snapshot and republish it once, so the tab
755
+ // shows the recovered history immediately instead of waiting for the next
756
+ // round. A missing/corrupt/foreign snapshot leaves the board empty.
757
+ if (board.size === 0 && sessionId !== undefined && typeof root === 'string' && root.length > 0) {
758
+ const restored = await loadBoardFile(root, sessionId)
759
+ if (restored !== undefined && restored.length > 0) {
760
+ for (const restoredLoop of restored) {
761
+ if (restoredLoop.goalId !== undefined) board.set(restoredLoop.goalId, restoredLoop)
762
+ }
763
+ await publishBoard()
764
+ }
765
+ }
766
+
213
767
  // 'list' needs no goal resolution and no workflow engine.
214
768
  if (action === 'list') {
215
769
  return {
216
770
  board: [...board.values()].map((g) => ({
217
- goalId: g.goalId, title: g.title, status: g.status ?? 'paused',
771
+ goalId: g.goalId, title: g.title, objective: g.objective, status: g.status ?? 'paused',
218
772
  running: running.has(g.goalId!), round: g.round, subgoal: g.subgoal,
773
+ depth: g.depth ?? 'full',
774
+ executor: g.executor ?? 'ephemeral',
775
+ ...(g.memberName ? { memberName: g.memberName } : {}),
776
+ ...(g.parentGoalId ? { parentGoalId: g.parentGoalId } : {}),
777
+ ...(g.supersededBy ? { supersededBy: g.supersededBy } : {}),
219
778
  })),
220
779
  }
221
780
  }
@@ -234,6 +793,8 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
234
793
 
235
794
  // --- Resolve the target loop for this call ------------------------------
236
795
  // Priority: explicit goalId > new goal (title) > active dsh-goal focus.
796
+ // 'revise' is excluded from the title branch: there `title` names the NEW
797
+ // goal's title while the call targets the OLD goal.
237
798
  let loop: LoopState | undefined
238
799
  if (args.goalId) {
239
800
  loop = board.get(args.goalId)
@@ -246,16 +807,21 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
246
807
  board.set(loop.goalId!, loop)
247
808
  }
248
809
  if (!loop) throw new Error(`cybernetics: goal "${args.goalId}" not on this session's board (try action "list")`)
249
- } else if (args.title) {
810
+ } else if (args.title && action !== 'revise') {
250
811
  // New task: create a registry goal. dsh-goal stays single-slot; when it
251
812
  // is free, focus the new goal into it (GUI tab + round-driver pickup).
252
813
  const goalId = `cyb-${randomUUID().slice(0, 8)}`
253
814
  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
- }
815
+ loop = createGoal(undefined, {
816
+ goalId,
817
+ title: args.title,
818
+ objective,
819
+ ...(args.subgoal ? { subgoal: args.subgoal } : {}),
820
+ })
258
821
  board.set(goalId, loop)
822
+ // Goal creation is a projection write point: the tab gains the goal
823
+ // immediately, before the first round settles.
824
+ await publishBoard()
259
825
  if (!goalView || goalView.phase !== 'active') {
260
826
  try { goals.create(parent, { objective: `${args.title}\n\n${objective}` }) } catch { /* focus sync is best-effort */ }
261
827
  }
@@ -283,21 +849,182 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
283
849
  // Per-call overrides.
284
850
  if (args.subgoal) loop.subgoal = args.subgoal
285
851
  if (args.brief) loop.brief = args.brief
852
+ if (args.depth) loop.depth = args.depth
853
+ if (args.executor) loop.executor = args.executor
286
854
  if (args.role) loop.role = args.role
287
855
 
856
+ // A goal adopted from the dsh-goal focus slot joins the board here; make
857
+ // it visible in the tab even when this call changes nothing else.
858
+ await publishBoard()
859
+
860
+ // Flat settings-panel model fields fold into the canonical roles shape:
861
+ // an unset field leaves its role untouched = inherit the session agent.
862
+ // Both fields are volatile (v0.7.7), so the loader hands back live
863
+ // references — unwrap through readConfigField ('' = inherit) exactly
864
+ // like the allGoalsMode live ref above.
865
+ const roleOverrides: RolesConfig = { ...(config.roles ?? {}) }
866
+ const taskSpec = parseAgentModelSpec(readConfigField(config.taskAgentModel, ''))
867
+ if (taskSpec) roleOverrides.default = { ...roleOverrides.default, ...taskSpec }
868
+ const verifySpec = parseAgentModelSpec(readConfigField(config.verifyAgentModel, ''))
869
+ if (verifySpec) roleOverrides.verify = { ...roleOverrides.verify, ...verifySpec }
288
870
  const controllerConfig: ControllerConfig = {
289
871
  roundScriptPath: config.roundScriptPath ?? fileURLToPath(new URL('../workflows/round.js', import.meta.url)),
290
872
  roundsPerSubgoal: config.roundsPerSubgoal,
291
- roles: config.roles,
873
+ roles: roleOverrides,
292
874
  }
293
875
  const objective = loop.objective ?? goalView?.objective ?? loop.title ?? ''
294
876
 
877
+ /**
878
+ * Start one round on the goal's chosen substrate. Ephemeral (default) is
879
+ * the v0.5 workflow run; persistent uses an Agent Teams teammate that
880
+ * survives across rounds. Both return the same run-like handle, so the
881
+ * async/blocking/cancel paths below stay substrate-agnostic.
882
+ */
883
+ const startAnyRound = async (target: LoopState, signal?: AbortSignal): Promise<any> => {
884
+ if (target.executor === 'persistent') {
885
+ // Soft probe: never read ctx.agentTeams directly here — Cordis throws
886
+ // on an un-injected service property, which would surface as an
887
+ // internal proxy error instead of "unsupported, use ephemeral".
888
+ if (!teamExecutorAvailable(ctx)) throw teamExecutorUnavailableError()
889
+ return startTeamRound(ctx, parent, target, controllerConfig, signal)
890
+ }
891
+ const started = await startRound(engine, parent, root, target, controllerConfig, { objective: target.objective ?? '' }, signal)
892
+ return {
893
+ round: started.round,
894
+ result: started.run.result,
895
+ cancel: (reason?: string) => started.run.cancel(reason),
896
+ dispose: () => started.run.dispose(),
897
+ run: started.run,
898
+ }
899
+ }
900
+
901
+ /**
902
+ * Start one goal's round in the background (action 'start' and `revise`
903
+ * share this): install the handle, then queue the completion notice. The
904
+ * goal's status is already 'running' when this is called.
905
+ */
906
+ const launchBackground = async (target: LoopState): Promise<{ round: number }> => {
907
+ const controller = new AbortController()
908
+ if (exec.signal) {
909
+ if (exec.signal.aborted) controller.abort()
910
+ else exec.signal.addEventListener('abort', () => controller.abort(), { once: true })
911
+ }
912
+ const started: any = await startAnyRound(target, controller.signal)
913
+ const round = started.round as number
914
+ const handle: RunningRound = { goalId: target.goalId!, round, parent, root, loop: target, config: controllerConfig, run: started }
915
+ running.set(target.goalId!, handle)
916
+ started.result.then(async (settled: any) => {
917
+ handle.settled = settled
918
+ // Superseded goal (revise with abortPrevious: false): the successor
919
+ // carries the work from here — no collect notice, and release the run
920
+ // so a finished-but-replaced round cannot leak the handle.
921
+ if (target.supersededBy !== undefined) {
922
+ if (running.get(target.goalId!) === handle) {
923
+ running.delete(target.goalId!)
924
+ await disposeOnce(handle)
925
+ }
926
+ return
927
+ }
928
+ notifyRoundSettled(parent, target, round)
929
+ }).catch(() => { /* result never rejects by contract; belt and braces */ })
930
+ return { round }
931
+ }
932
+
933
+ // --- revise (append-only goal model) ------------------------------------
934
+ // A goal change NEVER rewrites the objective in place: it creates a
935
+ // successor goal (parentGoalId = old), starts that goal's first round, and
936
+ // freezes the old goal as superseded (supersededBy = new). The session
937
+ // agent decides whether the old goal's in-flight task agent is aborted.
938
+ if (action === 'revise') {
939
+ const previous = loop
940
+ const reason = typeof args.reason === 'string' ? args.reason.trim() : ''
941
+ if (reason.length === 0) {
942
+ throw new Error('cybernetics: action "revise" requires a non-empty "reason" — record why the goal changed')
943
+ }
944
+ if (previous.supersededBy !== undefined) {
945
+ throw new Error(
946
+ `cybernetics: goal "${previous.goalId}" was already superseded by "${previous.supersededBy}" — ` +
947
+ 'revise the successor goal instead of forking the lineage',
948
+ )
949
+ }
950
+ // v0.7 abort policy: explicit abortPrevious keeps the v0.6 manual
951
+ // semantics; when the caller omits it, all-goals mode auto-decides via
952
+ // the decision tree (see decideAbort). With allGoalsMode off, the
953
+ // omitted flag means false exactly as before (backward compat) and the
954
+ // audit records that legacy default instead of a fabricated decision.
955
+ const roundInFlight = running.has(previous.goalId!)
956
+ const abortDecision = decideAbort(previous, roundInFlight, args)
957
+ const legacyDefault = args.abortPrevious === undefined && !allGoalsMode()
958
+ const effectiveAbort = legacyDefault
959
+ ? { abort: false, mode: 'explicit' as const, basis: 'all-goals 模式未开启:abortPrevious 缺省沿用 v0.6 默认 false(不中止旧轮)' }
960
+ : abortDecision
961
+ const abortPrevious = args.abortPrevious !== undefined
962
+ ? args.abortPrevious === true
963
+ : (allGoalsMode() ? abortDecision.abort : false)
964
+ const previousHandle = running.get(previous.goalId!)
965
+ let aborted: string | undefined
966
+ if (abortPrevious && previousHandle) {
967
+ previousHandle.run.cancel(args.brief ?? `goal 被修订取代:${reason}`)
968
+ running.delete(previous.goalId!)
969
+ await disposeOnce(previousHandle)
970
+ aborted = '旧 goal 的在跑轮已中止'
971
+ }
972
+ const newGoalId = `cyb-${randomUUID().slice(0, 8)}`
973
+ const next = createGoal(previous, {
974
+ goalId: newGoalId,
975
+ ...(args.title ? { title: args.title } : {}),
976
+ ...(args.objective ? { objective: args.objective } : {}),
977
+ ...(args.depth ? { depth: args.depth } : {}),
978
+ ...(args.executor ? { executor: args.executor } : {}),
979
+ ...(args.role ? { role: args.role } : {}),
980
+ subgoal: args.objective ?? previous.objective ?? previous.title ?? newGoalId,
981
+ })
982
+ // A revision inherits the previous work order unless a corrected brief
983
+ // is supplied; the round-1 task agent reads `next.brief` verbatim.
984
+ if (args.brief ?? previous.brief) next.brief = args.brief ?? previous.brief
985
+ board.set(newGoalId, next)
986
+ supersedeGoal(previous, newGoalId)
987
+ await publishBoard()
988
+ const at = Date.now()
989
+ let revisionLog: string | undefined
990
+ try {
991
+ revisionLog = await writeRevisionLog(root, newGoalId, {
992
+ from: previous.goalId!, to: newGoalId, reason, at,
993
+ abort: effectiveAbort,
994
+ })
995
+ } catch {
996
+ // Audit is observability: a read-only workspace must not break the
997
+ // revision (the lineage fields on the board still carry the change).
998
+ }
999
+ const { round } = await launchBackground(next)
1000
+ return {
1001
+ goalId: newGoalId,
1002
+ previousGoalId: previous.goalId,
1003
+ revised: true,
1004
+ round,
1005
+ started: true,
1006
+ ...(aborted ? { aborted } : {}),
1007
+ abort: effectiveAbort,
1008
+ revision: { from: previous.goalId, to: newGoalId, reason, at },
1009
+ ...(revisionLog ? { revisionLog } : {}),
1010
+ }
1011
+ }
1012
+
1013
+ // A superseded goal is frozen history: only the successor may be driven.
1014
+ if (loop.supersededBy !== undefined) {
1015
+ throw new Error(
1016
+ `cybernetics: goal "${loop.goalId}" is superseded by "${loop.supersededBy}" — ` +
1017
+ 'drive the successor goal (see action "list")',
1018
+ )
1019
+ }
1020
+
295
1021
  // --- cancel -------------------------------------------------------------
296
1022
  if (action === 'cancel') {
297
1023
  const h = running.get(loop.goalId!)
298
1024
  if (!h) return { goalId: loop.goalId, cancelled: '该 goal 当前没有运行中的轮' }
299
1025
  loop.status = 'cancelled'
300
1026
  h.run.cancel(args.brief ?? 'session agent 决定终止(用户修正)')
1027
+ await publishBoard()
301
1028
  return { goalId: loop.goalId, round: h.round, cancelled: '已请求中止;完成通知到达后请 collect 折叠结果' }
302
1029
  }
303
1030
 
@@ -309,7 +1036,9 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
309
1036
  const { result, decision } = await foldRound(h.root, h.round, h.settled, h.loop, h.config)
310
1037
  applyDecision(h.loop, h.round, result, decision)
311
1038
  running.delete(loop.goalId!)
312
- await h.run.dispose()
1039
+ await disposeOnce(h)
1040
+ // Round settlement decision applied — republish (round / status / subgoal).
1041
+ await publishBoard()
313
1042
  return { goalId: loop.goalId, round: h.round, decision, result }
314
1043
  }
315
1044
 
@@ -318,35 +1047,33 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
318
1047
  throw new Error(`cybernetics: goal "${loop.goalId}" already has a round in flight — collect or cancel it first`)
319
1048
  }
320
1049
  loop.status = 'running'
1050
+ // In-flight visibility: the blocking 'round' path can run for minutes, so
1051
+ // publish before awaiting it (the async path needs no second publish —
1052
+ // the state did not change).
1053
+ await publishBoard()
321
1054
 
322
1055
  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 */ })
1056
+ const { round } = await launchBackground(loop)
337
1057
  return { goalId: loop.goalId, round, started: true }
338
1058
  }
339
1059
 
340
- // Blocking round (default).
341
- const { result, decision } = await runRound(
342
- engine, parent, root, loop, controllerConfig, { objective }, exec.signal,
343
- )
1060
+ // Blocking round (default). Both substrates expose {round, result, dispose}.
1061
+ const handle: any = await startAnyRound(loop, exec.signal)
1062
+ let settled: any
1063
+ try {
1064
+ settled = await handle.result
1065
+ } finally {
1066
+ await handle.dispose()
1067
+ }
1068
+ const { result, decision } = await foldRound(root, handle.round, settled, loop, controllerConfig)
344
1069
  applyDecision(loop, result.round, result, decision)
1070
+ // Round settlement decision applied — republish (round / status / subgoal).
1071
+ await publishBoard()
345
1072
  return { goalId: loop.goalId, round: result.round, decision, result }
346
1073
  },
347
1074
  })
348
1075
 
349
- return () => disposer()
1076
+ return () => { disposer(); disposers.forEach((d) => d()) }
350
1077
  }
351
1078
 
352
1079
  /** Fold a controller decision back into the loop's durable-ish state. */