@chorus-aidlc/chorus-openclaw-plugin 0.17.2 → 0.18.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.
@@ -71,6 +71,16 @@ interface NotificationDetail {
71
71
  * inject the same handback instruction the daemon does.
72
72
  */
73
73
  orchestrator?: { type: string; uuid: string; name: string } | null;
74
+ /**
75
+ * Derived, NON-persisted waker-session anchor (wake-carry-waker-session-anchor, T1).
76
+ * Present only for an agent-caused wake on an idea/task-anchored resource whose waking
77
+ * agent has a live, ONLINE-origin session for that idea — it tells the woken peer that
78
+ * replying on this resource reaches the waker's existing live session. A SIBLING of
79
+ * `orchestrator` (actor-scoped vs assignment-scoped): either, both, or neither may be
80
+ * present. Mirrors the daemon's cli/prompts.mjs `wakerSession` field so
81
+ * buildWakerSessionGuidance can inject the same advisory the daemon does.
82
+ */
83
+ wakerSession?: { agentUuid: string; agentName: string; ideaUuid: string } | null;
74
84
  }
75
85
 
76
86
  export class ChorusEventRouter {
@@ -255,11 +265,34 @@ export class ChorusEventRouter {
255
265
  }
256
266
 
257
267
  /**
258
- * Append orchestrator-handoff guidance (when the resource has an agent orchestrator)
259
- * to a wake message and dispatch it. Every handler routes its wake through this so the
260
- * handback instruction rides EVERY action parity with the daemon, which appends
261
- * orchestratorGuidance once in buildPrompt (cli/prompts.mjs). No orchestrator the
262
- * message is dispatched unchanged.
268
+ * Waker-session advisory for a wake — the OpenClaw twin of the daemon's
269
+ * wakerSessionGuidance in cli/prompts.mjs. KEEP THE TWO WORDINGS IN SYNC. Returns null
270
+ * unless the notification carries a `wakerSession` anchor (surfaced by the server only when
271
+ * the waking agent has a live, ONLINE-origin session for this resource's idea). It tells the
272
+ * woken peer that replying on this resource reaches the waker's existing live session — an
273
+ * ADVISORY only, not an enforced server route. A SIBLING of buildOrchestratorGuidance:
274
+ * independent, so either/both/neither may render on one wake.
275
+ */
276
+ private buildWakerSessionGuidance(n: NotificationDetail): string | null {
277
+ if (!n.wakerSession) return null;
278
+ const { agentName, agentUuid } = n.wakerSession;
279
+ return (
280
+ `@[${agentName}](agent:${agentUuid}) woke you and has a live session open on this ` +
281
+ `resource. If you reply by commenting on this same resource, your reply reaches that ` +
282
+ `agent's live session, keeping the exchange on one thread. This is advisory, not an ` +
283
+ `enforced server route — there is no automatic subscription and nothing is force-delivered; ` +
284
+ `replying here is simply where a reply lands via the normal return path. Prefer replying ` +
285
+ `on this resource over opening a new session.`
286
+ );
287
+ }
288
+
289
+ /**
290
+ * Append orchestrator-handoff guidance (when the resource has an agent orchestrator) and the
291
+ * waker-session advisory (when the wake carries an online waker anchor) to a wake message and
292
+ * dispatch it. Every handler routes its wake through this so both instructions ride EVERY
293
+ * action — parity with the daemon, which appends orchestratorGuidance + wakerSessionGuidance
294
+ * in buildPrompt (cli/prompts.mjs). The two blocks are independent: either, both, or neither
295
+ * may append; with neither the message is dispatched unchanged.
263
296
  */
264
297
  private wakeWithHandoff(
265
298
  message: string,
@@ -268,7 +301,11 @@ export class ChorusEventRouter {
268
301
  attr: WakeAttribution,
269
302
  ): void {
270
303
  const handoff = this.buildOrchestratorGuidance(n);
271
- this.wake(handoff ? `${message}\n\n${handoff}` : message, contextKey, attr);
304
+ const anchor = this.buildWakerSessionGuidance(n);
305
+ let msg = message;
306
+ if (handoff) msg += `\n\n${handoff}`;
307
+ if (anchor) msg += `\n\n${anchor}`;
308
+ this.wake(msg, contextKey, attr);
272
309
  }
273
310
 
274
311
  private handleTaskAssigned(n: NotificationDetail, attr: WakeAttribution): void {
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Spec-mode resolver for the Chorus OpenClaw plugin.
3
+ *
4
+ * This is the TypeScript reimplementation of the canonical bash resolver
5
+ * `public/chorus-plugin/bin/resolve-spec-mode.sh` (which the bash ports copy
6
+ * byte-identically; the TS ports reimplement + ship a same-contract test). It
7
+ * mirrors the chorus-pi port's `resolveSpecMode` in
8
+ * `packages/chorus-pi/lib/lib.ts` and is the **single source of truth** for the
9
+ * spec mode on OpenClaw.
10
+ *
11
+ * OpenClaw has no SessionStart hook and no per-session context-injection
12
+ * channel, so nothing precomputes the mode into the agent's context. Instead:
13
+ * - the `/chorus` command calls `resolveSpecModeFromEnv` so the resolved mode
14
+ * is a user-visible surface (its one real runtime caller), and
15
+ * - the stage skills (proposal / develop / yolo / openspec-aware) resolve the
16
+ * SAME contract inline, pointing back at this file as the authoritative rule.
17
+ *
18
+ * `resolveSpecMode` itself is pure given injectable fs + execSync, so it can be
19
+ * unit-tested without touching the disk or PATH.
20
+ */
21
+
22
+ import { existsSync } from "node:fs";
23
+ import { execSync } from "node:child_process";
24
+
25
+ /** Minimal fs surface needed by the resolver (injectable for tests). */
26
+ export interface FsLike {
27
+ existsSync(p: string): boolean;
28
+ }
29
+
30
+ /** Minimal execSync surface — used only to probe `command -v openspec`. */
31
+ export type ExecSync = (cmd: string, opts: { stdio: "ignore" }) => void;
32
+
33
+ /**
34
+ * The resolved spec mode surfaced to the agent. `openspec` = the OpenSpec
35
+ * (openspec-aware) path; `lite` = Chorus-native lightweight specs
36
+ * (`.chorus/specs/<slug>/`); `off` = free-form, no spec artifact.
37
+ */
38
+ export type SpecMode = "lite" | "openspec" | "off";
39
+
40
+ /**
41
+ * Inputs to the spec-mode resolver (env values + repo root). Mirrors the
42
+ * canonical bash resolver `public/chorus-plugin/bin/resolve-spec-mode.sh`.
43
+ */
44
+ export interface SpecModeInputs {
45
+ /** CHORUS_SPEC_MODE — explicit override: "lite" | "openspec" | "off" (else unset/""). */
46
+ specMode?: string;
47
+ /** CHORUS_OPENSPEC_MODE — legacy opt-out: "off" disables OpenSpec. */
48
+ openspecMode?: string;
49
+ /** CLAUDE_PLUGIN_OPTION_ENABLEOPENSPEC — plugin toggle: "false" disables OpenSpec (default "true"). */
50
+ enableOpenSpec?: string;
51
+ /** Repo root to probe for openspec/. */
52
+ projectRoot: string;
53
+ }
54
+
55
+ /**
56
+ * Resolved spec mode for a repo — the TS mirror of the bash resolver's output
57
+ * vars. `specFail` non-empty ⇒ a stage skill MUST halt (an explicit
58
+ * `CHORUS_SPEC_MODE=openspec` that cannot be honored); `chorusOpenspecActive`
59
+ * is true only when the resolved mode is a USABLE openspec.
60
+ */
61
+ export interface SpecModeResult {
62
+ specMode: SpecMode;
63
+ specReason: string;
64
+ specFail: string;
65
+ openspecUsable: boolean;
66
+ openspecUsableReason: string;
67
+ openspecHint: string;
68
+ chorusOpenspecActive: boolean;
69
+ }
70
+
71
+ /**
72
+ * Resolve the active Chorus spec mode for a repo. Pure given injectable fs +
73
+ * execSync.
74
+ *
75
+ * Rule (per owner): an explicit `CHORUS_SPEC_MODE` wins; when unset, OpenSpec
76
+ * stays the default whenever it is usable (openspec/ dir + CLI, not disabled),
77
+ * and lite is the fallback only when OpenSpec is absent or disabled. An explicit
78
+ * `=openspec` that isn't usable fails fast (`specFail`).
79
+ */
80
+ export function resolveSpecMode(
81
+ inputs: SpecModeInputs,
82
+ fs: FsLike,
83
+ execSync: ExecSync,
84
+ ): SpecModeResult {
85
+ const projectRoot = inputs.projectRoot || "";
86
+
87
+ // --- Is OpenSpec usable? (needs openspec/ dir + CLI on PATH + not disabled) ---
88
+ // enableOpenSpec toggle is checked BEFORE the legacy CHORUS_OPENSPEC_MODE, so a
89
+ // plugin-level opt-out wins the reason string (matches the bash resolver order).
90
+ let openspecDisabled = false;
91
+ let disabledReason = "";
92
+ if ((inputs.enableOpenSpec ?? "true") !== "true") {
93
+ openspecDisabled = true;
94
+ disabledReason = "enableOpenSpec userConfig=false (plugin-level opt-out)";
95
+ } else if (inputs.openspecMode === "off") {
96
+ openspecDisabled = true;
97
+ disabledReason = "CHORUS_OPENSPEC_MODE=off (legacy opt-out)";
98
+ }
99
+
100
+ let openspecUsable = false;
101
+ let openspecUsableReason = "";
102
+ let openspecHint = "";
103
+ if (openspecDisabled) {
104
+ openspecUsableReason = disabledReason;
105
+ } else if (!fs.existsSync(`${projectRoot}/openspec`)) {
106
+ openspecUsableReason = `no openspec/ directory at ${projectRoot}/openspec`;
107
+ openspecHint = "npm i -g @fission-ai/openspec && openspec init";
108
+ } else if (!openspecCliPresent(execSync)) {
109
+ openspecUsableReason = "openspec/ directory present but `openspec` CLI not on PATH";
110
+ openspecHint = "npm i -g @fission-ai/openspec";
111
+ } else {
112
+ openspecUsable = true;
113
+ openspecUsableReason = "openspec/ directory + openspec CLI both present";
114
+ }
115
+
116
+ // --- Resolve CHORUS_SPEC_MODE (unset and "" are treated the same, as in bash) ---
117
+ let specMode: SpecMode;
118
+ let specReason: string;
119
+ let specFail = "";
120
+ const raw = inputs.specMode ?? "";
121
+ switch (raw) {
122
+ case "lite":
123
+ specMode = "lite";
124
+ specReason = "explicit — Chorus-native lightweight specs in .chorus/specs/<slug>/";
125
+ break;
126
+ case "off":
127
+ specMode = "off";
128
+ specReason = "explicit — free-form, no spec artifact";
129
+ break;
130
+ case "openspec":
131
+ specMode = "openspec";
132
+ if (openspecUsable) {
133
+ specReason = `explicit; ${openspecUsableReason}`;
134
+ } else if (openspecDisabled) {
135
+ specReason = `explicit, but OpenSpec is disabled: ${openspecUsableReason}`;
136
+ specFail = `config conflict — CHORUS_SPEC_MODE=openspec vs OpenSpec disabled (${openspecUsableReason}); re-enable OpenSpec or set CHORUS_SPEC_MODE=lite`;
137
+ } else {
138
+ specReason = `explicit, but OpenSpec is not installed: ${openspecUsableReason}`;
139
+ specFail = `OpenSpec not usable (${openspecUsableReason})`;
140
+ }
141
+ break;
142
+ case "":
143
+ // Unset: OpenSpec is the default when usable; lite is the fallback otherwise.
144
+ if (openspecUsable) {
145
+ specMode = "openspec";
146
+ specReason = `default — ${openspecUsableReason}; set CHORUS_SPEC_MODE=lite for Chorus-native specs, =off to disable`;
147
+ } else {
148
+ specMode = "lite";
149
+ specReason = `default — OpenSpec not usable (${openspecUsableReason}); using Chorus-native lightweight specs in .chorus/specs/<slug>/`;
150
+ }
151
+ break;
152
+ default:
153
+ // Unrecognized value: treat like unset (OpenSpec-if-usable, else lite).
154
+ if (openspecUsable) {
155
+ specMode = "openspec";
156
+ specReason = `CHORUS_SPEC_MODE='${raw}' unrecognized; falling back to default (${openspecUsableReason})`;
157
+ } else {
158
+ specMode = "lite";
159
+ specReason = `CHORUS_SPEC_MODE='${raw}' unrecognized; OpenSpec not usable, defaulting to lite`;
160
+ }
161
+ }
162
+
163
+ const chorusOpenspecActive = specMode === "openspec" && specFail === "";
164
+ return {
165
+ specMode,
166
+ specReason,
167
+ specFail,
168
+ openspecUsable,
169
+ openspecUsableReason,
170
+ openspecHint,
171
+ chorusOpenspecActive,
172
+ };
173
+ }
174
+
175
+ function openspecCliPresent(execSync: ExecSync): boolean {
176
+ try {
177
+ execSync("command -v openspec", { stdio: "ignore" });
178
+ return true;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Convenience wrapper that wires the real Node `fs.existsSync` + `child_process.execSync`
186
+ * and reads the spec-mode env vars off a process-env-shaped bag. This is the
187
+ * `/chorus` command's real runtime caller. Kept out of `resolveSpecMode` so the
188
+ * core stays pure/injectable for tests.
189
+ */
190
+ export function resolveSpecModeFromEnv(
191
+ env: NodeJS.ProcessEnv,
192
+ projectRoot: string,
193
+ ): SpecModeResult {
194
+ const fs: FsLike = { existsSync: (p: string) => existsSync(p) };
195
+ const exec: ExecSync = (cmd, opts) => {
196
+ execSync(cmd, opts);
197
+ };
198
+ return resolveSpecMode(
199
+ {
200
+ specMode: env.CHORUS_SPEC_MODE,
201
+ openspecMode: env.CHORUS_OPENSPEC_MODE,
202
+ enableOpenSpec: env.CLAUDE_PLUGIN_OPTION_ENABLEOPENSPEC,
203
+ projectRoot,
204
+ },
205
+ fs,
206
+ exec,
207
+ );
208
+ }