@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/lib/client.js CHANGED
@@ -3,8 +3,13 @@
3
3
  // host — no plugin HTTP route needed. Hand-written ModuleLoader wrapper, same
4
4
  // format the host bundler emits (see dsh-context / dsh-maze client.js).
5
5
  /* global window */
6
+ // Registration id = the package name in package.json, NOT the short plugin row
7
+ // id: the client-modules boot graph keys every row by the package manifest name
8
+ // (`@mars.liu/dsh-cybernetics`) and only materializes a row whose bundle
9
+ // registered under exactly that id — a stale `dsh-cybernetics` here executes
10
+ // the batch script fine, then reports "loaded without registering".
6
11
  window.__ModuleLoader__.load({
7
- id: 'dsh-cybernetics',
12
+ id: '@mars.liu/dsh-cybernetics',
8
13
  factory: (require) => {
9
14
  var module = { exports: {} };
10
15
  var exports = module.exports;
@@ -35,6 +40,28 @@ window.__ModuleLoader__.load({
35
40
  'agent.noData': '目标数据未同步',
36
41
  'agents.empty': '本会话当前没有子 agent。',
37
42
  'agents.count': '{running}/{total} 个 agent 运行中',
43
+ // v0.6 multi-goal projection (`cyberneticsGoals`) — every goal of the
44
+ // session in one tab, updated in place.
45
+ 'goals.count': '{count} 个目标',
46
+ 'goal.round': '第 {round} 轮',
47
+ 'goal.subgoal': '当前子目标',
48
+ 'goal.depth.quick': '轻量',
49
+ 'goal.depth.full': '完整',
50
+ 'goal.executor.ephemeral': '临时执行者',
51
+ 'goal.executor.persistent': '持久执行者',
52
+ 'goalStatus.pending': '待启动',
53
+ 'goalStatus.running': '进行中',
54
+ 'goalStatus.paused': '已暂停',
55
+ 'goalStatus.met': '已完成',
56
+ 'goalStatus.cancelled': '已取消',
57
+ 'goalStatus.superseded': '已被取代',
58
+ 'goalStatus.failed': '受阻',
59
+ // v0.6 append-only goal lineage lines (server sets these fields)
60
+ 'goal.supersededBy': '已被「{title}」取代',
61
+ 'goal.revisedFrom': '修订自「{title}」',
62
+ // v0.6 executor line: the durable teammate's name (persistent only)
63
+ 'goal.member': '成员 {name}',
64
+ 'control.goals': '目标',
38
65
  };
39
66
  const en = {
40
67
  'view.goal': 'Goal',
@@ -57,6 +84,24 @@ window.__ModuleLoader__.load({
57
84
  'agent.noData': 'Goal data not synced',
58
85
  'agents.empty': 'No child agents in this session yet.',
59
86
  'agents.count': '{running}/{total} agents running',
87
+ 'goals.count': '{count} goals',
88
+ 'goal.round': 'round {round}',
89
+ 'goal.subgoal': 'Current subgoal',
90
+ 'goal.depth.quick': 'Quick',
91
+ 'goal.depth.full': 'Full',
92
+ 'goal.executor.ephemeral': 'Ephemeral executor',
93
+ 'goal.executor.persistent': 'Persistent executor',
94
+ 'goalStatus.pending': 'Pending',
95
+ 'goalStatus.running': 'Running',
96
+ 'goalStatus.paused': 'Paused',
97
+ 'goalStatus.met': 'Complete',
98
+ 'goalStatus.cancelled': 'Cancelled',
99
+ 'goalStatus.superseded': 'Superseded',
100
+ 'goalStatus.failed': 'Blocked',
101
+ 'goal.supersededBy': 'Superseded by "{title}"',
102
+ 'goal.revisedFrom': 'Revised from "{title}"',
103
+ 'goal.member': 'Member {name}',
104
+ 'control.goals': 'Goals',
60
105
  };
61
106
  //#endregion
62
107
 
@@ -68,12 +113,104 @@ window.__ModuleLoader__.load({
68
113
  };
69
114
 
70
115
  //#region goal view component
71
- // props.goal: ObservableSnapshot face of the session `goal` projection.
72
- // The snapshot may be the raw state ({current: GoalProjection | null, ...})
73
- // or the projection value itself — normalize both; undefined = not yet arrived.
74
- function GoalView(props) {
116
+ // v0.6: the tab is driven by the plugin's own `cyberneticsGoals`
117
+ // projection — the WHOLE multi-goal board of this session, pushed live
118
+ // by the host (one whole-value `cybernetics/goals` event per change, no
119
+ // polling). When that key is absent (older plugin/host) or carries an
120
+ // empty board, fall back to dsh-goal's single-slot `goal` projection,
121
+ // which is exactly the pre-v0.6 rendering.
122
+ const GOAL_STATUS_KEY = {
123
+ pending: 'goalStatus.pending',
124
+ running: 'goalStatus.running',
125
+ paused: 'goalStatus.paused',
126
+ met: 'goalStatus.met',
127
+ cancelled: 'goalStatus.cancelled',
128
+ superseded: 'goalStatus.superseded',
129
+ failed: 'goalStatus.failed',
130
+ };
131
+
132
+ function goalStatusStyle(status) {
133
+ if (status === 'running') return { color: '#3fb950' };
134
+ if (status === 'failed') return { color: '#d29922' };
135
+ if (status === 'met') return { color: '#58a6ff' };
136
+ return { color: '#8b949e' };
137
+ }
138
+
139
+ // End-of-life statuses: the same two-segment rule the goal tab uses
140
+ // (unfinished first, finished after). The server already sorts that way;
141
+ // this stable partition keeps the invariant even if a view arrives with a
142
+ // different order, without re-sorting anything inside a segment.
143
+ const COMPLETED_GOAL_STATUSES = ['met', 'cancelled', 'superseded'];
144
+ function groupGoals(goals) {
145
+ const open = [];
146
+ const done = [];
147
+ for (const g of goals) {
148
+ if (COMPLETED_GOAL_STATUSES.indexOf(g.status) >= 0) done.push(g);
149
+ else open.push(g);
150
+ }
151
+ return open.concat(done);
152
+ }
153
+
154
+ // One goal row: title + status badge + round + current subgoal + tags.
155
+ // Append-only lineage (v0.6): a superseded goal names its successor, a
156
+ // revised goal names where it came from — titles resolved from the board
157
+ // snapshot, falling back to the goalId when the successor is not listed.
158
+ function GoalRow(props) {
159
+ const t = props.t;
160
+ const g = props.goal;
161
+ const titles = props.titles || {};
162
+ const statusKey = GOAL_STATUS_KEY[g.status] ?? 'goalStatus.pending';
163
+ const depthKey = g.depth === 'quick' ? 'goal.depth.quick' : 'goal.depth.full';
164
+ const executorKey = g.executor === 'persistent' ? 'goal.executor.persistent' : 'goal.executor.ephemeral';
165
+ return jsx.jsxs('div', { style: styles.goalCard, children: [
166
+ jsx.jsxs('div', { style: styles.goalHead, children: [
167
+ jsx.jsx('span', { style: styles.goalTitle, children: g.title || g.goalId }),
168
+ jsx.jsx('span', { style: { ...styles.badge, ...goalStatusStyle(g.status) }, children: t(statusKey) }),
169
+ ] }),
170
+ jsx.jsxs('div', { style: styles.goalMetaRow, children: [
171
+ jsx.jsx('span', { style: styles.meta, children: t('goal.round', { round: g.round ?? 0 }) }),
172
+ jsx.jsx('span', { style: styles.tag, children: t(depthKey) }),
173
+ jsx.jsx('span', { style: styles.tag, children: t(executorKey) }),
174
+ g.memberName
175
+ ? jsx.jsx('span', { style: styles.tag, children: t('goal.member', { name: g.memberName }) })
176
+ : null,
177
+ ] }),
178
+ g.supersededBy
179
+ ? jsx.jsx('div', { style: styles.lineage, children: t('goal.supersededBy', { title: titles[g.supersededBy] || g.supersededBy }) })
180
+ : null,
181
+ g.parentGoalId
182
+ ? jsx.jsx('div', { style: styles.lineage, children: t('goal.revisedFrom', { title: titles[g.parentGoalId] || g.parentGoalId }) })
183
+ : null,
184
+ g.subgoal
185
+ ? jsx.jsxs('div', { style: styles.goalSubgoal, children: [t('goal.subgoal') + ':', g.subgoal] })
186
+ : null,
187
+ ] });
188
+ }
189
+
190
+ // Multi-goal list. Order is the projection's (server-side two segments:
191
+ // unfinished goals first, finished after; newest change first inside each),
192
+ // so the client renders verbatim and never re-sorts.
193
+ function GoalsList(props) {
194
+ const t = props.t;
195
+ const goals = props.goals;
196
+ const titles = {};
197
+ for (const g of goals) titles[g.goalId] = g.title || g.goalId;
198
+ return jsx.jsxs('div', { style: styles.root, children: [
199
+ jsx.jsx('div', { style: styles.badgeRow, children:
200
+ jsx.jsx('span', { style: styles.badge, children: t('goals.count', { count: goals.length }) }) }),
201
+ ...goals.map(function (g) {
202
+ return jsx.jsx(GoalRow, { goal: g, t: t, titles: titles }, g.goalId + ':' + (g.updatedAt ?? 0));
203
+ }),
204
+ jsx.jsx('div', { style: styles.hint, children: t('loopHint') }),
205
+ ] });
206
+ }
207
+
208
+ // props.raw: the session `goal` projection snapshot value. It may be the
209
+ // raw state ({current: GoalProjection | null, ...}) or the projection
210
+ // value itself — normalize both; undefined = not yet arrived.
211
+ function SingleGoalView(props) {
75
212
  const t = props.t;
76
- const raw = react.useSyncExternalStore(props.goal.subscribe, props.goal.getSnapshot);
213
+ const raw = props.raw;
77
214
  if (raw === undefined) return null;
78
215
  const goal = raw !== null && typeof raw === 'object' && 'current' in raw ? raw.current : raw;
79
216
  if (goal === null || goal === undefined) {
@@ -93,6 +230,16 @@ window.__ModuleLoader__.load({
93
230
  ] });
94
231
  }
95
232
 
233
+ function GoalView(props) {
234
+ const goalsRaw = react.useSyncExternalStore(props.goals.subscribe, props.goals.getSnapshot);
235
+ const singleRaw = react.useSyncExternalStore(props.goal.subscribe, props.goal.getSnapshot);
236
+ const goals = goalsRaw !== null && typeof goalsRaw === 'object' && Array.isArray(goalsRaw.goals)
237
+ ? goalsRaw.goals
238
+ : [];
239
+ if (goals.length > 0) return jsx.jsx(GoalsList, { t: props.t, goals: goals });
240
+ return jsx.jsx(SingleGoalView, { t: props.t, raw: singleRaw });
241
+ }
242
+
96
243
  var styles = {
97
244
  root: { display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 16px', overflow: 'auto' },
98
245
  empty: { padding: '12px 16px', opacity: 0.6 },
@@ -102,6 +249,17 @@ window.__ModuleLoader__.load({
102
249
  blocked: { color: '#d29922', fontSize: 12 },
103
250
  meta: { opacity: 0.65, fontSize: 12 },
104
251
  hint: { opacity: 0.45, fontSize: 11 },
252
+ // v0.6 multi-goal rows
253
+ goalCard: { display: 'flex', flexDirection: 'column', gap: 4, padding: '8px 10px', border: '1px solid rgba(127,127,127,0.25)', borderRadius: 6 },
254
+ goalHead: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 },
255
+ goalTitle: { fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
256
+ goalMetaRow: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
257
+ tag: { fontSize: 10, padding: '0 6px', borderRadius: 999, border: '1px solid rgba(127,127,127,0.35)', opacity: 0.75 },
258
+ goalSubgoal: { fontSize: 12, opacity: 0.85, whiteSpace: 'pre-wrap', lineHeight: 1.45 },
259
+ lineage: { fontSize: 11, opacity: 0.6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
260
+ // v0.6 control tab executor rows
261
+ controlGoalRow: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0, flexWrap: 'wrap', padding: '2px 0' },
262
+ goalCards: { display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 4 },
105
263
  agentRow: { borderLeft: '2px solid transparent', borderRadius: 4 },
106
264
  agentMain: { display: 'flex', flexDirection: 'column', gap: 2 },
107
265
  agentHead: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 },
@@ -122,13 +280,39 @@ window.__ModuleLoader__.load({
122
280
  //#endregion
123
281
 
124
282
  //#region control view component
125
- // The「控制 / Control」tab: every agent of the current session — the lead
126
- // itself plus the subagent catalog (recursively) — with its run state and
127
- // its own durable goal (dsh-goal projection values). All reactive input
128
- // comes from the session list store, which the Session Controller updates
129
- // live via the control stream (running flips, goal/control frames).
283
+ // The「控制 / Control」tab: every goal of the session with its EXECUTOR
284
+ // line (substrate + the durable teammate's name when it runs on one),
285
+ // plus every agent of the current session — the lead itself and the
286
+ // subagent catalog (recursively) — with its run state and its own durable
287
+ // goal (dsh-goal projection values). Goal data comes from the same
288
+ // `cyberneticsGoals` whole-value projection the goal tab reads; the agent
289
+ // tree comes from the session list store, updated live by the Session
290
+ // Controller via the control stream (running flips, goal/control frames).
130
291
  const MAX_TREE_DEPTH = 3;
131
292
 
293
+ // No goal face (older host/plugin): keep the agent tree rendering exactly
294
+ // as before instead of throwing.
295
+ const NOOP_GOALS_FACE = { subscribe: () => () => {}, getSnapshot: () => undefined };
296
+
297
+ // One goal row of the control tab: status + title + executor + durable
298
+ // member name + round. Same fields as the goal tab, restated as the
299
+ // operator's line — who is executing this goal and how far it got.
300
+ function ControlGoalRow(props) {
301
+ const t = props.t;
302
+ const g = props.goal;
303
+ const statusKey = GOAL_STATUS_KEY[g.status] ?? 'goalStatus.pending';
304
+ const executorKey = g.executor === 'persistent' ? 'goal.executor.persistent' : 'goal.executor.ephemeral';
305
+ return jsx.jsxs('div', { style: styles.controlGoalRow, children: [
306
+ jsx.jsx('span', { style: { ...styles.badge, ...goalStatusStyle(g.status) }, children: t(statusKey) }),
307
+ jsx.jsx('span', { style: styles.agentName, children: g.title || g.goalId }),
308
+ jsx.jsx('span', { style: styles.tag, children: t(executorKey) }),
309
+ g.memberName
310
+ ? jsx.jsx('span', { style: styles.tag, children: t('goal.member', { name: g.memberName }) })
311
+ : null,
312
+ jsx.jsx('span', { style: styles.meta, children: t('goal.round', { round: g.round ?? 0 }) }),
313
+ ] });
314
+ }
315
+
132
316
  /** Depth-first agent rows rooted at `rootId`: lead + subagentCatalog, recursed. */
133
317
  function collectAgents(list, rootId) {
134
318
  const rows = [];
@@ -184,9 +368,23 @@ window.__ModuleLoader__.load({
184
368
  const t = props.t;
185
369
  const sessions = props.sessions;
186
370
  const list = react.useSyncExternalStore(sessions.list.subscribe, sessions.list.getSnapshot);
371
+ const goalsFace = props.goals ?? NOOP_GOALS_FACE;
372
+ const goalsRaw = react.useSyncExternalStore(goalsFace.subscribe, goalsFace.getSnapshot);
373
+ const goals = goalsRaw !== null && typeof goalsRaw === 'object' && Array.isArray(goalsRaw.goals)
374
+ ? groupGoals(goalsRaw.goals)
375
+ : [];
187
376
  const agents = collectAgents(list, props.sessionId);
188
377
  const runningCount = agents.filter((a) => a.running).length;
189
378
  return jsx.jsxs('div', { style: styles.root, children: [
379
+ goals.length > 0
380
+ ? jsx.jsxs('div', { style: styles.goalCards, children: [
381
+ jsx.jsxs('div', { style: styles.badgeRow, children: [
382
+ jsx.jsx('span', { style: styles.meta, children: t('control.goals') }),
383
+ jsx.jsx('span', { style: styles.badge, children: t('goals.count', { count: goals.length }) }),
384
+ ] }),
385
+ ...goals.map((g) => jsx.jsx(ControlGoalRow, { goal: g, t }, 'goal:' + g.goalId + ':' + (g.updatedAt ?? 0))),
386
+ ] })
387
+ : null,
190
388
  jsx.jsxs('div', { style: styles.badgeRow, children: [
191
389
  jsx.jsx('span', { style: styles.badge, children: t('agents.count', { total: agents.length, running: runningCount }) }),
192
390
  ] }),
@@ -218,19 +416,39 @@ window.__ModuleLoader__.load({
218
416
  inject: (sessionId) => {
219
417
  const binding = ctx.sessions.binding(sessionId);
220
418
  if (binding === undefined) throw new Error('dsh-cybernetics: session "' + sessionId + '" unavailable');
221
- return { goal: binding.session.projections.faceOf('goal') };
419
+ // v0.6: both faces. `cyberneticsGoals` carries the whole goal
420
+ // board (live, pushed by the host); `goal` stays as the
421
+ // backward-compatible fallback for older plugin/host builds.
422
+ return {
423
+ goals: binding.session.projections.faceOf('cyberneticsGoals'),
424
+ goal: binding.session.projections.faceOf('goal'),
425
+ };
222
426
  },
223
427
  }, GoalView));
224
428
 
225
429
  // Control tab: goals + run states of every agent in this session,
226
430
  // driven by the session list store (live via the control stream).
431
+ // The goal face is optional (older host/plugin): the agent tree then
432
+ // renders exactly as before.
227
433
  ctx.slots.inject('conversation.view', () => ctx.slots.register({
228
434
  name: 'conversation.view',
229
435
  id: 'cybernetics-control',
230
436
  order: 16,
231
437
  locale: NS,
232
438
  label: () => t('view.control'),
233
- inject: (sessionId) => ({ sessionId, sessions: ctx.sessions }),
439
+ inject: (sessionId) => {
440
+ let goals = NOOP_GOALS_FACE;
441
+ try {
442
+ const binding = ctx.sessions.binding(sessionId);
443
+ const face = binding && binding.session && binding.session.projections
444
+ ? binding.session.projections.faceOf('cyberneticsGoals')
445
+ : undefined;
446
+ if (face) goals = face;
447
+ } catch {
448
+ // No multi-goal projection: keep the control tab's agent tree.
449
+ }
450
+ return { sessionId, sessions: ctx.sessions, goals };
451
+ },
234
452
  }, ControlView));
235
453
  }
236
454
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "@mars.liu/dsh-cybernetics",
3
- "version": "0.2.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
+ "files": [
6
+ "src",
7
+ "lib",
8
+ "workflows",
9
+ "docs",
10
+ "locale",
11
+ "cordis.patch.yml",
12
+ "README.md"
13
+ ],
5
14
  "description": "Cybernetic work protocol for DSH: the session goal (dsh-goal) driven loop of task -> verify -> triage agents, implicitly advanced via one workflow run per round. Server plugin + client goal tab.",
6
15
  "type": "module",
7
16
  "main": "src/index.ts",
@@ -29,5 +38,8 @@
29
38
  "platform": "web",
30
39
  "immediately": true
31
40
  }
41
+ },
42
+ "peerDependencies": {
43
+ "@deepseek-ai/dsh-session": "*"
32
44
  }
33
45
  }
package/src/controller.ts CHANGED
@@ -22,6 +22,8 @@ export interface RoundResult {
22
22
  verdict?: unknown
23
23
  triage?: unknown
24
24
  failed?: string
25
+ /** Execution substrate used for this round (v0.6 audit). */
26
+ executor?: 'ephemeral' | 'persistent'
25
27
  }
26
28
 
27
29
  /** LLM target overrides for one sub-agent role; workflow `agent()` opts subset. */
@@ -70,9 +72,60 @@ export interface LoopState {
70
72
  role: string
71
73
  /** Detailed per-round work order extracted by the calling model; passed to the task agent verbatim. */
72
74
  brief?: string
73
- /** Lifecycle: running (a round in flight) / paused / met (verify passed, awaits user) / cancelled. */
74
- status?: 'running' | 'paused' | 'met' | 'cancelled'
75
+ /** Round depth: 'quick' = task agent only (lightweight analysis); 'full' = task→verify→triage (default). */
76
+ depth?: 'quick' | 'full'
77
+ /** Execution substrate: 'ephemeral' (workflow subagents, default) or 'persistent' (Agent Teams teammate). */
78
+ executor?: 'ephemeral' | 'persistent'
79
+ /** Persistent teammate name, set on the first persistent round and reused afterwards. */
80
+ memberName?: string
81
+ /**
82
+ * Lifecycle: running (a round in flight) / paused / met (verify passed, awaits
83
+ * user) / cancelled / superseded (a revision created a successor goal; this
84
+ * goal keeps its final state and is never driven again).
85
+ */
86
+ status?: 'running' | 'paused' | 'met' | 'cancelled' | 'superseded'
75
87
  paused?: { reason: ResumableReason; note: string }
88
+ /**
89
+ * Append-only goal lineage (v0.6, user 2026-11-02): a revision never edits the
90
+ * objective in place — it creates a NEW goal, and the old one keeps its final
91
+ * state. `parentGoalId` = the goal this one was revised from.
92
+ */
93
+ parentGoalId?: string
94
+ /** Set on a superseded goal: the id of the successor that replaced it. */
95
+ supersededBy?: string
96
+ /**
97
+ * In-place revision log kept for wire compatibility (pre-append-only shape).
98
+ * v0.6 append-only model: a revision creates a successor goal instead, so new
99
+ * revisions are recorded as `parentGoalId`/`supersededBy` plus the
100
+ * `.cybernetics/revisions-<newGoalId>.jsonl` audit line, and this stays empty.
101
+ */
102
+ revisions?: Array<{ version: number; reason: string; at: number }>
103
+ }
104
+
105
+ /** One `revise` transition as written to `.cybernetics/revisions-<newGoalId>.jsonl`. */
106
+ export interface GoalRevisionRecord {
107
+ /** Superseded goal id. */
108
+ from: string
109
+ /** Newly created successor goal id. */
110
+ to: string
111
+ /** Why the goal changed (required on the `revise` action). */
112
+ reason: string
113
+ /** Epoch milliseconds of the revision. */
114
+ at: number
115
+ }
116
+
117
+ /** Input for the append-only goal creation path. */
118
+ export interface GoalSeed {
119
+ goalId: string
120
+ title?: string
121
+ /** Overrides the inherited objective when present. */
122
+ objective?: string
123
+ /** Goal this one was revised from (lineage link). */
124
+ parentGoalId?: string
125
+ depth?: 'quick' | 'full'
126
+ executor?: 'ephemeral' | 'persistent'
127
+ role?: string
128
+ subgoal?: string
76
129
  }
77
130
 
78
131
  /** Append one round's structured result to the per-workspace audit log. */
@@ -85,6 +138,60 @@ async function writeRoundLog(root: string, goalId: string | undefined, round: nu
85
138
  await writeFile(join(root, STATE_DIR, `round-${slug}-${round}.json`), JSON.stringify(result, null, 2))
86
139
  }
87
140
 
141
+ /**
142
+ * Append one revision line to `.cybernetics/revisions-<newGoalId>.jsonl`.
143
+ * One line per transition, so a revision chain is readable (and greppable)
144
+ * without reconstructing it from per-goal fields. The directory is created
145
+ * lazily; the caller treats a write failure as non-fatal (see `revise`).
146
+ */
147
+ export async function writeRevisionLog(root: string, newGoalId: string, record: GoalRevisionRecord): Promise<string> {
148
+ await mkdir(join(root, STATE_DIR), { recursive: true })
149
+ const slug = newGoalId.replace(/[^a-zA-Z0-9._-]/g, '_')
150
+ const file = join(root, STATE_DIR, `revisions-${slug}.jsonl`)
151
+ await writeFile(file, `${JSON.stringify(record)}\n`, { flag: 'a' })
152
+ return file
153
+ }
154
+
155
+ /**
156
+ * Build the successor goal of an append-only revision. Everything the old goal
157
+ * owned that is not per-round state — title, objective, depth, executor, role —
158
+ * is inherited unless a seed field overrides it; the new goal starts at round 0
159
+ * and carries `parentGoalId`. Pure: it neither mutates nor registers anything.
160
+ */
161
+ export function createGoal(previous: LoopState | undefined, seed: GoalSeed): LoopState {
162
+ const objective = seed.objective ?? previous?.objective ?? previous?.title ?? ''
163
+ const title = seed.title ?? previous?.title ?? objective.split('\n')[0].slice(0, 60)
164
+ const goal: LoopState = {
165
+ goalId: seed.goalId,
166
+ title,
167
+ objective,
168
+ round: 0,
169
+ doneSummary: '',
170
+ subgoal: seed.subgoal ?? (objective.length > 0 ? objective : title),
171
+ role: seed.role ?? previous?.role ?? 'default',
172
+ status: 'running',
173
+ }
174
+ const depth = seed.depth ?? previous?.depth
175
+ if (depth !== undefined) goal.depth = depth
176
+ const executor = seed.executor ?? previous?.executor
177
+ if (executor !== undefined) goal.executor = executor
178
+ const parentGoalId = seed.parentGoalId ?? previous?.goalId
179
+ if (parentGoalId !== undefined) goal.parentGoalId = parentGoalId
180
+ return goal
181
+ }
182
+
183
+ /**
184
+ * Freeze a goal as superseded by a successor. Mutates the loop in place (the
185
+ * registry holds the same object); the paused marker is dropped so the view
186
+ * does not mix "awaiting human" with "replaced".
187
+ */
188
+ export function supersedeGoal(previous: LoopState, newGoalId: string): LoopState {
189
+ previous.status = 'superseded'
190
+ previous.supersededBy = newGoalId
191
+ previous.paused = undefined
192
+ return previous
193
+ }
194
+
88
195
  /**
89
196
  * Start one round's workflow run without awaiting it. Returns the round
90
197
  * number and the live run; the caller either awaits `run.result` itself
@@ -109,6 +216,7 @@ export async function startRound(
109
216
  state: { round, doneSummary: state.doneSummary },
110
217
  subgoal: state.subgoal,
111
218
  brief: state.brief,
219
+ depth: state.depth ?? 'full',
112
220
  role: state.role,
113
221
  roles: config.roles ?? {},
114
222
  },
@@ -137,7 +245,9 @@ export async function foldRound(
137
245
  } else {
138
246
  result = settled.value as RoundResult
139
247
  }
140
- await writeRoundLog(root, state.goalId, round, result)
248
+ // v0.6 audit: record which substrate ran the round.
249
+ const logged: RoundResult = { ...result, executor: state.executor ?? 'ephemeral' }
250
+ await writeRoundLog(root, state.goalId, round, logged)
141
251
  return { result, decision: decide(result, state, config) }
142
252
  }
143
253