@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.
@@ -0,0 +1,763 @@
1
+ /**
2
+ * The session projection that carries the plugin's WHOLE multi-goal board to
3
+ * the client, plus the whole-value event writer that feeds it.
4
+ *
5
+ * Why a projection and not a route: dsh-goal's own `goal` projection is
6
+ * single-slot (one focus goal per session), so the「目标 / Goal」tab could
7
+ * never show the `cyb-*` goals this plugin keeps in its own `boards` map.
8
+ * The host's projection registry (ctx.sessionProjections) is the sanctioned
9
+ * seam: one pure synchronous fold per registered key, driven eagerly over
10
+ * committed session events, with the client value pushed live — no polling.
11
+ *
12
+ * Whole-value event rule (upstream contract, load-bearing): a state-carrying
13
+ * event MUST carry the complete post-change state, never a delta. Every
14
+ * publish therefore snapshots the session's entire goal board.
15
+ *
16
+ * Zero runtime dependencies: the real `ProjectionDefinition` comes from
17
+ * @deepseek-ai/dsh-session-projection (a Zod schema and a Zod view schema).
18
+ * That package is not installed in this checkout (the plugin is `link:`ed
19
+ * into a dsh profile), and the host only ever calls `.parse(...)` on both
20
+ * schemas, so a duck-typed `{ parse: (v) => v }` is behaviourally exact.
21
+ * `src/upstream.d.ts` declares the two host modules just so the type-merge
22
+ * below type-checks standalone; the shape mirrors upstream's own
23
+ * merge-extensible tables.
24
+ * @module dsh-cybernetics/projection
25
+ */
26
+
27
+ import { appendFileSync, existsSync, mkdirSync, realpathSync } from 'node:fs'
28
+ import { createRequire } from 'node:module'
29
+ import { dirname, join } from 'node:path'
30
+ import { fileURLToPath, pathToFileURL } from 'node:url'
31
+ import { appendPluginRecord } from '@deepseek-ai/dsh-session'
32
+ import type { LoopState } from './controller.ts'
33
+
34
+ /** One goal revision (v0.6 outer loop: agent proposes, the user confirms). */
35
+ export interface GoalRevisionView {
36
+ /** Monotonic revision number within the goal. */
37
+ version: number
38
+ /** Why the goal changed. */
39
+ reason: string
40
+ /** Epoch milliseconds of the revision. */
41
+ at: number
42
+ }
43
+
44
+ /** Lifecycle as the tab renders it. */
45
+ export type GoalStatus = 'running' | 'paused' | 'met' | 'cancelled' | 'superseded' | 'failed' | 'pending'
46
+
47
+ /** Execution substrate (v0.6: orthogonal to depth). */
48
+ export type GoalExecutor = 'ephemeral' | 'persistent'
49
+
50
+ /**
51
+ * Statuses that END a goal's life. The tab's two-segment ordering puts the
52
+ * unfinished group first and this group last (user 2026-11-02 append-only model).
53
+ */
54
+ export const COMPLETED_GOAL_STATUSES: ReadonlySet<GoalStatus> = new Set<GoalStatus>(['met', 'cancelled', 'superseded'])
55
+
56
+ /** One goal as the client tab renders it (wire-safe plain JSON). */
57
+ export interface GoalView {
58
+ goalId: string
59
+ title: string
60
+ objective: string
61
+ status: GoalStatus
62
+ depth: 'quick' | 'full'
63
+ executor: GoalExecutor
64
+ /**
65
+ * Name of the durable Agent Teams teammate executing this goal (v0.6
66
+ * `executor: 'persistent'`). Present only once a persistent round has
67
+ * provisioned the member; ephemeral goals never carry it.
68
+ */
69
+ memberName?: string
70
+ /** Rounds completed (the next round is `round + 1`). */
71
+ round: number
72
+ /** Current subgoal of the inner correction loop. */
73
+ subgoal?: string
74
+ /** Append-only lineage: the goal this one was revised from. */
75
+ parentGoalId?: string
76
+ /** Set when a revision replaced this goal: the successor's goalId. */
77
+ supersededBy?: string
78
+ revisions: GoalRevisionView[]
79
+ /** Epoch milliseconds of the last content change (ordering key for the tab). */
80
+ updatedAt: number
81
+ }
82
+
83
+ /** Fold state / wire value of the `cyberneticsGoals` projection. */
84
+ export interface CyberneticsGoalsState {
85
+ goals: GoalView[]
86
+ }
87
+
88
+ // Type merge into the host's projection tables (compile-time only).
89
+ declare module '@deepseek-ai/dsh-session-projection/types' {
90
+ interface SessionProjectionStateMap {
91
+ cyberneticsGoals: CyberneticsGoalsState
92
+ }
93
+ interface SessionProjectionMap {
94
+ cyberneticsGoals: CyberneticsGoalsState
95
+ }
96
+ }
97
+
98
+ // Type merge for the session event this plugin appends. The host accepts any
99
+ // registered event type at runtime (`Session.append(type, data)`); the merge
100
+ // keeps that call site typed instead of `any`.
101
+ declare module '@deepseek-ai/dsh-session/types' {
102
+ interface PluginRecordMap {
103
+ 'plugin:cybernetics/goals': CyberneticsGoalsState
104
+ }
105
+ }
106
+
107
+ /** Projection key; also the session event type (one key, one event). */
108
+ export const PROJECTION_KEY = 'cyberneticsGoals' as const
109
+ /**
110
+ * The persisted record type. The host's persistence loader FAIL-CLOSES on an
111
+ * unknown, non-ignorable session event (validateStoredEvents →
112
+ * SessionFormatUnsupportedError), so this board must travel as a plugin
113
+ * record: `appendPluginRecord` writes `{type, data, ignorable: true}`, which
114
+ * every reader may safely skip when it does not know the type.
115
+ */
116
+ export const EVENT_TYPE = 'plugin:cybernetics/goals' as const
117
+ /** Pre-fix event name; accepted on restore so older logs still fold. */
118
+ export const LEGACY_EVENT_TYPE = 'cybernetics/goals' as const
119
+ /** Persisted-cache invalidation version of the fold. */
120
+ export const STATE_VERSION = 2
121
+
122
+ /** Duck-typed Zod stand-in: the host only calls `parse`. */
123
+ interface SchemaLike<T> {
124
+ parse(value: unknown): T
125
+ }
126
+
127
+ const passThrough = <T>(): SchemaLike<T> => ({ parse: (value) => value as T })
128
+
129
+ /**
130
+ * Structural replica of the host's `ProjectionDefinition` (fields we use).
131
+ * Kept local because the upstream package is not a dependency of this
132
+ * zero-dep plugin; `cyberneticsGoalsProjection()` is handed to
133
+ * `ctx.sessionProjections.register()` where the host's own typing applies.
134
+ */
135
+ export interface ProjectionDefinitionLike {
136
+ key: typeof PROJECTION_KEY
137
+ stateSchema: SchemaLike<CyberneticsGoalsState>
138
+ init(header: unknown, inheritedEventCount: number): CyberneticsGoalsState
139
+ apply(state: CyberneticsGoalsState, event: { type: string; data?: unknown }): CyberneticsGoalsState
140
+ wire: {
141
+ viewSchema: SchemaLike<CyberneticsGoalsState>
142
+ view(state: CyberneticsGoalsState): CyberneticsGoalsState
143
+ }
144
+ stateVersion: number
145
+ }
146
+
147
+ /** Empty fold state (a fresh session with no goals yet). */
148
+ export function emptyGoalsState(): CyberneticsGoalsState {
149
+ return { goals: [] }
150
+ }
151
+
152
+ function isGoalView(value: unknown): value is GoalView {
153
+ if (value === null || typeof value !== 'object') return false
154
+ const g = value as Record<string, unknown>
155
+ return typeof g.goalId === 'string' && typeof g.status === 'string'
156
+ }
157
+
158
+ /** Normalize one wire goal into a fresh plain-JSON object. */
159
+ function normalizeGoal(goal: GoalView): GoalView {
160
+ return {
161
+ goalId: String(goal.goalId),
162
+ title: typeof goal.title === 'string' ? goal.title : String(goal.goalId),
163
+ objective: typeof goal.objective === 'string' ? goal.objective : '',
164
+ status: goal.status,
165
+ depth: goal.depth === 'quick' ? 'quick' : 'full',
166
+ executor: goal.executor === 'persistent' ? 'persistent' : 'ephemeral',
167
+ ...(typeof goal.memberName === 'string' && goal.memberName.length > 0
168
+ ? { memberName: goal.memberName }
169
+ : {}),
170
+ round: Number.isFinite(goal.round) ? Number(goal.round) : 0,
171
+ ...(typeof goal.subgoal === 'string' && goal.subgoal.length > 0 ? { subgoal: goal.subgoal } : {}),
172
+ ...(typeof goal.parentGoalId === 'string' && goal.parentGoalId.length > 0
173
+ ? { parentGoalId: goal.parentGoalId }
174
+ : {}),
175
+ ...(typeof goal.supersededBy === 'string' && goal.supersededBy.length > 0
176
+ ? { supersededBy: goal.supersededBy }
177
+ : {}),
178
+ revisions: Array.isArray(goal.revisions)
179
+ ? goal.revisions.map((r) => ({
180
+ version: Number.isFinite(r?.version) ? Number(r.version) : 0,
181
+ reason: typeof r?.reason === 'string' ? r.reason : '',
182
+ at: Number.isFinite(r?.at) ? Number(r.at) : 0,
183
+ }))
184
+ : [],
185
+ updatedAt: Number.isFinite(goal.updatedAt) ? Number(goal.updatedAt) : 0,
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The `cyberneticsGoals` projection unit: `{goals: [...]}` rebuilt from each
191
+ * whole-value `cybernetics/goals` event, byte-identical for unrelated events
192
+ * (same state reference — the host's zero-work fast path).
193
+ */
194
+ export function cyberneticsGoalsProjection(): ProjectionDefinitionLike {
195
+ return {
196
+ key: PROJECTION_KEY,
197
+ stateSchema: passThrough<CyberneticsGoalsState>(),
198
+ init: () => emptyGoalsState(),
199
+ apply(state, event) {
200
+ if (event?.type !== EVENT_TYPE && event?.type !== LEGACY_EVENT_TYPE) return state
201
+ const data = event.data as CyberneticsGoalsState | undefined
202
+ if (data === null || typeof data !== 'object' || !Array.isArray(data.goals)) return state
203
+ const goals = data.goals.filter(isGoalView).map(normalizeGoal)
204
+ // A fresh object per owned event: the wire view is the state itself, and
205
+ // the host republishes only when the view reference changes.
206
+ return { goals }
207
+ },
208
+ wire: {
209
+ viewSchema: passThrough<CyberneticsGoalsState>(),
210
+ view: (state) => state,
211
+ },
212
+ stateVersion: STATE_VERSION,
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Content fingerprint -> last-change timestamp, per loop object. `updatedAt`
218
+ * must move only when a goal's content moves (the tab sorts by it, and an
219
+ * unchanged republish must stay a no-op); deriving it from the fingerprint
220
+ * keeps that invariant in one place instead of sprinkling clock writes over
221
+ * every mutation site.
222
+ */
223
+ const goalStamps = new WeakMap<object, { fingerprint: string; at: number }>()
224
+
225
+ function statusOf(loop: LoopState): GoalStatus {
226
+ const status = loop.status
227
+ if (status === undefined) return 'pending'
228
+ if (status === 'paused' && loop.paused?.reason === 'failed') return 'failed'
229
+ return status
230
+ }
231
+
232
+ /** Project one loop onto the client view (fresh plain JSON). */
233
+ function toGoalView(loop: LoopState, now: number): GoalView {
234
+ const goalId = loop.goalId ?? ''
235
+ const revisions = Array.isArray(loop.revisions) ? loop.revisions : []
236
+ const view: GoalView = {
237
+ goalId,
238
+ title: loop.title ?? goalId,
239
+ objective: loop.objective ?? '',
240
+ status: statusOf(loop),
241
+ depth: loop.depth === 'quick' ? 'quick' : 'full',
242
+ executor: loop.executor === 'persistent' ? 'persistent' : 'ephemeral',
243
+ ...(loop.memberName ? { memberName: loop.memberName } : {}),
244
+ round: Number.isFinite(loop.round) ? loop.round : 0,
245
+ ...(loop.subgoal ? { subgoal: loop.subgoal } : {}),
246
+ ...(loop.parentGoalId ? { parentGoalId: loop.parentGoalId } : {}),
247
+ ...(loop.supersededBy ? { supersededBy: loop.supersededBy } : {}),
248
+ revisions: revisions.map((r) => ({ version: r.version, reason: r.reason, at: r.at })),
249
+ updatedAt: 0,
250
+ }
251
+ const fingerprint = JSON.stringify({ ...view, updatedAt: 0 })
252
+ const stamp = goalStamps.get(loop)
253
+ const at = stamp !== undefined && stamp.fingerprint === fingerprint ? stamp.at : now
254
+ goalStamps.set(loop, { fingerprint, at })
255
+ return { ...view, updatedAt: at }
256
+ }
257
+
258
+ /**
259
+ * Tab order (user 2026-11-02): unfinished goals first, finished goals after;
260
+ * inside each segment the most recently changed goal leads. Total and stable:
261
+ * `goalId` breaks exact `updatedAt` ties so the wire value never flips between
262
+ * publishes that carry identical timestamps.
263
+ */
264
+ function compareGoals(a: GoalView, b: GoalView): number {
265
+ const groupA = COMPLETED_GOAL_STATUSES.has(a.status) ? 1 : 0
266
+ const groupB = COMPLETED_GOAL_STATUSES.has(b.status) ? 1 : 0
267
+ if (groupA !== groupB) return groupA - groupB
268
+ if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt
269
+ return a.goalId.localeCompare(b.goalId)
270
+ }
271
+
272
+ /**
273
+ * Snapshot one session's whole goal board as the projection's wire value.
274
+ * The array is pre-sorted (two segments, see `compareGoals`) so the client can
275
+ * render it verbatim without re-ordering.
276
+ * @param loops - every loop on the session's board.
277
+ * @param now - epoch milliseconds (injectable for tests).
278
+ */
279
+ export function goalsFromBoard(loops: Iterable<LoopState>, now = Date.now()): CyberneticsGoalsState {
280
+ const goals: GoalView[] = []
281
+ for (const loop of loops) {
282
+ if (loop.goalId === undefined) continue
283
+ goals.push(toGoalView(loop, now))
284
+ }
285
+ goals.sort(compareGoals)
286
+ return { goals }
287
+ }
288
+
289
+ /** Last successfully published JSON per session (idempotence guard). */
290
+ const published = new WeakMap<object, string>()
291
+
292
+ /** Record appender seam. */
293
+ export type RecordAppender = (session: unknown, type: string, data: unknown) => unknown
294
+
295
+ const MODULE_SPECIFIER = '@deepseek-ai/dsh-session'
296
+ /** Directory (inside a workspace root) that receives the record. */
297
+ export const DEBUG_DIR = '.cybernetics'
298
+ /** File name of the resolution/publish record. */
299
+ export const DEBUG_FILE = 'publish-debug.log'
300
+ /** Longest error excerpt kept per candidate in the publish record. */
301
+ export const MAX_DEBUG_ERROR_CHARS = 300
302
+
303
+ function errorText(error: unknown): string {
304
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error)
305
+ }
306
+
307
+ function truncate(text: string, max: number): string {
308
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`
309
+ }
310
+
311
+ /** Outcome of resolving the host's own copy of `@deepseek-ai/dsh-session`. */
312
+ export interface HostSessionResolution {
313
+ /** Entry script the host resolution started from (`process.argv[1]`). */
314
+ argv1: string | undefined
315
+ /** File this module's static import binds to (its own copy). */
316
+ ownPath: string | undefined
317
+ /** File the host's entry script resolves `@deepseek-ai/dsh-session` to. */
318
+ hostPath: string | undefined
319
+ /**
320
+ * True when the host copy is a DIFFERENT file from this module's static
321
+ * import. Diagnostic only: both dynamic candidates are loaded regardless
322
+ * (a same-file resolution never proved the Session brand matched).
323
+ */
324
+ needsHostImport: boolean
325
+ /** The base that actually resolved (differs from `argv1` for a launcher symlink). */
326
+ resolvedFrom?: string
327
+ /** Resolution failure text (diagnostics only, never payload data). */
328
+ error?: string
329
+ }
330
+
331
+ /**
332
+ * Resolve the host's copy of `@deepseek-ai/dsh-session` WITHOUT importing it.
333
+ *
334
+ * Why this is load-bearing: the plugin is loaded through the profile's
335
+ * `node_modules` (a symlink), while the running host keeps its Session class
336
+ * in the copy bundled with the `dsh` install. `appendPluginRecord` commits
337
+ * through a module-private `#appendRecord`, and a Session instance from the
338
+ * other copy cannot satisfy that brand — the call throws
339
+ * `TypeError: Receiver must be an instance of class Session`, no
340
+ * `plugin:cybernetics/goals` event lands, and the「目标」tab silently falls
341
+ * back to dsh-goal's single slot.
342
+ *
343
+ * Resolution starts from the host's entry script (`process.argv[1]`), which is
344
+ * how the host itself finds the module — but node keeps `argv[1]` as the
345
+ * INVOKED path, and `dsh` is normally started through a launcher symlink
346
+ * (`/opt/homebrew/bin/dsh -> .../dsh/lib/bin.js`). Resolving from the symlink's
347
+ * own directory cannot find the package, so the realpath is tried next. Pure:
348
+ * only `require.resolve` runs, so tests can drive it with a fake `argv1`.
349
+ * @param argv1 - entry script; defaults to `process.argv[1]`.
350
+ * @param selfUrl - this module's URL; defaults to `import.meta.url`.
351
+ */
352
+ export function resolveHostSessionCopy(
353
+ argv1: string | undefined = process.argv[1],
354
+ selfUrl: string = import.meta.url,
355
+ ): HostSessionResolution {
356
+ const entry = typeof argv1 === 'string' && argv1.length > 0 ? argv1 : undefined
357
+ const bases: string[] = []
358
+ if (entry === undefined) {
359
+ bases.push(selfUrl)
360
+ } else {
361
+ bases.push(entry)
362
+ try {
363
+ const real = realpathSync(entry)
364
+ if (real !== entry) bases.push(real)
365
+ } catch {
366
+ // Not a readable file: nothing to follow, the entry base stays alone.
367
+ }
368
+ }
369
+ let ownPath: string | undefined
370
+ let ownError: string | undefined
371
+ try {
372
+ // `require.resolve` returns the realpath, exactly like the ESM resolver
373
+ // that bound this module's static import — so the two are comparable.
374
+ ownPath = createRequire(selfUrl).resolve(MODULE_SPECIFIER)
375
+ } catch (cause) {
376
+ ownError = `own copy: ${errorText(cause)}`
377
+ }
378
+ let hostPath: string | undefined
379
+ let resolvedFrom: string | undefined
380
+ const failures: string[] = []
381
+ for (const base of bases) {
382
+ try {
383
+ hostPath = createRequire(base).resolve(MODULE_SPECIFIER)
384
+ resolvedFrom = base
385
+ break
386
+ } catch (cause) {
387
+ failures.push(`${base}: ${errorText(cause)}`)
388
+ }
389
+ }
390
+ const needsHostImport = hostPath !== undefined && hostPath !== ownPath
391
+ // Only a hard failure is an `error`: an unresolvable INVOKED path followed by
392
+ // a successful realpath (the launcher symlink) is the normal host shape.
393
+ const problems = [...(ownError !== undefined ? [ownError] : []), ...failures]
394
+ return {
395
+ argv1: entry,
396
+ ownPath,
397
+ hostPath,
398
+ needsHostImport,
399
+ ...(resolvedFrom !== undefined ? { resolvedFrom } : {}),
400
+ ...(hostPath === undefined && problems.length > 0 ? { error: problems.join('; ') } : {}),
401
+ }
402
+ }
403
+
404
+ /** Resolution performed once, at module init, from the host's entry script. */
405
+ const resolution = resolveHostSessionCopy()
406
+
407
+ /**
408
+ * Resolve the copy of `@deepseek-ai/dsh-session` that sits under THIS package's
409
+ * own `node_modules` (this repo checks that path in as a symlink to the
410
+ * profile's install). It is a separate candidate from the host entry's copy:
411
+ * the Session instance the plugin is handed may come from either install, and
412
+ * only the copy that DEFINED the instance's class can pass
413
+ * `appendPluginRecord`'s private-field brand check.
414
+ *
415
+ * Zero-dep and pure: existence checks only, no import. Walks up to the package
416
+ * root (the first ancestor with a `package.json`) and then looks for the
417
+ * conventional nested path there.
418
+ * @param selfUrl - this module's URL; defaults to `import.meta.url`.
419
+ */
420
+ export function resolvePluginSessionCopy(selfUrl: string = import.meta.url): string | undefined {
421
+ try {
422
+ let dir = dirname(fileURLToPath(selfUrl))
423
+ let packageRoot: string | undefined
424
+ for (let depth = 0; depth < 8; depth += 1) {
425
+ if (existsSync(join(dir, 'package.json'))) {
426
+ packageRoot = dir
427
+ break
428
+ }
429
+ const parent = dirname(dir)
430
+ if (parent === dir) break
431
+ dir = parent
432
+ }
433
+ if (packageRoot === undefined) return undefined
434
+ const candidate = join(packageRoot, 'node_modules', '@deepseek-ai', 'dsh-session', 'lib', 'index.js')
435
+ return existsSync(candidate) ? candidate : undefined
436
+ } catch {
437
+ // A virtual/foreign URL: no third candidate, the other two still try.
438
+ return undefined
439
+ }
440
+ }
441
+
442
+ /** One module copy loaded (or being loaded) for the appender candidates. */
443
+ interface DynamicCopy {
444
+ /** The callable once the import settled successfully. */
445
+ appender?: RecordAppender
446
+ /** Load failure text (diagnostics only, never payload data). */
447
+ error?: string
448
+ }
449
+
450
+ /** Loaded copies by realpath: one import per distinct file. */
451
+ const dynamicCopies = new Map<string, DynamicCopy>()
452
+ /** Dynamic imports still in flight. */
453
+ let dynamicPending = 0
454
+ /** True once every dynamic candidate settled (successfully or not). */
455
+ let hostSettled = false
456
+ /** Workspace root for the records; supplied by the caller. */
457
+ let debugRoot: string | undefined
458
+ /** Roots already carrying the one-shot resolution line. */
459
+ const debugWritten = new Set<string>()
460
+
461
+ /** Append one JSON line to `<workspace>/.cybernetics/publish-debug.log`. */
462
+ function appendDebugRecord(record: Record<string, unknown>): void {
463
+ const root = debugRoot
464
+ if (root === undefined) return
465
+ try {
466
+ mkdirSync(join(root, DEBUG_DIR), { recursive: true })
467
+ appendFileSync(join(root, DEBUG_DIR, DEBUG_FILE), `${JSON.stringify(record)}\n`)
468
+ } catch {
469
+ // A read-only workspace still publishes; the record is best-effort.
470
+ }
471
+ }
472
+
473
+ /** One appender candidate, tried in list order until one accepts the record. */
474
+ export interface AppenderCandidate {
475
+ /** 0-based position in the active list (part of the debug label). */
476
+ index: number
477
+ /** Where the appender comes from. */
478
+ kind: 'static' | 'host' | 'plugin' | 'test'
479
+ /** Path summary of the module behind it (a path, never payload data). */
480
+ path: string
481
+ /** The callable, or undefined while its dynamic import is still pending. */
482
+ get(): RecordAppender | undefined
483
+ }
484
+
485
+ /** Test-only shape: one appender with the label it reports. */
486
+ export interface AppenderCandidateInput {
487
+ kind?: 'static' | 'host' | 'plugin' | 'test'
488
+ path?: string
489
+ append: RecordAppender
490
+ }
491
+
492
+ function candidateLabel(candidate: AppenderCandidate): string {
493
+ return `#${candidate.index} ${candidate.kind} ${candidate.path}`
494
+ }
495
+
496
+ /**
497
+ * Start the dynamic import of one module copy.
498
+ *
499
+ * Deliberately ungated: BOTH dynamic candidates are always attempted. The old
500
+ * `needsHostImport === false → skip` short-circuit is exactly what left the
501
+ * host copy unloaded (`needsHostImport:false, hostAppendReady:false` in
502
+ * publish-debug.log) while the statically imported copy kept rejecting the
503
+ * host's Session with `TypeError: Receiver must be an instance of class
504
+ * Session`. Distinct files are deduplicated by realpath so one file is
505
+ * imported at most once.
506
+ */
507
+ function loadDynamicCopy(
508
+ path: string | undefined,
509
+ label: string,
510
+ ): { real: string; copy: DynamicCopy } | undefined {
511
+ if (typeof path !== 'string' || path.length === 0) return undefined
512
+ let real: string
513
+ try {
514
+ real = realpathSync(path)
515
+ } catch {
516
+ return undefined
517
+ }
518
+ const existing = dynamicCopies.get(real)
519
+ if (existing !== undefined) return { real, copy: existing }
520
+ const copy: DynamicCopy = {}
521
+ dynamicCopies.set(real, copy)
522
+ dynamicPending += 1
523
+ // Fire-and-forget: publishing must never wait on a module load; a candidate
524
+ // whose import is still pending is simply skipped for that publish.
525
+ import(pathToFileURL(real).href)
526
+ .then((loaded: { appendPluginRecord?: unknown }) => {
527
+ if (typeof loaded.appendPluginRecord === 'function') {
528
+ copy.appender = loaded.appendPluginRecord as RecordAppender
529
+ } else {
530
+ copy.error = `${label}: module exports no appendPluginRecord`
531
+ }
532
+ })
533
+ .catch((cause: unknown) => {
534
+ copy.error = `${label}: ${errorText(cause)}`
535
+ })
536
+ .finally(() => {
537
+ dynamicPending -= 1
538
+ settleDynamicImports()
539
+ })
540
+ return { real, copy }
541
+ }
542
+
543
+ function settleDynamicImports(): void {
544
+ if (hostSettled || dynamicPending > 0) return
545
+ hostSettled = true
546
+ flushPublishDebug()
547
+ }
548
+
549
+ /** Load errors across the dynamic candidates (diagnostics only). */
550
+ function dynamicLoadError(): string | undefined {
551
+ const errors: string[] = []
552
+ for (const copy of dynamicCopies.values()) {
553
+ if (typeof copy.error === 'string') errors.push(copy.error)
554
+ }
555
+ return errors.length > 0 ? errors.join('; ') : undefined
556
+ }
557
+
558
+ // Both dynamic candidates are loaded unconditionally (dedup is by realpath,
559
+ // inside `loadDynamicCopy`); the static import stays first in the list.
560
+ const pluginSessionCopyPath = resolvePluginSessionCopy()
561
+ const hostCopy = loadDynamicCopy(resolution.hostPath, 'host copy')
562
+ const pluginCopy = loadDynamicCopy(pluginSessionCopyPath, 'plugin copy')
563
+
564
+ function buildProductionCandidates(): AppenderCandidate[] {
565
+ const raw: Array<Pick<AppenderCandidate, 'kind' | 'path' | 'get'>> = [
566
+ {
567
+ kind: 'static',
568
+ path: resolution.ownPath ?? 'static import',
569
+ get: () => appendPluginRecord as unknown as RecordAppender,
570
+ },
571
+ ]
572
+ if (hostCopy !== undefined) {
573
+ raw.push({ kind: 'host', path: hostCopy.real, get: () => hostCopy.copy.appender })
574
+ }
575
+ if (pluginCopy !== undefined) {
576
+ raw.push({ kind: 'plugin', path: pluginCopy.real, get: () => pluginCopy.copy.appender })
577
+ }
578
+ return raw.map((candidate, index) => ({ ...candidate, index }))
579
+ }
580
+
581
+ const productionCandidates: AppenderCandidate[] = buildProductionCandidates()
582
+
583
+ // No dynamic candidate at all → the resolution record is ready immediately;
584
+ // otherwise it is flushed when the last in-flight import settles.
585
+ settleDynamicImports()
586
+
587
+ /** Test-only candidate override; `undefined` restores production. */
588
+ let testCandidates: AppenderCandidate[] | undefined
589
+
590
+ /**
591
+ * Test seam: replace the candidate sequence (pass undefined to restore the
592
+ * production list). Lets a test prove the fallback chain — first candidate
593
+ * rejects the Session brand, second accepts — without a second real install.
594
+ */
595
+ export function setAppenderCandidatesForTest(inputs: readonly AppenderCandidateInput[] | undefined): void {
596
+ if (inputs === undefined) {
597
+ testCandidates = undefined
598
+ return
599
+ }
600
+ testCandidates = inputs.map((input, index) => ({
601
+ index,
602
+ kind: input.kind ?? 'test',
603
+ path: input.path ?? input.kind ?? `candidate-${index}`,
604
+ get: () => input.append,
605
+ }))
606
+ }
607
+
608
+ function activeCandidates(): AppenderCandidate[] {
609
+ return testCandidates ?? productionCandidates
610
+ }
611
+
612
+ interface AttemptReport {
613
+ /** Debug label of the candidate that accepted the record (null on failure). */
614
+ candidate: string | null
615
+ /** One truncated error excerpt per rejected candidate. */
616
+ errors: string[]
617
+ }
618
+
619
+ /** Outcome of the last `defaultAppender` call (read by `publishGoals`). */
620
+ let lastAttempt: AttemptReport | undefined
621
+
622
+ /**
623
+ * Append the resolution/load outcome to
624
+ * `<workspace>/.cybernetics/publish-debug.log` (one JSON line, append mode).
625
+ * Written at most once per workspace root, as soon as both the root and the
626
+ * dynamic-candidate outcome are known — a multi-workspace host gets one line
627
+ * per workspace, each readable after a restart. Observability only: any
628
+ * failure is ignored.
629
+ */
630
+ function flushPublishDebug(): void {
631
+ const root = debugRoot
632
+ if (root === undefined || !hostSettled || debugWritten.has(root)) return
633
+ debugWritten.add(root)
634
+ appendDebugRecord({
635
+ at: new Date().toISOString(),
636
+ event: 'host-session',
637
+ specifier: MODULE_SPECIFIER,
638
+ argv1: resolution.argv1 ?? null,
639
+ ownPath: resolution.ownPath ?? null,
640
+ hostPath: resolution.hostPath ?? null,
641
+ resolvedFrom: resolution.resolvedFrom ?? null,
642
+ needsHostImport: resolution.needsHostImport,
643
+ hostAppendReady: hostCopy?.copy.appender !== undefined,
644
+ pluginPath: pluginSessionCopyPath ?? null,
645
+ pluginAppendReady: pluginCopy?.copy.appender !== undefined,
646
+ error: resolution.error ?? dynamicLoadError() ?? null,
647
+ })
648
+ }
649
+
650
+ /**
651
+ * Point the records at a workspace root (only the caller knows it — a session
652
+ * header's `cwd`, or dsh's working directory service). The resolution line is
653
+ * idempotent per root; publish lines are appended per publish.
654
+ */
655
+ export function setPublishDebugRoot(root: string | undefined): void {
656
+ if (typeof root !== 'string' || root.length === 0) return
657
+ debugRoot = root
658
+ flushPublishDebug()
659
+ }
660
+
661
+ /** Diagnostic view of the copy resolution / load state (no side effects). */
662
+ export function hostSessionStatus(): HostSessionResolution & {
663
+ hostAppendReady: boolean
664
+ pluginAppendReady: boolean
665
+ pluginPath?: string
666
+ loadError?: string
667
+ } {
668
+ const loadError = dynamicLoadError()
669
+ return {
670
+ ...resolution,
671
+ hostAppendReady: hostCopy?.copy.appender !== undefined,
672
+ pluginAppendReady: pluginCopy?.copy.appender !== undefined,
673
+ ...(pluginSessionCopyPath !== undefined ? { pluginPath: pluginSessionCopyPath } : {}),
674
+ ...(loadError !== undefined ? { loadError } : {}),
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Production appender: try every candidate in order, first success wins. The
680
+ * Session class comes from exactly one module copy, so the static import can
681
+ * reject the host's Session while the host copy accepts it (or vice versa).
682
+ * Only when every candidate rejects do we throw the LAST error — the publish
683
+ * contract (`failed`, round unaffected) is unchanged.
684
+ */
685
+ const defaultAppender: RecordAppender = (session, type, data) => {
686
+ const errors: string[] = []
687
+ let lastError: unknown
688
+ for (const candidate of activeCandidates()) {
689
+ const append = candidate.get()
690
+ if (append === undefined) continue
691
+ try {
692
+ const result = append(session, type, data)
693
+ lastAttempt = { candidate: candidateLabel(candidate), errors }
694
+ return result
695
+ } catch (cause) {
696
+ lastError = cause
697
+ errors.push(`${candidateLabel(candidate)}: ${truncate(errorText(cause), MAX_DEBUG_ERROR_CHARS)}`)
698
+ }
699
+ }
700
+ lastAttempt = { candidate: null, errors }
701
+ throw lastError ?? new Error('no appender candidate is available')
702
+ }
703
+ let recordAppender: RecordAppender = defaultAppender
704
+ /** Test seam: replace the plugin-record appender (pass undefined to restore). */
705
+ export function setRecordAppender(appender: RecordAppender | undefined): void {
706
+ recordAppender = appender ?? defaultAppender
707
+ }
708
+
709
+ /** Outcome of one publish attempt. */
710
+ export type PublishResult = 'published' | 'unchanged' | 'skipped' | 'failed'
711
+
712
+ /**
713
+ * Record the outcome of one publish. Paths/results only: the goal payload is
714
+ * never written to the log.
715
+ */
716
+ function appendPublishRecord(result: 'published' | 'failed', report: AttemptReport | undefined): void {
717
+ appendDebugRecord({
718
+ at: new Date().toISOString(),
719
+ event: 'publish',
720
+ result,
721
+ candidate: report?.candidate ?? null,
722
+ errors: report?.errors ?? [],
723
+ })
724
+ }
725
+
726
+ /**
727
+ * Append the whole goal board as one `cybernetics/goals` session event. The
728
+ * projection folds it into the client value; the host pushes the change to
729
+ * the tab. Publishing is observability, never workflow control: a missing or
730
+ * unhappy session degrades to a no-op instead of breaking a round.
731
+ *
732
+ * Idempotent by contract: republishing a byte-identical state appends
733
+ * nothing.
734
+ * @param session - the agent session whose tab shows the board (may be absent).
735
+ * @param state - the complete post-change board.
736
+ */
737
+ export function publishGoals(session: unknown, state: CyberneticsGoalsState): PublishResult {
738
+ const target = session as { append?: unknown; header?: { cwd?: unknown } } | null | undefined
739
+ if (target === null || target === undefined || typeof target !== 'object') return 'skipped'
740
+ // A real Session exposes `append`; anything else is not a log we may write.
741
+ if (typeof target.append !== 'function') return 'skipped'
742
+ // Best-effort workspace hint for the debug records: a real Session carries
743
+ // its cwd in the header, so the log lands in the right `.cybernetics/` even
744
+ // when the caller cannot pass the root itself.
745
+ const cwd = target.header?.cwd
746
+ if (typeof cwd === 'string') setPublishDebugRoot(cwd)
747
+ const json = JSON.stringify(state)
748
+ if (published.get(target) === json) return 'unchanged'
749
+ const usingCandidates = recordAppender === defaultAppender
750
+ try {
751
+ recordAppender(target, EVENT_TYPE, state)
752
+ } catch (cause) {
753
+ // A non-loggable payload (or a session mid-teardown) must not fail the
754
+ // round that produced it; the tab simply keeps the previous snapshot.
755
+ appendPublishRecord('failed', usingCandidates
756
+ ? lastAttempt
757
+ : { candidate: null, errors: [`injected: ${truncate(errorText(cause), MAX_DEBUG_ERROR_CHARS)}`] })
758
+ return 'failed'
759
+ }
760
+ published.set(target, json)
761
+ appendPublishRecord('published', usingCandidates ? lastAttempt : { candidate: 'injected', errors: [] })
762
+ return 'published'
763
+ }