@chorus-aidlc/chorus-pi 0.0.1

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/lib.ts ADDED
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Pure helpers extracted from the chorus-pi extension for unit testing.
3
+ *
4
+ * These functions hold no mutable state and (except for detectOpenSpec, which
5
+ * takes injectable fs/execSync) have no I/O — so they can be tested without a
6
+ * running Pi session or a live Chorus instance. The extension imports them
7
+ * from here; tests import the same functions.
8
+ */
9
+
10
+ import { dirname, join } from "node:path";
11
+
12
+ /**
13
+ * Minimal fs surface needed by the config readers below.
14
+ * (detectOpenSpec already uses FsLike; keep this as the shared type.)
15
+ */
16
+ export interface FsLike {
17
+ existsSync(p: string): boolean;
18
+ }
19
+
20
+ /**
21
+ * Read raw file contents (utf-8). Injected so tests can stub the disk.
22
+ */
23
+ export type ReadFileLike = (p: string) => string;
24
+
25
+ export interface ChorusConnection {
26
+ url: string;
27
+ apiKey: string;
28
+ }
29
+
30
+ /**
31
+ * Parse the chorus server entry out of a standard .mcp.json shape:
32
+ * { "mcpServers": { "chorus": { "url": "…/api/mcp",
33
+ * "headers": { "Authorization": "Bearer cho_…" } } } }
34
+ *
35
+ * Returns { url, apiKey } with apiKey extracted from the Authorization header
36
+ * (accepts both "Bearer cho_…" and a bare "cho_…"). Empty strings if absent.
37
+ * Pure given the raw file text — no fs dependency — so it is unit-testable.
38
+ */
39
+ export function parseChorusServerFromMcpJson(rawJson: string): ChorusConnection {
40
+ if (!rawJson) return { url: "", apiKey: "" };
41
+ let obj: any;
42
+ try {
43
+ obj = JSON.parse(rawJson);
44
+ } catch {
45
+ return { url: "", apiKey: "" };
46
+ }
47
+ const srv = obj?.mcpServers?.chorus;
48
+ if (!srv || typeof srv !== "object") return { url: "", apiKey: "" };
49
+ const url = typeof srv.url === "string" ? srv.url : "";
50
+ let apiKey = "";
51
+ const auth = srv?.headers?.Authorization;
52
+ if (typeof auth === "string") {
53
+ if (auth.startsWith("Bearer ")) apiKey = auth.slice("Bearer ".length);
54
+ else if (auth.startsWith("cho_")) apiKey = auth;
55
+ }
56
+ return { url, apiKey };
57
+ }
58
+
59
+ /**
60
+ * Resolve the Chorus connection (url + apiKey) from the standard .mcp.json
61
+ * auto-discovered by pi-mcp-adapter. Searches candidate paths in order
62
+ * (project-root .mcp.json, then ~/.pi/agent/mcp.json), and returns the first
63
+ * COMPLETE chorus server entry (both url AND apiKey present). A partial entry
64
+ * (e.g. only url, no Authorization) is skipped so a complete global candidate
65
+ * is still reached — a partial project config must NOT shadow a complete
66
+ * ~/.pi/agent/mcp.json. Returns { "", "" } if no candidate is complete.
67
+ *
68
+ * Used as a fallback when CHORUS_URL / CHORUS_API_KEY env vars are unset,
69
+ * so a single .mcp.json config source covers both the MCP gateway (literal
70
+ * URL+Bearer) and the extension's own checkin + the OpenSpec wrapper.
71
+ */
72
+ export function resolveChorusConfigFromMcpJson(
73
+ candidatePaths: string[],
74
+ fs: FsLike,
75
+ readFile: ReadFileLike,
76
+ ): ChorusConnection {
77
+ for (const p of candidatePaths) {
78
+ if (!fs.existsSync(p)) continue;
79
+ const { url, apiKey } = parseChorusServerFromMcpJson(readFile(p));
80
+ if (url && apiKey) return { url, apiKey };
81
+ }
82
+ return { url: "", apiKey: "" };
83
+ }
84
+
85
+ export type ExecSync = (cmd: string, opts: { stdio: "ignore" }) => void;
86
+
87
+ /**
88
+ * Matches the three Chorus reviewer agent names. These do NOT get a Chorus
89
+ * session — they are read-only and post a single VERDICT comment.
90
+ */
91
+ export function isReviewerAgent(name: string): boolean {
92
+ return /^(chorus-proposal|chorus-task|chorus-code)-reviewer$/.test(name);
93
+ }
94
+
95
+ /**
96
+ * Positive classification of an agent that should get an auto-managed Chorus
97
+ * session + task-lifecycle (checkin/update/report/checkout) injection.
98
+ *
99
+ * Only canonical worker agent names get a session. The three Chorus reviewers
100
+ * are read-only (handled by isReviewerAgent) and the official subagent example's
101
+ * read-only agents `scout` / `planner` / `reviewer` are NOT workers — injecting
102
+ * the session workflow into them adds irrelevant task-lifecycle instructions and
103
+ * unnecessary chorus_create_session API traffic for agents that never touch a task.
104
+ *
105
+ * This is a positive allowlist (not a reviewer exclusion) so arbitrary custom
106
+ * read-only agents also do NOT get a session. Add more worker names here if the
107
+ * project introduces them.
108
+ */
109
+ export const WORKER_AGENT_NAMES = ["worker"] as const;
110
+ export function isWorkerAgent(name: string): boolean {
111
+ return (WORKER_AGENT_NAMES as readonly string[]).includes(name);
112
+ }
113
+ /**
114
+ * Enumerate the (agent, task) items in an official `subagent` tool call's input,
115
+ * across its three modes:
116
+ * - single: { agent, task }
117
+ * - parallel: { tasks: [{ agent, task }, ...] }
118
+ * - chain: { chain: [{ agent, task }, ...] }
119
+ *
120
+ * Each returned holder carries the agent name, the current task text, and a
121
+ * `setTask` that writes back into the SAME input object in place — so the
122
+ * extension can inject the Chorus session workflow into a worker's task before
123
+ * the ephemeral child `pi` process is spawned (pi's `tool_call` event input is
124
+ * mutable). Holders with a non-string / empty agent or task are skipped.
125
+ *
126
+ * Replaces the old persistent-model agentId extraction: the official subagent
127
+ * children are ephemeral (spawn → run → exit within one tool call) and expose
128
+ * no `sa_<uuid>` agentId to map, so there is nothing to parse out of a result.
129
+ */
130
+ export interface SubagentTaskItem {
131
+ agent: string;
132
+ task: string;
133
+ setTask: (task: string) => void;
134
+ }
135
+
136
+ export function subagentTaskItems(input: unknown): SubagentTaskItem[] {
137
+ if (!input || typeof input !== "object") return [];
138
+ const obj = input as Record<string, unknown>;
139
+ const items: SubagentTaskItem[] = [];
140
+ const collect = (holder: Record<string, unknown>): void => {
141
+ const agent = typeof holder.agent === "string" ? holder.agent : "";
142
+ const task = typeof holder.task === "string" ? holder.task : "";
143
+ if (!agent || !task) return;
144
+ items.push({
145
+ agent,
146
+ task,
147
+ setTask: (t) => {
148
+ holder.task = t;
149
+ },
150
+ });
151
+ };
152
+ if (Array.isArray(obj.tasks)) {
153
+ for (const t of obj.tasks) if (t && typeof t === "object") collect(t as Record<string, unknown>);
154
+ } else if (Array.isArray(obj.chain)) {
155
+ for (const c of obj.chain) if (c && typeof c === "object") collect(c as Record<string, unknown>);
156
+ } else {
157
+ collect(obj);
158
+ }
159
+ return items;
160
+ }
161
+
162
+ /**
163
+ * Build the session-workflow suffix injected into a spawned worker's task
164
+ * (via the tool_call event's mutable input). The subprocess receives this
165
+ * appended to its task prompt and reads the `Session UUID` from it.
166
+ */
167
+ export function sessionWorkflow(sessionUuid: string): string {
168
+ const s = sessionUuid;
169
+ return [
170
+ "",
171
+ "--- Chorus session (auto-injected by the chorus-pi extension) ---",
172
+ `Session UUID: ${s}`,
173
+ "For each Chorus task you work on:",
174
+ ` 1. chorus_session_checkin_task({ sessionUuid: "${s}", taskUuid: <task-uuid> })`,
175
+ ` 2. chorus_update_task({ taskUuid: <task-uuid>, status: "in_progress", sessionUuid: "${s}" })`,
176
+ " 3. ...do the work, commit...",
177
+ ` 4. chorus_report_work({ taskUuid: <task-uuid>, report: \"...\", sessionUuid: "${s}" })`,
178
+ ` 5. chorus_session_checkout_task({ sessionUuid: "${s}", taskUuid: <task-uuid> })`,
179
+ "Do NOT call chorus_create_session or chorus_close_session — the extension owns the lifecycle.",
180
+ ].join("\n");
181
+ }
182
+
183
+ /**
184
+ * Resolved OpenSpec mode for a repo. `active` is the effective on/off; `reason`
185
+ * is a human-readable explanation; `optout` marks an explicit opt-out (so the
186
+ * banner does not nag); `hint` is an optional install hint when the directory
187
+ * exists but the CLI is missing.
188
+ */
189
+ export interface OpenSpecState {
190
+ active: boolean;
191
+ reason: string;
192
+ optout: boolean;
193
+ hint: string;
194
+ }
195
+
196
+ /**
197
+ * Detect OpenSpec mode for a repo. Active only when all three hold:
198
+ * (1) not explicitly opted out (CHORUS_OPENSPEC_MODE != "off")
199
+ * (2) an openspec/ directory exists at the project root
200
+ * (3) the `openspec` CLI is on PATH
201
+ *
202
+ * fs and execSync are injected so tests can stub the filesystem and the CLI
203
+ * presence check without touching the real environment.
204
+ */
205
+ export function detectOpenSpec(
206
+ cwd: string,
207
+ optout: boolean,
208
+ fs: FsLike,
209
+ execSync: ExecSync,
210
+ ): OpenSpecState {
211
+ if (optout) {
212
+ return { active: false, reason: "CHORUS_OPENSPEC_MODE=off (explicit opt-out)", optout: true, hint: "" };
213
+ }
214
+ const openspecDir = `${cwd}/openspec`;
215
+ if (!fs.existsSync(openspecDir)) {
216
+ return { active: false, reason: `no openspec/ directory at ${openspecDir}`, optout: false, hint: "" };
217
+ }
218
+ let cliPresent = false;
219
+ try {
220
+ execSync("command -v openspec", { stdio: "ignore" });
221
+ cliPresent = true;
222
+ } catch {
223
+ cliPresent = false;
224
+ }
225
+ if (!cliPresent) {
226
+ return {
227
+ active: false,
228
+ reason: "openspec/ directory present but `openspec` CLI not on PATH",
229
+ optout: false,
230
+ hint: "install with: npm i -g @fission-ai/openspec",
231
+ };
232
+ }
233
+ return { active: true, reason: "openspec/ directory + openspec CLI both present", optout: false, hint: "" };
234
+ }
235
+
236
+ /**
237
+ * Build the user-visible one-line startup banner (the Pi equivalent of the
238
+ * Claude plugin's SessionStart `systemMessage` / Codex `$chorus` toast).
239
+ *
240
+ * Mirrors the three OpenSpec states from upstream (#442):
241
+ * - active -> "(OpenSpec Enabled)"
242
+ * - explicit opt-out -> "(OpenSpec off)" [neutral, no nag]
243
+ * - not set up -> "(OpenSpec off — run /skill:chorus enable openspec to set it up)"
244
+ *
245
+ * Plus two non-OpenSpec states:
246
+ * - not configured -> warning that CHORUS_URL / CHORUS_API_KEY are missing
247
+ * - connection failed -> error that the checkin couldn't reach Chorus
248
+ *
249
+ * Pure (no I/O) so it can be unit-tested without a running Pi session.
250
+ */
251
+ export interface SessionBanner {
252
+ message: string;
253
+ level: "info" | "warning" | "error";
254
+ }
255
+
256
+ export function buildSessionBanner(args: {
257
+ configured: boolean;
258
+ connected: boolean;
259
+ chorusUrl: string;
260
+ openspec: OpenSpecState;
261
+ }): SessionBanner {
262
+ // Not configured at all — env vars missing. Warn once so the user knows
263
+ // the plugin loaded but is inert (Claude's hook emits the same warning).
264
+ if (!args.configured) {
265
+ return {
266
+ message: "Chorus plugin: not configured (set CHORUS_URL and CHORUS_API_KEY)",
267
+ level: "warning",
268
+ };
269
+ }
270
+
271
+ // Configured but checkin failed — the session runs but hooks are dead.
272
+ if (!args.connected) {
273
+ return {
274
+ message: `Chorus: connection failed (${args.chorusUrl})`,
275
+ level: "error",
276
+ };
277
+ }
278
+
279
+ // Connected. Append the OpenSpec status suffix.
280
+ let suffix: string;
281
+ if (args.openspec.active) {
282
+ suffix = "(OpenSpec Enabled)";
283
+ } else if (args.openspec.optout) {
284
+ suffix = "(OpenSpec off)";
285
+ } else {
286
+ suffix = "(OpenSpec off — run /skill:chorus enable openspec to set it up)";
287
+ }
288
+ return {
289
+ message: `Chorus connected at ${args.chorusUrl} ${suffix}`,
290
+ level: "info",
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Parse CHORUS_MAX_CODE_REVIEW_ROUNDS into a non-negative integer.
296
+ * Mirrors the Claude plugin's `maxCodeReviewRounds` userConfig (default 3,
297
+ * 0 = unlimited). Empty/absent → default. Invalid (NaN, negative, non-integer)
298
+ * → default. Pure so it is unit-testable.
299
+ */
300
+ export const DEFAULT_MAX_CODE_REVIEW_ROUNDS = 3;
301
+ export function parseMaxCodeReviewRounds(raw: string | undefined): number {
302
+ if (raw == null) return DEFAULT_MAX_CODE_REVIEW_ROUNDS;
303
+ const trimmed = raw.trim();
304
+ if (trimmed === "") return DEFAULT_MAX_CODE_REVIEW_ROUNDS;
305
+ // Use Number (not parseInt) so "3.5" or "3abc" both fall through to default
306
+ // instead of silently parsing the leading digits.
307
+ const n = Number(trimmed);
308
+ if (!Number.isInteger(n) || n < 0) return DEFAULT_MAX_CODE_REVIEW_ROUNDS;
309
+ return n;
310
+ }
311
+
312
+ /**
313
+ * Resolve the bundled `bin/chorus-mcp-call.sh` wrapper path.
314
+ *
315
+ * The wrapper ships inside the chorus-pi package (`bin/chorus-mcp-call.sh`,
316
+ * declared as a `bin` in package.json). When installed via `pi install ./packages/chorus-pi`
317
+ * (a local path), the script is neither linked onto PATH nor placed under
318
+ * `~/.pi/agent/npm/...` (those only happen for npm/git installs), so the skill's
319
+ * `find ~/.pi/agent/npm` fallback misses it. Instead, the extension knows its own
320
+ * install location and can resolve the wrapper relative to the package root.
321
+ *
322
+ * Strategy:
323
+ * 1. start from the extension module's own URL (import.meta.url) → its dir
324
+ * 2. walk up at most a few levels looking for `bin/chorus-mcp-call.sh`
325
+ * (handles `extensions/chorus.ts` → `..`/bin, and `dist/extensions/...` →
326
+ * `../../bin` if the package is ever bundled)
327
+ * 3. return the first existing match, or "" if none found (the skill then falls
328
+ * back to PATH / find)
329
+ *
330
+ * Pure given an injectable fs so it is unit-testable without a real layout.
331
+ */
332
+ export function resolveChorusBin(extensionFileUrl: string, fs: FsLike): string {
333
+ if (!extensionFileUrl) return "";
334
+ // file: URL → filesystem path. Works for import.meta.url of a real .ts/.js file.
335
+ let extPath: string;
336
+ try {
337
+ extPath = extensionFileUrl.startsWith("file:")
338
+ ? new URL(extensionFileUrl).pathname
339
+ : extensionFileUrl;
340
+ } catch {
341
+ return "";
342
+ }
343
+ if (!extPath) return "";
344
+ let dir = dirname(extPath);
345
+ // Walk up at most 6 levels to find <pkgRoot>/bin/chorus-mcp-call.sh.
346
+ for (let i = 0; i < 6; i++) {
347
+ const candidate = join(dir, "bin", "chorus-mcp-call.sh");
348
+ if (fs.existsSync(candidate)) return candidate;
349
+ const parent = dirname(dir);
350
+ if (parent === dir) break; // reached filesystem root
351
+ dir = parent;
352
+ }
353
+ return "";
354
+ }
355
+
356
+ /**
357
+ * The 3 Chorus tool names that should trigger a reviewer nudge after they run.
358
+ * These are the BACKEND native names (no server prefix).
359
+ */
360
+ export const NUDGE_TOOL_NAMES = [
361
+ "chorus_pm_submit_proposal",
362
+ "chorus_submit_for_verify",
363
+ "chorus_admin_verify_task",
364
+ ] as const;
365
+ export type NudgeToolName = (typeof NUDGE_TOOL_NAMES)[number];
366
+
367
+ /**
368
+ * Normalize a tool name seen in a pi event to the Chorus backend native name,
369
+ * so it can be matched against NUDGE_TOOL_NAMES regardless of how pi-mcp-adapter
370
+ * exposed it.
371
+ *
372
+ * Handles all three exposure modes:
373
+ * - gateway mode: event.toolName === "mcp", real name in event.input.tool
374
+ * (e.g. "chorus_chorus_submit_for_verify" — server-prefixed)
375
+ * - direct, toolPrefix "server": "chorus_chorus_submit_for_verify"
376
+ * - direct, toolPrefix "none": "chorus_submit_for_verify" (native)
377
+ *
378
+ * Strips at most one leading "chorus_" server prefix. Returns null if the input
379
+ * is empty or not a chorus tool.
380
+ */
381
+ export function normalizeChorusToolName(name: string | undefined | null): string | null {
382
+ if (!name) return null;
383
+ let n = name;
384
+ // The chorus server name is "chorus"; the adapter prefixes it once. Strip one.
385
+ if (n.startsWith("chorus_chorus_")) n = n.slice("chorus_".length);
386
+ // Must still be a chorus tool after stripping.
387
+ if (!n.startsWith("chorus_")) return null;
388
+ return n;
389
+ }
390
+
391
+ /**
392
+ * Resolve the Chorus native tool name from a tool_result / tool_execution_end event,
393
+ * accounting for MCP gateway mode (where the real name lives in event.input.tool).
394
+ *
395
+ * Returns the native name (e.g. "chorus_submit_for_verify") or null.
396
+ */
397
+ export function resolveChorusToolName(event: {
398
+ toolName: string;
399
+ input?: { tool?: string } | Record<string, unknown>;
400
+ }): string | null {
401
+ // Gateway mode: the agent called the `mcp` proxy tool; the real chorus tool
402
+ // name is in event.input.tool.
403
+ if (event.toolName === "mcp") {
404
+ const input = event.input as { tool?: string } | undefined;
405
+ return normalizeChorusToolName(input?.tool);
406
+ }
407
+ // Direct mode: the tool name itself is the (possibly server-prefixed) chorus name.
408
+ return normalizeChorusToolName(event.toolName);
409
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@chorus-aidlc/chorus-pi",
3
+ "version": "0.0.1",
4
+ "description": "Chorus AI-DLC collaboration platform extension for the Pi coding agent. Provides skills for every stage of the AI-DLC lifecycle, read-only reviewer subagents, and session-aware extension hooks. The Chorus MCP server is auto-discovered from the repo's .mcp.json by pi-mcp-adapter — no installer required.",
5
+ "author": {
6
+ "name": "Chorus-AIDLC"
7
+ },
8
+ "license": "AGPL-3.0",
9
+ "homepage": "https://github.com/Chorus-AIDLC/Chorus",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Chorus-AIDLC/Chorus.git",
13
+ "directory": "packages/chorus-pi"
14
+ },
15
+ "keywords": [
16
+ "chorus",
17
+ "ai-dlc",
18
+ "project-management",
19
+ "multi-agent",
20
+ "collaboration",
21
+ "pi-extension",
22
+ "pi-package"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "main": "extensions/chorus.ts",
28
+ "type": "module",
29
+ "bin": {
30
+ "chorus-mcp-call": "bin/chorus-mcp-call.sh"
31
+ },
32
+ "files": [
33
+ "extensions",
34
+ "lib",
35
+ "skills",
36
+ "agents",
37
+ "bin",
38
+ "README.md"
39
+ ],
40
+ "scripts": {
41
+ "prepublishOnly": "pnpm run check:package",
42
+ "check:package": "node scripts/validate-package.mjs",
43
+ "check:pack": "bash scripts/check-pack.sh"
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-coding-agent": "*"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@earendil-works/pi-coding-agent": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "pi": {
54
+ "extensions": [
55
+ "./extensions"
56
+ ],
57
+ "skills": [
58
+ "./skills"
59
+ ]
60
+ },
61
+ "//note": "Reviewer subagents use pi's official subagent pattern, bundled at extensions/subagent/ (index.ts + agents.ts, copied from earendil-works/pi's examples). The copied agents.ts discovers this package's own agents/*.md via a package-relative BUNDLED_DIR, so the 3 reviewer agents load with ZERO manual copy into ~/.pi/agent/agents/. pi auto-loads extensions/subagent/index.ts as a subdirectory-with-index extension (no manifest entry needed)."
62
+ }
@@ -0,0 +1,166 @@
1
+ ---
2
+ name: brainstorm
3
+ description: Optional divergent-then-convergent dialogue for fuzzy ideas. Invoked from the idea skill as a prelude to structured elaboration; produces one ElaborationRound of decision-point Q&A and returns control. Never writes files, never posts comments, never resolves elaboration.
4
+ license: AGPL-3.0
5
+ metadata:
6
+ author: chorus
7
+ version: "0.17.0"
8
+ category: project-management
9
+ mcp_server: chorus
10
+ ---
11
+
12
+ # Brainstorm Skill
13
+
14
+ A divergent-then-convergent dialogue cadence for ideas whose direction is still being formed. Compresses the conversation into one `ElaborationRound` of decision-point Q&A — same shape as a structured elaboration round, but the questions, options and answers are synthesized at the end of the conversation rather than asked up front.
15
+
16
+ This skill is a **producer** of one elaboration round; the **scheduler** decision (resolve vs. follow-up) belongs to the calling idea skill.
17
+
18
+ ---
19
+
20
+ ## When invoked
21
+
22
+ Only as a sub-step of the idea skill, only after the user has explicitly opted in via `AskUserQuestion`. Never run standalone, never run without user opt-in. The expected entry point is the idea skill's "Step 4.5: Brainstorm Mode (Optional Prelude)" — see the idea skill for the surrounding flow.
23
+
24
+ ---
25
+
26
+ ## Hard rules
27
+
28
+ 1. **One question at a time.** Each `AskUserQuestion` call MUST contain exactly one question entry. Wait for the answer before asking the next.
29
+ 2. **Multi-choice preferred.** Frame each question as 2-4 options where possible. Open-ended is acceptable when options would be premature, but lean toward concrete choices.
30
+ 3. **Propose 2-3 directions before stopping divergence.** Once the requirement direction is clear enough to enumerate, present 2-3 distinct approaches in a single `AskUserQuestion`. Mark exactly one as the recommended option per the host tool's `AskUserQuestion` recommendation convention (the spec does not dictate a specific marking format — follow the tool's documentation).
31
+ 4. **Explicit user approval required to exit divergence.** Do NOT proceed to synthesis until the user has selected one of the proposed directions.
32
+ 5. **No files written.** Do NOT write any markdown, design doc, scratch file, or any other file to disk. The conversation produces an `ElaborationRound` and nothing else on disk.
33
+ 6. **No comments posted.** Do NOT call `chorus_add_comment` from this skill. Comments belong to the idea skill or the user, not to the brainstorm step.
34
+ 7. **No design-doc handoff.** Do NOT invoke any skill whose purpose is to produce a design document or implementation plan. The brainstorm output is the synthesized round — there is no separate doc.
35
+ 8. **No `validate_elaboration` call.** Do NOT call `chorus_pm_validate_elaboration` from this skill. Whether to resolve the elaboration or open a follow-up round (`chorus_pm_start_elaboration` again) is the calling idea skill's decision, not this skill's.
36
+
37
+ ---
38
+
39
+ ## Step-by-step
40
+
41
+ ### 1. Gather context
42
+
43
+ Before asking the first divergent question, read the idea and surrounding project state. Mirror the idea skill's gather-context list:
44
+
45
+ ```
46
+ chorus_get_idea({ ideaUuid })
47
+ chorus_get_documents({ projectUuid })
48
+ chorus_get_document({ documentUuid }) # for any document worth reading in full
49
+ chorus_get_proposals({ projectUuid, status: "approved" }) # to understand patterns
50
+ chorus_list_tasks({ projectUuid }) # to avoid duplicating existing work
51
+ chorus_get_comments({ targetType: "idea", targetUuid: ideaUuid })
52
+ ```
53
+
54
+ Skim each result for: stated background, stated requirements, stated constraints, and what is conspicuously NOT stated. The gaps are the questions worth asking.
55
+
56
+ ### 2. Divergent Q&A
57
+
58
+ Ask one question at a time via `AskUserQuestion`. Aim to surface:
59
+
60
+ - The **goal** the idea is trying to serve (often more abstract than the idea statement).
61
+ - The **constraints** that exclude entire branches of solution space (deadlines, compatibility, scope).
62
+ - The **success criteria** — how will the user know this is done.
63
+
64
+ Keep each question single-purpose. If you need to ask three things, that is three rounds, not one combined `AskUserQuestion`.
65
+
66
+ ### 3. Propose 2-3 directions
67
+
68
+ When the goal, constraints, and success criteria are clear enough that you can name distinct approaches, present them in a single `AskUserQuestion`:
69
+
70
+ ```
71
+ AskUserQuestion({
72
+ questions: [
73
+ {
74
+ question: "<the convergence question>",
75
+ header: "<short header>",
76
+ options: [
77
+ { label: "Option A (Recommended)", description: "<what + tradeoff>" },
78
+ { label: "Option B", description: "<what + tradeoff>" },
79
+ { label: "Option C", description: "<what + tradeoff>" }
80
+ ],
81
+ multiSelect: false
82
+ }
83
+ ]
84
+ })
85
+ ```
86
+
87
+ The recommendation must be visibly marked to the user using the host tool's `AskUserQuestion` convention. State **why** you recommend it — usually a sentence about the dominant tradeoff.
88
+
89
+ ### 4. Wait for explicit approval
90
+
91
+ Do not proceed to synthesis if the user has not selected one of the options. If the user picks "Other" with free text, treat that as a new constraint — go back to step 2 or step 3 with the refined direction.
92
+
93
+ ### 5. Synthesize decision-point Q&A
94
+
95
+ For each material decision the user made during the conversation, build one `ElaborationQuestion`. A "material decision" is a moment where the user chose between alternatives or set scope explicitly. Map each decision per the synthesis spec below.
96
+
97
+ ### 6. Persist the round
98
+
99
+ Call `chorus_pm_start_elaboration` with the synthesized questions:
100
+
101
+ ```
102
+ chorus_pm_start_elaboration({
103
+ ideaUuid,
104
+ depth: "standard",
105
+ questions: [
106
+ { id: "q1", text: "...", category: "...", options: [...] },
107
+ ...
108
+ ]
109
+ })
110
+ ```
111
+
112
+ Then submit the answers in one call:
113
+
114
+ ```
115
+ chorus_answer_elaboration({
116
+ ideaUuid,
117
+ roundUuid,
118
+ answers: [
119
+ { questionId: "q1", selectedOptionId: "...", customText: "<rationale>" },
120
+ ...
121
+ ]
122
+ })
123
+ ```
124
+
125
+ ### 7. Return control
126
+
127
+ Stop here. Do **NOT** call `chorus_pm_validate_elaboration`. The idea skill's caller now decides:
128
+
129
+ - If the synthesized round answers cover everything → caller obtains human confirmation, then resolves with `chorus_pm_validate_elaboration`.
130
+ - If gaps remain → caller opens a structured Round 2 by calling `chorus_pm_start_elaboration` again.
131
+
132
+ The depth of any follow-up round is the caller's call, not yours.
133
+
134
+ ---
135
+
136
+ ## Synthesis spec
137
+
138
+ Each material decision becomes exactly one `ElaborationQuestion` with these fields:
139
+
140
+ | Field | Source |
141
+ |---|---|
142
+ | `text` | The decision question, phrased neutrally. Example: "Which depth-model placement?" |
143
+ | `category` | `functional`, `non_functional`, `business_context`, `technical_context`, `user_scenario`, or `scope` — derived from the topic. |
144
+ | `options` | All directions that were considered, length 2-5. Collapse near-duplicates into one option. |
145
+ | `selectedOptionId` | The id of the option the user approved. |
146
+ | `customText` | A 1-3 sentence rationale capturing the constraint or tradeoff that drove the choice. Not a transcript dump. |
147
+
148
+ Rules:
149
+
150
+ - A `customText` longer than ~3 sentences is a sign you are summarizing transcript instead of capturing rationale. Cut.
151
+ - An `options` array of length 2 with binary "yes / no" framing is a sign you pre-narrowed alternatives. Re-examine — there are usually at least three meaningfully different paths, even if two of them get rejected quickly.
152
+ - Skip "decisions" that were never genuinely contested. If the user agreed instantly to the only proposal, that is information for the idea content, not a decision-point Q&A.
153
+
154
+ ---
155
+
156
+ ## Anti-patterns
157
+
158
+ Do not do any of the following. Each has a specific failure mode that this skill must prevent:
159
+
160
+ - **Single-summary `customText` blob.** Compressing the entire conversation into one ElaborationQuestion with a long markdown summary in `customText`. The schema is multi-question for a reason — preserve the decision granularity.
161
+ - **Transcript-as-comment.** Posting the raw conversation log as a comment on the idea (or anywhere). The synthesized round IS the artifact. Raw transcripts pollute the audit trail with noise.
162
+ - **File writes.** Writing any markdown, design doc, plan, or scratch file to disk. There is no design doc in this flow. The brainstorm output is the synthesized round, not an external document.
163
+ - **`validate_elaboration` calls.** Closing the elaboration phase from this skill. The lifecycle decision belongs to the idea skill. Calling it here strips the caller of its scheduler role.
164
+ - **Design-doc handoff.** Invoking any skill that produces an implementation plan or design document. The Chorus pipeline already has Proposal → Document Drafts → Task Drafts for that — the brainstorm output feeds them through ElaborationRound, not through external doc skills.
165
+ - **Length-2 binary "yes / no" framings.** Reducing every decision to "do this thing — yes / no". Almost always the genuine alternatives are 3+ approaches with meaningfully different tradeoffs. Length-2 framings often mean the divergent phase ended too early.
166
+ - **Asking multiple questions in one `AskUserQuestion`.** The cadence is one question per turn during divergence, then one final convergence question with 2-3 options. Combining unrelated questions is a sign you are rushing.