@chorus-aidlc/chorus-openclaw-plugin 0.10.0 → 0.11.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.
Files changed (61) hide show
  1. package/dist/connection-state.d.ts +35 -0
  2. package/dist/connection-state.d.ts.map +1 -0
  3. package/dist/connection-state.js +52 -0
  4. package/dist/connection-state.js.map +1 -0
  5. package/dist/control-handler.d.ts +73 -0
  6. package/dist/control-handler.d.ts.map +1 -0
  7. package/dist/control-handler.js +135 -0
  8. package/dist/control-handler.js.map +1 -0
  9. package/dist/daemon-client.d.ts +203 -0
  10. package/dist/daemon-client.d.ts.map +1 -0
  11. package/dist/daemon-client.js +469 -0
  12. package/dist/daemon-client.js.map +1 -0
  13. package/dist/daemon-rest-client.d.ts +86 -0
  14. package/dist/daemon-rest-client.d.ts.map +1 -0
  15. package/dist/daemon-rest-client.js +196 -0
  16. package/dist/daemon-rest-client.js.map +1 -0
  17. package/dist/event-router.d.ts +31 -6
  18. package/dist/event-router.d.ts.map +1 -1
  19. package/dist/event-router.js +58 -27
  20. package/dist/event-router.js.map +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +106 -7
  23. package/dist/index.js.map +1 -1
  24. package/dist/lineage.d.ts +44 -0
  25. package/dist/lineage.d.ts.map +1 -0
  26. package/dist/lineage.js +116 -0
  27. package/dist/lineage.js.map +1 -0
  28. package/dist/mcp-registration.d.ts.map +1 -1
  29. package/dist/mcp-registration.js +5 -4
  30. package/dist/mcp-registration.js.map +1 -1
  31. package/dist/sse-listener.d.ts +34 -0
  32. package/dist/sse-listener.d.ts.map +1 -1
  33. package/dist/sse-listener.js +78 -4
  34. package/dist/sse-listener.js.map +1 -1
  35. package/dist/wake.d.ts +20 -0
  36. package/dist/wake.d.ts.map +1 -1
  37. package/dist/wake.js +56 -0
  38. package/dist/wake.js.map +1 -1
  39. package/package.json +1 -1
  40. package/skills/brainstorm/SKILL.md +1 -1
  41. package/skills/chorus/SKILL.md +36 -5
  42. package/skills/develop/SKILL.md +1 -1
  43. package/skills/idea/SKILL.md +1 -1
  44. package/skills/openspec-aware/SKILL.md +1 -1
  45. package/skills/proposal/SKILL.md +1 -1
  46. package/skills/proposal-reviewer/SKILL.md +1 -1
  47. package/skills/quick-dev/SKILL.md +1 -1
  48. package/skills/review/SKILL.md +1 -1
  49. package/skills/task-reviewer/SKILL.md +1 -1
  50. package/skills/yolo/SKILL.md +1 -1
  51. package/src/connection-state.ts +66 -0
  52. package/src/control-handler.ts +219 -0
  53. package/src/daemon-client.ts +622 -0
  54. package/src/daemon-rest-client.ts +312 -0
  55. package/src/event-router.ts +103 -33
  56. package/src/index.ts +113 -8
  57. package/src/lineage.ts +157 -0
  58. package/src/mcp-registration.ts +6 -19
  59. package/src/openclaw-sdk.d.ts +232 -1
  60. package/src/sse-listener.ts +117 -5
  61. package/src/wake.ts +69 -26
@@ -0,0 +1,312 @@
1
+ // packages/openclaw-plugin/src/daemon-rest-client.ts
2
+ // TypeScript mirror of the shared, host-agnostic pure-REST client for the Chorus
3
+ // daemon → server reporting surface (`/api/daemon/*`).
4
+ //
5
+ // WHY A MIRROR (not an import): the single-source-of-truth implementation is
6
+ // `cli/daemon-rest-client.mjs`, consumed verbatim by the chorus CLI daemon. The
7
+ // OpenClaw plugin, however, is a SEPARATELY-PUBLISHED npm package
8
+ // (`@chorus-aidlc/chorus-openclaw-plugin`, `files: ["src", "dist", ...]`) whose
9
+ // TS build has `rootDir: "src"`. Importing a file under `cli/` (outside both the
10
+ // package boundary AND rootDir) is rejected by `tsc` and would not be present in
11
+ // the published tarball. So we mirror the EXACT same factory + payload shapes here
12
+ // — this is NOT a fork of the wire contract: every payload below is byte-for-byte
13
+ // the shape `cli/daemon-rest-client.mjs` sends (and the server already accepts).
14
+ // The two files are kept in lock-step by the spec's "single source of truth for
15
+ // the payload shapes" requirement; a drift would be caught by T5 (live e2e).
16
+ //
17
+ // The five operations and their EXACT server payload shapes (verified against
18
+ // cli/daemon-rest-client.mjs + src/app/api/daemon/*/route.ts — server unchanged):
19
+ // turnAdvance → POST /api/daemon/turn-advance
20
+ // { connectionUuid, sessionId, status, entityType?, entityUuid? }
21
+ // transcript → POST /api/daemon/transcript
22
+ // { sessionId, messages: [{ role, text }] }
23
+ // executionState → POST /api/daemon/execution-state
24
+ // { connectionUuid, executions: [{ entityType, entityUuid,
25
+ // rootIdeaUuid|null, status,
26
+ // startedAt|null }] }
27
+ // reportInterrupt → POST /api/daemon/report-interrupt
28
+ // { connectionUuid, entityType, entityUuid, reason }
29
+ // readPendingTurns → GET /api/daemon/pending-turns?connectionUuid=…
30
+ // → { turns: [{ turnUuid, sessionId, directIdeaUuid, trigger,
31
+ // promptText }] }
32
+ //
33
+ // HARD CONSTRAINTS (identical to the CLI client):
34
+ // • ZERO daemon-host coupling — no child_process, no OpenClaw SDK import. Its only
35
+ // outbound effect is HTTP via the injected `fetchImpl` (global fetch on Node 18+).
36
+ // Adds NO new npm dependency (CLAUDE.md pitfall #9).
37
+ // • Bearer-only auth: every request carries `Authorization: Bearer <apiKey>`.
38
+ // • NO SILENT ERRORS (project policy): a network error, a non-2xx response, or a
39
+ // bad/empty body is LOGGED WITH ITS CAUSE and SURFACED via a structured result —
40
+ // never swallowed into a silent success.
41
+ // • A failed report NEVER rejects: every method RESOLVES with `{ ok: false, ... }`
42
+ // so a fire-and-forget caller can `await` it safely.
43
+
44
+ export interface DaemonRestLogger {
45
+ info: (msg: string) => void;
46
+ warn: (msg: string) => void;
47
+ error: (msg: string) => void;
48
+ }
49
+
50
+ const NOOP_LOGGER: DaemonRestLogger = { info() {}, warn() {}, error() {} };
51
+
52
+ /** A single transcript message — only `role` + visible `text` (no internals). */
53
+ export interface DaemonTranscriptMessage {
54
+ role: "user" | "assistant";
55
+ text: string;
56
+ }
57
+
58
+ /** One execution-snapshot row, in the server's exact `execution-state` shape. */
59
+ export interface DaemonExecutionRow {
60
+ entityType: string;
61
+ entityUuid: string;
62
+ rootIdeaUuid: string | null;
63
+ status: "running" | "queued";
64
+ startedAt: string | null;
65
+ }
66
+
67
+ /** One unstarted (pending) turn read back from the turn table. */
68
+ export interface DaemonPendingTurn {
69
+ turnUuid: string;
70
+ sessionId: string;
71
+ directIdeaUuid: string | null;
72
+ trigger: string;
73
+ promptText: string | null;
74
+ }
75
+
76
+ /**
77
+ * Structured result of every client call. Mirrors the CLI client's
78
+ * `DaemonRestResult`: `ok` is true only on a 2xx (and, for reads, a well-formed
79
+ * body); `error` carries the (also-logged) failure cause; `skipped` marks an
80
+ * intentional non-call (e.g. no connection uuid yet); `data` holds parsed read
81
+ * payloads.
82
+ */
83
+ export interface DaemonRestResult<TData = unknown> {
84
+ ok: boolean;
85
+ status: number | null;
86
+ error?: string;
87
+ skipped?: boolean;
88
+ data?: TData;
89
+ }
90
+
91
+ export interface CreateDaemonRestClientOptions {
92
+ /** Chorus base URL (a trailing slash is normalized away). */
93
+ url: string;
94
+ /** `cho_` agent API key for Bearer auth. */
95
+ apiKey: string;
96
+ /**
97
+ * The daemon's registered connection uuid (learned from the SSE handshake),
98
+ * read LAZILY on every call so construction order does not matter. The
99
+ * connection-scoped operations (turnAdvance, executionState, reportInterrupt,
100
+ * readPendingTurns) require it; a null value skips the call (logged where the
101
+ * skip is unexpected, silent where it is a normal early state).
102
+ */
103
+ getConnectionUuid?: () => string | null;
104
+ /** Injectable for tests (defaults to global fetch). */
105
+ fetchImpl?: typeof fetch;
106
+ logger?: DaemonRestLogger;
107
+ }
108
+
109
+ export interface DaemonRestClient {
110
+ turnAdvance(p: {
111
+ sessionId: string;
112
+ status: "running" | "ended";
113
+ entityType?: string | null;
114
+ entityUuid?: string | null;
115
+ }): Promise<DaemonRestResult>;
116
+ transcript(p: {
117
+ sessionId: string;
118
+ messages: DaemonTranscriptMessage[];
119
+ }): Promise<DaemonRestResult>;
120
+ executionState(p: { executions: DaemonExecutionRow[] }): Promise<DaemonRestResult>;
121
+ reportInterrupt(p: {
122
+ entityType: string;
123
+ entityUuid: string;
124
+ reason: "user" | "crash";
125
+ }): Promise<DaemonRestResult>;
126
+ readPendingTurns(): Promise<DaemonRestResult<{ turns: DaemonPendingTurn[] }>>;
127
+ }
128
+
129
+ /**
130
+ * Build the shared daemon REST client. Inputs are entirely host-agnostic, which is
131
+ * exactly why the same surface serves both daemon hosts.
132
+ */
133
+ export function createDaemonRestClient(opts: CreateDaemonRestClientOptions): DaemonRestClient {
134
+ const url = opts.url.replace(/\/$/, "");
135
+ const apiKey = opts.apiKey;
136
+ const getConnectionUuid = opts.getConnectionUuid ?? (() => null);
137
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
138
+ const logger = opts.logger ?? NOOP_LOGGER;
139
+
140
+ const jsonHeaders = {
141
+ Authorization: `Bearer ${apiKey}`,
142
+ "Content-Type": "application/json",
143
+ Accept: "application/json",
144
+ };
145
+
146
+ /**
147
+ * Issue one daemon report. Owns the transport + the no-silent-errors contract
148
+ * IDENTICAL across all four POST endpoints; only the `op` label and the path
149
+ * differ. Never throws — returns a structured {@link DaemonRestResult}.
150
+ */
151
+ async function post(
152
+ op: string,
153
+ path: string,
154
+ body: unknown,
155
+ successLog?: string,
156
+ context = "",
157
+ ): Promise<DaemonRestResult> {
158
+ let response: Response;
159
+ try {
160
+ response = await fetchImpl(`${url}${path}`, {
161
+ method: "POST",
162
+ headers: jsonHeaders,
163
+ body: JSON.stringify(body),
164
+ });
165
+ } catch (err) {
166
+ // Network-level failure (DNS, connection refused, abort, …). Surface WITH cause.
167
+ const error = `${op} request failed${context}: ${err}`;
168
+ logger.warn(`[Chorus] ${error}`);
169
+ return { ok: false, status: null, error };
170
+ }
171
+ if (!response.ok) {
172
+ // Non-2xx. Surface WITH the status so a 4xx/5xx is debuggable.
173
+ const error = `${op} returned ${response.status}${context}`;
174
+ logger.warn(`[Chorus] ${error}`);
175
+ return { ok: false, status: response.status, error };
176
+ }
177
+ if (successLog) logger.info(`[Chorus] ${successLog}`);
178
+ return { ok: true, status: response.status };
179
+ }
180
+
181
+ return {
182
+ /**
183
+ * POST /api/daemon/turn-advance — advance a wake's DaemonSessionTurn lifecycle.
184
+ * The server resolves the turn by the session BUSINESS KEY (`sessionId`); the
185
+ * optional `entityType`/`entityUuid` stamp the weak executionUuid link. Requires
186
+ * the connectionUuid.
187
+ */
188
+ async turnAdvance({ sessionId, status, entityType, entityUuid }) {
189
+ const connectionUuid = getConnectionUuid();
190
+ if (!connectionUuid) {
191
+ const error = `cannot advance turn for session ${sessionId} → ${status} — no connection uuid yet`;
192
+ logger.warn(`[Chorus] ${error}`);
193
+ return { ok: false, status: null, error, skipped: true };
194
+ }
195
+ const body = {
196
+ connectionUuid,
197
+ sessionId,
198
+ status,
199
+ // Only sent when BOTH are present, so the server never gets a partial linkage.
200
+ ...(entityType && entityUuid ? { entityType, entityUuid } : {}),
201
+ };
202
+ return post(
203
+ "turn-advance",
204
+ "/api/daemon/turn-advance",
205
+ body,
206
+ `advanced turn for session ${sessionId} → ${status}`,
207
+ );
208
+ },
209
+
210
+ /**
211
+ * POST /api/daemon/transcript — append finalized user/assistant text to the
212
+ * current turn, targeted by the session BUSINESS KEY. The caller owns the content
213
+ * filter (only `{ role, text }`) and any batching. No connectionUuid needed.
214
+ */
215
+ async transcript({ sessionId, messages }) {
216
+ return post(
217
+ "transcript upload",
218
+ "/api/daemon/transcript",
219
+ { sessionId, messages },
220
+ `transcript uploaded (${messages.length} msg) for session ${sessionId}`,
221
+ );
222
+ },
223
+
224
+ /**
225
+ * POST /api/daemon/execution-state — publish the connection's running/queued
226
+ * execution snapshot (caller supplies the already-built `executions` array).
227
+ * Requires the connectionUuid; a null uuid is a normal early state (silent skip).
228
+ */
229
+ async executionState({ executions }) {
230
+ const connectionUuid = getConnectionUuid();
231
+ if (!connectionUuid) {
232
+ return { ok: false, status: null, skipped: true };
233
+ }
234
+ return post(
235
+ "execution-state upload",
236
+ "/api/daemon/execution-state",
237
+ { connectionUuid, executions },
238
+ `execution-state uploaded (${executions.length} active)`,
239
+ );
240
+ },
241
+
242
+ /**
243
+ * POST /api/daemon/report-interrupt — record a wake's `interrupted` outcome
244
+ * (reason = "user" | "crash") on the execution row keyed by connection + entity.
245
+ */
246
+ async reportInterrupt({ entityType, entityUuid, reason }) {
247
+ const connectionUuid = getConnectionUuid();
248
+ if (!connectionUuid) {
249
+ const error = `cannot report interrupt for ${entityType}:${entityUuid} — no connection uuid yet`;
250
+ logger.warn(`[Chorus] ${error}`);
251
+ return { ok: false, status: null, error, skipped: true };
252
+ }
253
+ return post(
254
+ "report-interrupt",
255
+ "/api/daemon/report-interrupt",
256
+ { connectionUuid, entityType, entityUuid, reason },
257
+ `reported ${entityType}:${entityUuid} interrupted (reason=${reason})`,
258
+ ` for ${entityType}:${entityUuid}`,
259
+ );
260
+ },
261
+
262
+ /**
263
+ * GET /api/daemon/pending-turns?connectionUuid=… — read this connection's
264
+ * unstarted (pending) turns. Returns the parsed `{ turns: [...] }` data on
265
+ * success; a network error / non-2xx / bad body / missing array is logged with
266
+ * cause and surfaced as a failure result — never a silent empty success.
267
+ */
268
+ async readPendingTurns() {
269
+ const connectionUuid = getConnectionUuid();
270
+ if (!connectionUuid) {
271
+ return { ok: false, status: null, skipped: true };
272
+ }
273
+ const endpoint = `${url}/api/daemon/pending-turns?connectionUuid=${encodeURIComponent(connectionUuid)}`;
274
+ let response: Response;
275
+ try {
276
+ response = await fetchImpl(endpoint, {
277
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
278
+ });
279
+ } catch (err) {
280
+ const error = `pending-turns backfill request failed: ${err}`;
281
+ logger.warn(`[Chorus] ${error}`);
282
+ return { ok: false, status: null, error };
283
+ }
284
+ if (!response.ok) {
285
+ const error = `pending-turns backfill returned ${response.status}`;
286
+ logger.warn(`[Chorus] ${error}`);
287
+ return { ok: false, status: response.status, error };
288
+ }
289
+ let parsed: unknown;
290
+ try {
291
+ parsed = await response.json();
292
+ } catch (err) {
293
+ const error = `pending-turns backfill: bad JSON: ${err}`;
294
+ logger.warn(`[Chorus] ${error}`);
295
+ return { ok: false, status: response.status, error };
296
+ }
297
+ // API envelope: { success: true, data: { turns: [...] } }.
298
+ const data =
299
+ parsed && typeof parsed === "object"
300
+ ? (parsed as { data?: unknown }).data
301
+ : undefined;
302
+ const turns =
303
+ data && typeof data === "object" ? (data as { turns?: unknown }).turns : undefined;
304
+ if (!Array.isArray(turns)) {
305
+ const error = "pending-turns backfill: no turns array in response";
306
+ logger.warn(`[Chorus] ${error}`);
307
+ return { ok: false, status: response.status, error };
308
+ }
309
+ return { ok: true, status: response.status, data: { turns: turns as DaemonPendingTurn[] } };
310
+ },
311
+ };
312
+ }
@@ -1,21 +1,50 @@
1
1
  import type { ChorusMcpClient } from "./mcp-client.js";
2
2
  import type { SseNotificationEvent } from "./sse-listener.js";
3
+ import type { LineageResolver } from "./lineage.js";
4
+
5
+ /**
6
+ * Per-wake attribution the router resolves and threads to the wake. Carries the
7
+ * reportable resource (`entityType`/`entityUuid`) and the lineage ids (the two-id
8
+ * contract: `directIdeaUuid` = session anchor, `rootIdeaUuid` = snapshot attribution).
9
+ * All optional so a host wired WITHOUT the daemon client (no lineage) still wakes —
10
+ * the daemon-reporting fields are simply absent and the run is a plain wake.
11
+ */
12
+ export interface WakeAttribution {
13
+ entityType?: string | null;
14
+ entityUuid?: string | null;
15
+ directIdeaUuid?: string | null;
16
+ rootIdeaUuid?: string | null;
17
+ }
3
18
 
4
19
  /**
5
20
  * Wake callback injected by the entry. Runs an embedded agent turn on the main
6
- * agent's session with `message` as the prompt (see `wake.ts` createWake,
7
- * which calls `api.runtime.agent.runEmbeddedAgent`).
21
+ * agent's session with `message` as the prompt (see `wake.ts` / daemon-client.ts,
22
+ * which call `api.runtime.agent.runEmbeddedAgent`).
8
23
  *
9
24
  * `contextKey` identifies the originating Chorus action+entity (e.g.
10
- * `chorus:mentioned:<uuid>`); it is used for the run id / logging. The wake
11
- * resolves the main agent session + model and DROPS (logs + returns) when it
12
- * cannot run it never throws, so the SSE service stays alive.
25
+ * `chorus:mentioned:<uuid>`); it is used for the run id / logging. `attribution`
26
+ * (when present) lets the daemon client report turn-advance / execution-state /
27
+ * interrupt for the wake and anchor the session on the business key. The wake
28
+ * resolves the main agent session + model and DROPS (logs + returns) when it cannot
29
+ * run — it never throws, so the SSE service stays alive.
13
30
  */
14
- export type ChorusWakeFn = (message: string, contextKey: string) => void;
31
+ export type ChorusWakeFn = (
32
+ message: string,
33
+ contextKey: string,
34
+ attribution?: WakeAttribution,
35
+ ) => void;
15
36
 
16
37
  export interface ChorusEventRouterOptions {
17
38
  mcpClient: ChorusMcpClient;
18
39
  wake: ChorusWakeFn;
40
+ /**
41
+ * Optional lineage resolver (daemon parity). When present, the router resolves each
42
+ * notification's `{ rootIdeaUuid, directIdeaUuid }` via the root-idea REST endpoint
43
+ * before waking, so the daemon client can anchor the session on the direct idea and
44
+ * report the root idea in its execution snapshot. When absent (a host with no daemon
45
+ * reporting), wakes carry only the entity fields the notification already provides.
46
+ */
47
+ lineage?: LineageResolver;
19
48
  logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
20
49
  }
21
50
 
@@ -39,11 +68,13 @@ interface NotificationDetail {
39
68
  export class ChorusEventRouter {
40
69
  private readonly mcpClient: ChorusMcpClient;
41
70
  private readonly wake: ChorusWakeFn;
71
+ private readonly lineage?: LineageResolver;
42
72
  private readonly logger: ChorusEventRouterOptions["logger"];
43
73
 
44
74
  constructor(opts: ChorusEventRouterOptions) {
45
75
  this.mcpClient = opts.mcpClient;
46
76
  this.wake = opts.wake;
77
+ this.lineage = opts.lineage;
47
78
  this.logger = opts.logger;
48
79
  }
49
80
 
@@ -52,6 +83,16 @@ export class ChorusEventRouter {
52
83
  * Never throws — all errors are caught and logged internally.
53
84
  */
54
85
  dispatch(event: SseNotificationEvent): void {
86
+ // The bidirectional daemon events `connection_registered` and `control` are
87
+ // forked upstream by the SSE listener (onConnectionId / onControl) and never
88
+ // reach the wake path. If one ever does arrive here (defense-in-depth), drop
89
+ // it SILENTLY — it is a known, handled-elsewhere event, NOT an unhandled one,
90
+ // so logging it as "ignored" would be misleading noise (and the spec requires
91
+ // connection_registered never be logged as ignored).
92
+ if (event.type === "connection_registered" || event.type === "control") {
93
+ return;
94
+ }
95
+
55
96
  // Only handle new_notification events (ignore count_update, etc.)
56
97
  if (event.type !== "new_notification") {
57
98
  this.logger.info(`SSE event type "${event.type}" ignored`);
@@ -103,35 +144,55 @@ export class ChorusEventRouter {
103
144
  return;
104
145
  }
105
146
 
147
+ // Resolve the wake attribution ONCE per notification (daemon parity). The lineage
148
+ // resolver returns { rootIdeaUuid, directIdeaUuid } via the root-idea REST endpoint;
149
+ // both null when there's no idea ancestor or no resolver is wired. The entity
150
+ // fields always come straight off the notification. This threads through to the
151
+ // daemon client so it reports/anchors against the right session + resource.
152
+ let attribution: WakeAttribution = {
153
+ entityType: notification.entityType,
154
+ entityUuid: notification.entityUuid,
155
+ };
156
+ if (this.lineage) {
157
+ try {
158
+ const { rootIdeaUuid, directIdeaUuid } = await this.lineage.resolve(notification);
159
+ attribution = { ...attribution, rootIdeaUuid, directIdeaUuid };
160
+ } catch (err) {
161
+ // A lineage failure must not lose the wake — fall back to the entity-only
162
+ // attribution (the daemon client then anchors on the entity uuid). Logged.
163
+ this.logger.warn(`Lineage resolve failed for ${notification.entityType}:${notification.entityUuid}: ${err}`);
164
+ }
165
+ }
166
+
106
167
  // Route based on action (which corresponds to notificationType)
107
168
  try {
108
169
  switch (notification.action) {
109
170
  case "task_assigned":
110
- this.handleTaskAssigned(notification);
171
+ this.handleTaskAssigned(notification, attribution);
111
172
  break;
112
173
  case "mentioned":
113
- this.handleMentioned(notification);
174
+ this.handleMentioned(notification, attribution);
114
175
  break;
115
176
  case "elaboration_requested":
116
- this.handleElaborationRequested(notification);
177
+ this.handleElaborationRequested(notification, attribution);
117
178
  break;
118
179
  case "elaboration_answered":
119
- this.handleElaborationAnswered(notification);
180
+ this.handleElaborationAnswered(notification, attribution);
120
181
  break;
121
182
  case "proposal_rejected":
122
- this.handleProposalRejected(notification);
183
+ this.handleProposalRejected(notification, attribution);
123
184
  break;
124
185
  case "proposal_approved":
125
- this.handleProposalApproved(notification);
186
+ this.handleProposalApproved(notification, attribution);
126
187
  break;
127
188
  case "idea_claimed":
128
- this.handleIdeaClaimed(notification);
189
+ this.handleIdeaClaimed(notification, attribution);
129
190
  break;
130
191
  case "task_verified":
131
- this.handleTaskVerified(notification);
192
+ this.handleTaskVerified(notification, attribution);
132
193
  break;
133
194
  case "task_reopened":
134
- this.handleTaskReopened(notification);
195
+ this.handleTaskReopened(notification, attribution);
135
196
  break;
136
197
  default:
137
198
  this.logger.info(`Unhandled notification action: "${notification.action}"`);
@@ -153,34 +214,37 @@ export class ChorusEventRouter {
153
214
  );
154
215
  }
155
216
 
156
- private handleTaskAssigned(n: NotificationDetail): void {
217
+ private handleTaskAssigned(n: NotificationDetail, attr: WakeAttribution): void {
157
218
  const mentionGuidance = this.buildMentionGuidance(n, "task");
158
219
 
159
220
  this.wake(
160
221
  `[Chorus] Task assigned: ${n.entityTitle}. Task UUID: ${n.entityUuid}, Project UUID: ${n.projectUuid}. Use chorus_get_task to review the task, then chorus_claim_task to start work.\n${mentionGuidance}`,
161
- this.contextKeyFor("task_assigned", n.entityUuid)
222
+ this.contextKeyFor("task_assigned", n.entityUuid),
223
+ attr
162
224
  );
163
225
  }
164
226
 
165
- private handleMentioned(n: NotificationDetail): void {
227
+ private handleMentioned(n: NotificationDetail, attr: WakeAttribution): void {
166
228
  const mentionGuidance = this.buildMentionGuidance(n, n.entityType);
167
229
 
168
230
  this.wake(
169
231
  `[Chorus] You were @mentioned in ${n.entityType} '${n.entityTitle}' (entityType: ${n.entityType}, entityUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}): ${n.message}\n` +
170
232
  `Review the ${n.entityType} content and use chorus_get_comments (targetType: "${n.entityType}", targetUuid: "${n.entityUuid}") to see the full conversation, then respond.\n` +
171
233
  mentionGuidance,
172
- this.contextKeyFor("mentioned", n.entityUuid)
234
+ this.contextKeyFor("mentioned", n.entityUuid),
235
+ attr
173
236
  );
174
237
  }
175
238
 
176
- private handleElaborationRequested(n: NotificationDetail): void {
239
+ private handleElaborationRequested(n: NotificationDetail, attr: WakeAttribution): void {
177
240
  this.wake(
178
241
  `[Chorus] Elaboration requested for idea '${n.entityTitle}' (ideaUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). Use chorus_get_elaboration to review questions.`,
179
- this.contextKeyFor("elaboration_requested", n.entityUuid)
242
+ this.contextKeyFor("elaboration_requested", n.entityUuid),
243
+ attr
180
244
  );
181
245
  }
182
246
 
183
- private handleProposalRejected(n: NotificationDetail): void {
247
+ private handleProposalRejected(n: NotificationDetail, attr: WakeAttribution): void {
184
248
  const mentionGuidance = this.buildMentionGuidance(n, "proposal");
185
249
 
186
250
  this.wake(
@@ -188,11 +252,12 @@ export class ChorusEventRouter {
188
252
  `Use chorus_get_proposal to review the proposal, then fix issues with chorus_update_task_draft / chorus_update_document_draft. ` +
189
253
  `After fixing, call chorus_validate_proposal then chorus_submit_proposal to resubmit.\n` +
190
254
  mentionGuidance,
191
- this.contextKeyFor("proposal_rejected", n.entityUuid)
255
+ this.contextKeyFor("proposal_rejected", n.entityUuid),
256
+ attr
192
257
  );
193
258
  }
194
259
 
195
- private handleProposalApproved(n: NotificationDetail): void {
260
+ private handleProposalApproved(n: NotificationDetail, attr: WakeAttribution): void {
196
261
  const mentionGuidance = this.buildMentionGuidance(n, "proposal");
197
262
 
198
263
  const reviewInfo = n.message.includes("Note: ") ? ` Review note: "${n.message.split("Note: ").pop()}"` : "";
@@ -200,40 +265,44 @@ export class ChorusEventRouter {
200
265
  `[Chorus] Proposal '${n.entityTitle}' was APPROVED (proposalUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid})!${reviewInfo} Documents and tasks have been created. ` +
201
266
  `Use chorus_get_available_tasks with projectUuid: "${n.projectUuid}" to see the new tasks ready for work.\n` +
202
267
  mentionGuidance,
203
- this.contextKeyFor("proposal_approved", n.entityUuid)
268
+ this.contextKeyFor("proposal_approved", n.entityUuid),
269
+ attr
204
270
  );
205
271
  }
206
272
 
207
- private handleIdeaClaimed(n: NotificationDetail): void {
273
+ private handleIdeaClaimed(n: NotificationDetail, attr: WakeAttribution): void {
208
274
  const mentionGuidance = this.buildMentionGuidance(n, "idea");
209
275
 
210
276
  this.wake(
211
277
  `[Chorus] Idea '${n.entityTitle}' has been assigned to you (ideaUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). ` +
212
278
  `Use chorus_get_idea to review the idea, then chorus_claim_idea to start elaboration.\n` +
213
279
  mentionGuidance,
214
- this.contextKeyFor("idea_claimed", n.entityUuid)
280
+ this.contextKeyFor("idea_claimed", n.entityUuid),
281
+ attr
215
282
  );
216
283
  }
217
284
 
218
- private handleTaskVerified(n: NotificationDetail): void {
285
+ private handleTaskVerified(n: NotificationDetail, attr: WakeAttribution): void {
219
286
  this.wake(
220
287
  `[Chorus] Task '${n.entityTitle}' has been verified and is now done (taskUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). ` +
221
288
  `Check if this unblocks other tasks: use chorus_get_unblocked_tasks with projectUuid "${n.projectUuid}" to find tasks that are now ready to start.`,
222
- this.contextKeyFor("task_verified", n.entityUuid)
289
+ this.contextKeyFor("task_verified", n.entityUuid),
290
+ attr
223
291
  );
224
292
  }
225
293
 
226
- private handleTaskReopened(n: NotificationDetail): void {
294
+ private handleTaskReopened(n: NotificationDetail, attr: WakeAttribution): void {
227
295
  const mentionGuidance = this.buildMentionGuidance(n, "task");
228
296
 
229
297
  this.wake(
230
298
  `[Chorus] Task '${n.entityTitle}' has been reopened and needs rework (taskUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). ` +
231
299
  `Use chorus_get_task to review the task and chorus_get_comments to see verification feedback, then fix the issues.\n${mentionGuidance}`,
232
- this.contextKeyFor("task_reopened", n.entityUuid)
300
+ this.contextKeyFor("task_reopened", n.entityUuid),
301
+ attr
233
302
  );
234
303
  }
235
304
 
236
- private handleElaborationAnswered(n: NotificationDetail): void {
305
+ private handleElaborationAnswered(n: NotificationDetail, attr: WakeAttribution): void {
237
306
  const mentionGuidance = this.buildMentionGuidance(n, "idea");
238
307
 
239
308
  this.wake(
@@ -243,7 +312,8 @@ export class ChorusEventRouter {
243
312
  `- Call chorus_validate_elaboration with issues + followUpQuestions for another round\n\n` +
244
313
  `After reviewing, @mention the answerer to ask if they have any further questions before you proceed.\n` +
245
314
  mentionGuidance,
246
- this.contextKeyFor("elaboration_answered", n.entityUuid)
315
+ this.contextKeyFor("elaboration_answered", n.entityUuid),
316
+ attr
247
317
  );
248
318
  }
249
319
  }