gsd-pi 2.3.5 → 2.3.7

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 (26) hide show
  1. package/README.md +26 -12
  2. package/dist/cli.js +24 -1
  3. package/dist/wizard.js +16 -0
  4. package/package.json +1 -1
  5. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +60 -0
  6. package/scripts/postinstall.js +5 -3
  7. package/src/resources/extensions/ask-user-questions.ts +54 -5
  8. package/src/resources/extensions/gsd/auto.ts +17 -3
  9. package/src/resources/extensions/gsd/commands.ts +16 -3
  10. package/src/resources/extensions/gsd/index.ts +17 -1
  11. package/src/resources/extensions/gsd/preferences.ts +17 -1
  12. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
  13. package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
  14. package/src/resources/extensions/gsd/worktree.ts +11 -0
  15. package/src/resources/extensions/remote-questions/config.ts +81 -0
  16. package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
  17. package/src/resources/extensions/remote-questions/format.ts +163 -0
  18. package/src/resources/extensions/remote-questions/manager.ts +192 -0
  19. package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
  20. package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
  21. package/src/resources/extensions/remote-questions/status.ts +31 -0
  22. package/src/resources/extensions/remote-questions/store.ts +77 -0
  23. package/src/resources/extensions/remote-questions/types.ts +75 -0
  24. package/src/resources/extensions/github/formatters.ts +0 -207
  25. package/src/resources/extensions/github/gh-api.ts +0 -553
  26. package/src/resources/extensions/github/index.ts +0 -778
@@ -0,0 +1,155 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts";
4
+ import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts";
5
+ import { sanitizeError } from "../../remote-questions/manager.ts";
6
+
7
+ test("parseSlackReply handles single-number single-question answers", () => {
8
+ const result = parseSlackReply("2", [{
9
+ id: "choice",
10
+ header: "Choice",
11
+ question: "Pick one",
12
+ allowMultiple: false,
13
+ options: [
14
+ { label: "Alpha", description: "A" },
15
+ { label: "Beta", description: "B" },
16
+ ],
17
+ }]);
18
+
19
+ assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } });
20
+ });
21
+
22
+ test("parseSlackReply handles multiline multi-question answers", () => {
23
+ const result = parseSlackReply("1\ncustom note", [
24
+ {
25
+ id: "first",
26
+ header: "First",
27
+ question: "Pick one",
28
+ allowMultiple: false,
29
+ options: [
30
+ { label: "Alpha", description: "A" },
31
+ { label: "Beta", description: "B" },
32
+ ],
33
+ },
34
+ {
35
+ id: "second",
36
+ header: "Second",
37
+ question: "Explain",
38
+ allowMultiple: false,
39
+ options: [
40
+ { label: "Gamma", description: "G" },
41
+ { label: "Delta", description: "D" },
42
+ ],
43
+ },
44
+ ]);
45
+
46
+ assert.deepEqual(result, {
47
+ answers: {
48
+ first: { answers: ["Alpha"] },
49
+ second: { answers: [], user_note: "custom note" },
50
+ },
51
+ });
52
+ });
53
+
54
+ test("parseDiscordResponse handles single-question reactions", () => {
55
+ const result = parseDiscordResponse([{ emoji: "2️⃣", count: 1 }], null, [{
56
+ id: "choice",
57
+ header: "Choice",
58
+ question: "Pick one",
59
+ allowMultiple: false,
60
+ options: [
61
+ { label: "Alpha", description: "A" },
62
+ { label: "Beta", description: "B" },
63
+ ],
64
+ }]);
65
+
66
+ assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } });
67
+ });
68
+
69
+ test("parseDiscordResponse rejects multi-question reaction parsing", () => {
70
+ const result = parseDiscordResponse([{ emoji: "1️⃣", count: 1 }], null, [
71
+ {
72
+ id: "first",
73
+ header: "First",
74
+ question: "Pick one",
75
+ allowMultiple: false,
76
+ options: [{ label: "Alpha", description: "A" }],
77
+ },
78
+ {
79
+ id: "second",
80
+ header: "Second",
81
+ question: "Pick one",
82
+ allowMultiple: false,
83
+ options: [{ label: "Beta", description: "B" }],
84
+ },
85
+ ]);
86
+
87
+ assert.match(String(result.answers.first.user_note), /single-question prompts/i);
88
+ assert.match(String(result.answers.second.user_note), /single-question prompts/i);
89
+ });
90
+
91
+ test("parseSlackReply truncates user_note longer than 500 chars", () => {
92
+ const longText = "x".repeat(600);
93
+ const result = parseSlackReply(longText, [{
94
+ id: "q1",
95
+ header: "Q1",
96
+ question: "Pick",
97
+ allowMultiple: false,
98
+ options: [{ label: "A", description: "a" }],
99
+ }]);
100
+
101
+ const note = result.answers.q1.user_note!;
102
+ assert.ok(note.length <= 502, `note should be truncated, got ${note.length} chars`);
103
+ assert.ok(note.endsWith("…"), "truncated note should end with ellipsis");
104
+ });
105
+
106
+ test("isValidChannelId rejects invalid Slack channel IDs", () => {
107
+ // Too short
108
+ assert.equal(isValidChannelId("slack", "C123"), false);
109
+ // Contains invalid chars (URL injection)
110
+ assert.equal(isValidChannelId("slack", "https://evil.com"), false);
111
+ // Lowercase
112
+ assert.equal(isValidChannelId("slack", "c12345678"), false);
113
+ // Too long
114
+ assert.equal(isValidChannelId("slack", "C1234567890AB"), false);
115
+ // Valid: 9-12 uppercase alphanumeric
116
+ assert.equal(isValidChannelId("slack", "C12345678"), true);
117
+ assert.equal(isValidChannelId("slack", "C12345678AB"), true);
118
+ assert.equal(isValidChannelId("slack", "C1234567890A"), true);
119
+ });
120
+
121
+ test("isValidChannelId rejects invalid Discord channel IDs", () => {
122
+ // Too short
123
+ assert.equal(isValidChannelId("discord", "12345"), false);
124
+ // Contains letters (not a snowflake)
125
+ assert.equal(isValidChannelId("discord", "abc12345678901234"), false);
126
+ // URL injection
127
+ assert.equal(isValidChannelId("discord", "https://evil.com"), false);
128
+ // Too long (21 digits)
129
+ assert.equal(isValidChannelId("discord", "123456789012345678901"), false);
130
+ // Valid: 17-20 digit snowflake
131
+ assert.equal(isValidChannelId("discord", "12345678901234567"), true);
132
+ assert.equal(isValidChannelId("discord", "11234567890123456789"), true);
133
+ });
134
+
135
+ test("sanitizeError strips Slack token patterns from error messages", () => {
136
+ assert.equal(
137
+ sanitizeError("Auth failed: xoxb-1234-5678-abcdef"),
138
+ "Auth failed: [REDACTED]",
139
+ );
140
+ assert.equal(
141
+ sanitizeError("Bad token xoxp-abc-def-ghi in request"),
142
+ "Bad token [REDACTED] in request",
143
+ );
144
+ });
145
+
146
+ test("sanitizeError strips long opaque secrets", () => {
147
+ const fakeDiscordToken = "MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.G1x2y3.abcdefghijklmnop";
148
+ assert.ok(!sanitizeError(`Token: ${fakeDiscordToken}`).includes(fakeDiscordToken));
149
+ });
150
+
151
+ test("sanitizeError preserves short safe messages", () => {
152
+ assert.equal(sanitizeError("HTTP 401: Unauthorized"), "HTTP 401: Unauthorized");
153
+ assert.equal(sanitizeError("Connection refused"), "Connection refused");
154
+ });
155
+
@@ -0,0 +1,99 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdirSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createPromptRecord, writePromptRecord } from "../../remote-questions/store.ts";
7
+ import { getLatestPromptSummary } from "../../remote-questions/status.ts";
8
+
9
+ function withTempHome(fn: (tempHome: string) => void | Promise<void>) {
10
+ return async () => {
11
+ const savedHome = process.env.HOME;
12
+ const savedUserProfile = process.env.USERPROFILE;
13
+ const tempHome = join(tmpdir(), `gsd-remote-status-${Date.now()}-${Math.random().toString(36).slice(2)}`);
14
+ mkdirSync(join(tempHome, ".gsd", "runtime", "remote-questions"), { recursive: true });
15
+ process.env.HOME = tempHome;
16
+ process.env.USERPROFILE = tempHome;
17
+ try {
18
+ await fn(tempHome);
19
+ } finally {
20
+ process.env.HOME = savedHome;
21
+ process.env.USERPROFILE = savedUserProfile;
22
+ rmSync(tempHome, { recursive: true, force: true });
23
+ }
24
+ };
25
+ }
26
+
27
+ test("getLatestPromptSummary returns latest stored prompt", withTempHome(() => {
28
+ const recordA = createPromptRecord({
29
+ id: "a-prompt",
30
+ channel: "slack",
31
+ createdAt: 1,
32
+ timeoutAt: 10,
33
+ pollIntervalMs: 5000,
34
+ questions: [],
35
+ });
36
+ recordA.updatedAt = 1;
37
+ writePromptRecord(recordA);
38
+
39
+ const recordB = createPromptRecord({
40
+ id: "z-prompt",
41
+ channel: "discord",
42
+ createdAt: 2,
43
+ timeoutAt: 10,
44
+ pollIntervalMs: 5000,
45
+ questions: [],
46
+ });
47
+ recordB.updatedAt = 2;
48
+ recordB.status = "answered";
49
+ writePromptRecord(recordB);
50
+
51
+ const latest = getLatestPromptSummary();
52
+ assert.equal(latest?.id, "z-prompt");
53
+ assert.equal(latest?.status, "answered");
54
+ }));
55
+
56
+ test("getLatestPromptSummary sorts by updatedAt, not filename", withTempHome(() => {
57
+ // Record with alphabetically-LAST id but OLDEST timestamp
58
+ const old = createPromptRecord({
59
+ id: "zzz-oldest",
60
+ channel: "slack",
61
+ createdAt: 1000,
62
+ timeoutAt: 9999,
63
+ pollIntervalMs: 5000,
64
+ questions: [],
65
+ });
66
+ old.updatedAt = 1000;
67
+ writePromptRecord(old);
68
+
69
+ // Record with alphabetically-FIRST id but NEWEST timestamp
70
+ const newest = createPromptRecord({
71
+ id: "aaa-newest",
72
+ channel: "discord",
73
+ createdAt: 3000,
74
+ timeoutAt: 9999,
75
+ pollIntervalMs: 5000,
76
+ questions: [],
77
+ });
78
+ newest.updatedAt = 3000;
79
+ newest.status = "answered";
80
+ writePromptRecord(newest);
81
+
82
+ // Record in between
83
+ const middle = createPromptRecord({
84
+ id: "mmm-middle",
85
+ channel: "slack",
86
+ createdAt: 2000,
87
+ timeoutAt: 9999,
88
+ pollIntervalMs: 5000,
89
+ questions: [],
90
+ });
91
+ middle.updatedAt = 2000;
92
+ writePromptRecord(middle);
93
+
94
+ const latest = getLatestPromptSummary();
95
+ // Should return "aaa-newest" (updatedAt=3000), NOT "zzz-oldest" (alphabetically last)
96
+ assert.equal(latest?.id, "aaa-newest", "should pick the most recently updated prompt, not the alphabetically last filename");
97
+ assert.equal(latest?.status, "answered");
98
+ assert.equal(latest?.updatedAt, 3000);
99
+ }));
@@ -86,6 +86,17 @@ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId
86
86
  created = true;
87
87
  }
88
88
 
89
+ // Auto-commit dirty files before checkout to prevent "would be overwritten" errors.
90
+ // This handles cases where doctor, STATE.md rebuild, or agent work left uncommitted changes.
91
+ const status = runGit(basePath, ["status", "--short"]);
92
+ if (status.trim()) {
93
+ runGit(basePath, ["add", "-A"]);
94
+ const staged = runGit(basePath, ["diff", "--cached", "--stat"]);
95
+ if (staged.trim()) {
96
+ runGit(basePath, ["commit", "-m", `"chore: auto-commit before switching to ${branch}"`]);
97
+ }
98
+ }
99
+
89
100
  runGit(basePath, ["checkout", branch]);
90
101
  return created;
91
102
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Remote Questions — configuration resolution and validation
3
+ */
4
+
5
+ import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js";
6
+ import type { RemoteChannel } from "./types.js";
7
+
8
+ export interface ResolvedConfig {
9
+ channel: RemoteChannel;
10
+ channelId: string;
11
+ timeoutMs: number;
12
+ pollIntervalMs: number;
13
+ token: string;
14
+ }
15
+
16
+ const ENV_KEYS: Record<RemoteChannel, string> = {
17
+ slack: "SLACK_BOT_TOKEN",
18
+ discord: "DISCORD_BOT_TOKEN",
19
+ };
20
+
21
+ // Channel ID format validation — prevents SSRF if preferences are attacker-controlled
22
+ const CHANNEL_ID_PATTERNS: Record<RemoteChannel, RegExp> = {
23
+ slack: /^[A-Z0-9]{9,12}$/,
24
+ discord: /^\d{17,20}$/,
25
+ };
26
+
27
+ const DEFAULT_TIMEOUT_MINUTES = 5;
28
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
29
+ const MIN_TIMEOUT_MINUTES = 1;
30
+ const MAX_TIMEOUT_MINUTES = 30;
31
+ const MIN_POLL_INTERVAL_SECONDS = 2;
32
+ const MAX_POLL_INTERVAL_SECONDS = 30;
33
+
34
+ export function resolveRemoteConfig(): ResolvedConfig | null {
35
+ const prefs = loadEffectiveGSDPreferences();
36
+ const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions;
37
+ if (!rq || !rq.channel || !rq.channel_id) return null;
38
+ if (rq.channel !== "slack" && rq.channel !== "discord") return null;
39
+
40
+ const channelId = String(rq.channel_id);
41
+ if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return null;
42
+
43
+ const token = process.env[ENV_KEYS[rq.channel]];
44
+ if (!token) return null;
45
+
46
+ const timeoutMinutes = clampNumber(rq.timeout_minutes, DEFAULT_TIMEOUT_MINUTES, MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES);
47
+ const pollIntervalSeconds = clampNumber(rq.poll_interval_seconds, DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS);
48
+
49
+ return {
50
+ channel: rq.channel,
51
+ channelId,
52
+ timeoutMs: timeoutMinutes * 60 * 1000,
53
+ pollIntervalMs: pollIntervalSeconds * 1000,
54
+ token,
55
+ };
56
+ }
57
+
58
+ export function getRemoteConfigStatus(): string {
59
+ const prefs = loadEffectiveGSDPreferences();
60
+ const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions;
61
+ if (!rq || !rq.channel || !rq.channel_id) return "Remote questions: not configured";
62
+ if (rq.channel !== "slack" && rq.channel !== "discord") return `Remote questions: unknown channel type \"${rq.channel}\"`;
63
+ const channelId = String(rq.channel_id);
64
+ if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return `Remote questions: invalid ${rq.channel} channel ID format`;
65
+ const envVar = ENV_KEYS[rq.channel];
66
+ if (!process.env[envVar]) return `Remote questions: ${envVar} not set — remote questions disabled`;
67
+
68
+ const timeoutMinutes = clampNumber(rq.timeout_minutes, DEFAULT_TIMEOUT_MINUTES, MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES);
69
+ const pollIntervalSeconds = clampNumber(rq.poll_interval_seconds, DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS);
70
+ return `Remote questions: ${rq.channel} configured (timeout ${timeoutMinutes}m, poll ${pollIntervalSeconds}s)`;
71
+ }
72
+
73
+ export function isValidChannelId(channel: RemoteChannel, id: string): boolean {
74
+ return CHANNEL_ID_PATTERNS[channel].test(id);
75
+ }
76
+
77
+ function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
78
+ const n = typeof value === "number" ? value : Number(value);
79
+ if (!Number.isFinite(n)) return fallback;
80
+ return Math.max(min, Math.min(max, n));
81
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Remote Questions — Discord adapter
3
+ */
4
+
5
+ import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
6
+ import { formatForDiscord, parseDiscordResponse } from "./format.js";
7
+
8
+ const DISCORD_API = "https://discord.com/api/v10";
9
+ const PER_REQUEST_TIMEOUT_MS = 15_000;
10
+ const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
11
+
12
+ export class DiscordAdapter implements ChannelAdapter {
13
+ readonly name = "discord" as const;
14
+ private botUserId: string | null = null;
15
+ private readonly token: string;
16
+ private readonly channelId: string;
17
+
18
+ constructor(token: string, channelId: string) {
19
+ this.token = token;
20
+ this.channelId = channelId;
21
+ }
22
+
23
+ async validate(): Promise<void> {
24
+ const res = await this.discordApi("GET", "/users/@me");
25
+ if (!res.id) throw new Error("Discord auth failed: invalid token");
26
+ this.botUserId = String(res.id);
27
+ }
28
+
29
+ async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
30
+ const { embeds, reactionEmojis } = formatForDiscord(prompt);
31
+ const res = await this.discordApi("POST", `/channels/${this.channelId}/messages`, {
32
+ content: "**GSD needs your input** — reply to this message with your answer",
33
+ embeds,
34
+ });
35
+
36
+ if (!res.id) throw new Error(`Discord send failed: ${JSON.stringify(res)}`);
37
+
38
+ const messageId = String(res.id);
39
+ if (prompt.questions.length === 1) {
40
+ for (const emoji of reactionEmojis) {
41
+ try {
42
+ await this.discordApi("PUT", `/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
43
+ } catch {
44
+ // Best-effort only
45
+ }
46
+ }
47
+ }
48
+
49
+ return {
50
+ ref: {
51
+ id: prompt.id,
52
+ channel: "discord",
53
+ messageId,
54
+ channelId: this.channelId,
55
+ },
56
+ };
57
+ }
58
+
59
+ async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
60
+ if (!this.botUserId) await this.validate();
61
+
62
+ if (prompt.questions.length === 1) {
63
+ const reactionAnswer = await this.checkReactions(prompt, ref);
64
+ if (reactionAnswer) return reactionAnswer;
65
+ }
66
+
67
+ return this.checkReplies(prompt, ref);
68
+ }
69
+
70
+ private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
71
+ const reactions: Array<{ emoji: string; count: number }> = [];
72
+ for (const emoji of NUMBER_EMOJIS) {
73
+ try {
74
+ const users = await this.discordApi("GET", `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`);
75
+ if (Array.isArray(users)) {
76
+ const humanUsers = users.filter((u: { id: string }) => u.id !== this.botUserId);
77
+ if (humanUsers.length > 0) reactions.push({ emoji, count: humanUsers.length });
78
+ }
79
+ } catch (err) {
80
+ const msg = String((err as Error).message ?? "");
81
+ // 404 = no reactions for this emoji — expected, continue
82
+ if (msg.includes("HTTP 404")) continue;
83
+ // 401/403 = auth failure — surface to caller so it can fail the poll
84
+ if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) throw err;
85
+ // Other errors (rate limit, network) — skip this emoji, best-effort
86
+ }
87
+ }
88
+
89
+ if (reactions.length === 0) return null;
90
+ return parseDiscordResponse(reactions, null, prompt.questions);
91
+ }
92
+
93
+ private async checkReplies(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
94
+ const messages = await this.discordApi("GET", `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`);
95
+ if (!Array.isArray(messages)) return null;
96
+
97
+ const replies = messages.filter(
98
+ (m: { author?: { id?: string }; message_reference?: { message_id?: string }; content?: string }) =>
99
+ m.author?.id &&
100
+ m.author.id !== this.botUserId &&
101
+ m.message_reference?.message_id === ref.messageId &&
102
+ m.content,
103
+ );
104
+
105
+ if (replies.length === 0) return null;
106
+ return parseDiscordResponse([], String(replies[0].content), prompt.questions);
107
+ }
108
+
109
+ private async discordApi(method: string, path: string, body?: unknown): Promise<any> {
110
+ const headers: Record<string, string> = { Authorization: `Bot ${this.token}` };
111
+ const init: RequestInit = { method, headers };
112
+ if (body) {
113
+ headers["Content-Type"] = "application/json";
114
+ init.body = JSON.stringify(body);
115
+ }
116
+
117
+ init.signal = AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS);
118
+ const response = await fetch(`${DISCORD_API}${path}`, init);
119
+ if (response.status === 204) return {};
120
+ if (!response.ok) {
121
+ const text = await response.text().catch(() => "");
122
+ // Limit error body length to avoid leaking verbose Discord error responses
123
+ const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text;
124
+ throw new Error(`Discord API HTTP ${response.status}: ${safeText}`);
125
+ }
126
+ return response.json();
127
+ }
128
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Remote Questions — payload formatting and parsing helpers
3
+ */
4
+
5
+ import type { RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js";
6
+
7
+ export interface SlackBlock {
8
+ type: string;
9
+ text?: { type: string; text: string };
10
+ elements?: Array<{ type: string; text: string }>;
11
+ }
12
+
13
+ export interface DiscordEmbed {
14
+ title: string;
15
+ description: string;
16
+ color: number;
17
+ fields: Array<{ name: string; value: string; inline?: boolean }>;
18
+ footer?: { text: string };
19
+ }
20
+
21
+ const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
22
+ const MAX_USER_NOTE_LENGTH = 500;
23
+
24
+ export function formatForSlack(prompt: RemotePrompt): SlackBlock[] {
25
+ const blocks: SlackBlock[] = [
26
+ {
27
+ type: "header",
28
+ text: { type: "plain_text", text: "GSD needs your input" },
29
+ },
30
+ ];
31
+
32
+ for (const q of prompt.questions) {
33
+ blocks.push({
34
+ type: "section",
35
+ text: { type: "mrkdwn", text: `*${q.header}*\n${q.question}` },
36
+ });
37
+
38
+ blocks.push({
39
+ type: "section",
40
+ text: {
41
+ type: "mrkdwn",
42
+ text: q.options.map((opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`).join("\n"),
43
+ },
44
+ });
45
+
46
+ blocks.push({
47
+ type: "context",
48
+ elements: [{
49
+ type: "mrkdwn",
50
+ text: q.allowMultiple
51
+ ? "Reply in thread with comma-separated numbers (`1,3`) or free text."
52
+ : "Reply in thread with a number (`1`) or free text.",
53
+ }],
54
+ });
55
+
56
+ blocks.push({ type: "divider" });
57
+ }
58
+
59
+ return blocks;
60
+ }
61
+
62
+ export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[]; reactionEmojis: string[] } {
63
+ const reactionEmojis: string[] = [];
64
+ const embeds: DiscordEmbed[] = prompt.questions.map((q, questionIndex) => {
65
+ const supportsReactions = prompt.questions.length === 1;
66
+ const optionLines = q.options.map((opt, i) => {
67
+ const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`;
68
+ if (supportsReactions && NUMBER_EMOJIS[i]) reactionEmojis.push(NUMBER_EMOJIS[i]);
69
+ return `${emoji} **${opt.label}** — ${opt.description}`;
70
+ });
71
+
72
+ const footerText = supportsReactions
73
+ ? (q.allowMultiple
74
+ ? "Reply with comma-separated choices (`1,3`) or react with matching numbers"
75
+ : "Reply with a number or react with the matching number")
76
+ : `Question ${questionIndex + 1}/${prompt.questions.length} — reply with one line per question or use semicolons`;
77
+
78
+ return {
79
+ title: q.header,
80
+ description: q.question,
81
+ color: 0x7c3aed,
82
+ fields: [{ name: "Options", value: optionLines.join("\n") }],
83
+ footer: { text: footerText },
84
+ };
85
+ });
86
+
87
+ return { embeds, reactionEmojis };
88
+ }
89
+
90
+ export function parseSlackReply(text: string, questions: RemoteQuestion[]): RemoteAnswer {
91
+ const answers: RemoteAnswer["answers"] = {};
92
+ const trimmed = text.trim();
93
+
94
+ if (questions.length === 1) {
95
+ answers[questions[0].id] = parseAnswerForQuestion(trimmed, questions[0]);
96
+ return { answers };
97
+ }
98
+
99
+ const parts = trimmed.includes(";")
100
+ ? trimmed.split(";").map((s) => s.trim()).filter(Boolean)
101
+ : trimmed.split("\n").map((s) => s.trim()).filter(Boolean);
102
+
103
+ for (let i = 0; i < questions.length; i++) {
104
+ answers[questions[i].id] = parseAnswerForQuestion(parts[i] ?? "", questions[i]);
105
+ }
106
+
107
+ return { answers };
108
+ }
109
+
110
+ export function parseDiscordResponse(
111
+ reactions: Array<{ emoji: string; count: number }>,
112
+ replyText: string | null,
113
+ questions: RemoteQuestion[],
114
+ ): RemoteAnswer {
115
+ if (replyText) return parseSlackReply(replyText, questions);
116
+
117
+ const answers: RemoteAnswer["answers"] = {};
118
+ if (questions.length !== 1) {
119
+ for (const q of questions) {
120
+ answers[q.id] = { answers: [], user_note: "Discord reactions are only supported for single-question prompts" };
121
+ }
122
+ return { answers };
123
+ }
124
+
125
+ const q = questions[0];
126
+ const picked = reactions
127
+ .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0)
128
+ .map((r) => q.options[NUMBER_EMOJIS.indexOf(r.emoji)]?.label)
129
+ .filter(Boolean) as string[];
130
+
131
+ answers[q.id] = picked.length > 0
132
+ ? { answers: q.allowMultiple ? picked : [picked[0]] }
133
+ : { answers: [], user_note: "No clear response via reactions" };
134
+
135
+ return { answers };
136
+ }
137
+
138
+ function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: string[]; user_note?: string } {
139
+ if (!text) return { answers: [], user_note: "No response provided" };
140
+
141
+ if (/^[\d,\s]+$/.test(text)) {
142
+ const nums = text
143
+ .split(",")
144
+ .map((s) => parseInt(s.trim(), 10))
145
+ .filter((n) => !Number.isNaN(n) && n >= 1 && n <= q.options.length);
146
+
147
+ if (nums.length > 0) {
148
+ const selected = nums.map((n) => q.options[n - 1].label);
149
+ return { answers: q.allowMultiple ? selected : [selected[0]] };
150
+ }
151
+ }
152
+
153
+ const single = parseInt(text, 10);
154
+ if (!Number.isNaN(single) && single >= 1 && single <= q.options.length) {
155
+ return { answers: [q.options[single - 1].label] };
156
+ }
157
+
158
+ return { answers: [], user_note: truncateNote(text) };
159
+ }
160
+
161
+ function truncateNote(text: string): string {
162
+ return text.length > MAX_USER_NOTE_LENGTH ? text.slice(0, MAX_USER_NOTE_LENGTH) + "…" : text;
163
+ }