gsd-pi 2.3.6 → 2.3.8

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/dist/wizard.js CHANGED
@@ -81,6 +81,8 @@ export function loadStoredEnvKeys(authStorage) {
81
81
  ['brave_answers', 'BRAVE_ANSWERS_KEY'],
82
82
  ['context7', 'CONTEXT7_API_KEY'],
83
83
  ['jina', 'JINA_API_KEY'],
84
+ ['slack_bot', 'SLACK_BOT_TOKEN'],
85
+ ['discord_bot', 'DISCORD_BOT_TOKEN'],
84
86
  ];
85
87
  for (const [provider, envVar] of providers) {
86
88
  if (!process.env[envVar]) {
@@ -120,6 +122,20 @@ const API_KEYS = [
120
122
  hint: '(clean page extraction)',
121
123
  description: 'High-quality web page content extraction',
122
124
  },
125
+ {
126
+ provider: 'slack_bot',
127
+ envVar: 'SLACK_BOT_TOKEN',
128
+ label: 'Slack Bot',
129
+ hint: '(remote questions in auto-mode)',
130
+ description: 'Bot token for remote questions via Slack',
131
+ },
132
+ {
133
+ provider: 'discord_bot',
134
+ envVar: 'DISCORD_BOT_TOKEN',
135
+ label: 'Discord Bot',
136
+ hint: '(remote questions in auto-mode)',
137
+ description: 'Bot token for remote questions via Discord',
138
+ },
123
139
  ];
124
140
  /**
125
141
  * Check for missing optional tool API keys and prompt for them if on a TTY.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.3.6",
3
+ "version": "2.3.8",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,3 +1,63 @@
1
+ diff --git a/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js b/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js
2
+ index 90622c2..cff094b 100644
3
+ --- a/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js
4
+ +++ b/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js
5
+ @@ -1007,7 +1007,7 @@ export class AgentSession {
6
+ * Validates API key, saves to session and settings.
7
+ * @throws Error if no API key available for the model
8
+ */
9
+ - async setModel(model) {
10
+ + async setModel(model, options) {
11
+ const apiKey = await this._modelRegistry.getApiKey(model);
12
+ if (!apiKey) {
13
+ throw new Error(`No API key for ${model.provider}/${model.id}`);
14
+ @@ -1016,7 +1016,9 @@ export class AgentSession {
15
+ const thinkingLevel = this._getThinkingLevelForModelSwitch();
16
+ this.agent.setModel(model);
17
+ this.sessionManager.appendModelChange(model.provider, model.id);
18
+ - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
19
+ + if (options?.persist !== false) {
20
+ + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
21
+ + }
22
+ // Re-clamp thinking level for new model's capabilities
23
+ this.setThinkingLevel(thinkingLevel);
24
+ await this._emitModelSelect(model, previousModel, "set");
25
+ @@ -1067,7 +1069,9 @@ export class AgentSession {
26
+ // Apply model
27
+ this.agent.setModel(next.model);
28
+ this.sessionManager.appendModelChange(next.model.provider, next.model.id);
29
+ - this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
30
+ + if (options?.persist !== false) {
31
+ + this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
32
+ + }
33
+ // Apply thinking level.
34
+ // - Explicit scoped model thinking level overrides current session level
35
+ // - Undefined scoped model thinking level inherits the current session preference
36
+ @@ -1094,7 +1098,9 @@ export class AgentSession {
37
+ const thinkingLevel = this._getThinkingLevelForModelSwitch();
38
+ this.agent.setModel(nextModel);
39
+ this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
40
+ - this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
41
+ + if (options?.persist !== false) {
42
+ + this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
43
+ + }
44
+ // Re-clamp thinking level for new model's capabilities
45
+ this.setThinkingLevel(thinkingLevel);
46
+ await this._emitModelSelect(nextModel, currentModel, "cycle");
47
+ @@ -1659,11 +1665,11 @@ export class AgentSession {
48
+ setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
49
+ refreshTools: () => this._refreshToolRegistry(),
50
+ getCommands,
51
+ - setModel: async (model) => {
52
+ + setModel: async (model, options) => {
53
+ const key = await this.modelRegistry.getApiKey(model);
54
+ if (!key)
55
+ return false;
56
+ - await this.setModel(model);
57
+ + await this.setModel(model, options);
58
+ return true;
59
+ },
60
+ getThinkingLevel: () => this.thinkingLevel,
1
61
  diff --git a/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js b/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js
2
62
  index 27fe820..68f277f 100644
3
63
  --- a/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js
@@ -21,12 +21,27 @@ import {
21
21
 
22
22
  // ─── Types ────────────────────────────────────────────────────────────────────
23
23
 
24
- interface AskUserQuestionsDetails {
24
+ interface LocalResultDetails {
25
+ remote?: false;
25
26
  questions: Question[];
26
27
  response: RoundResult | null;
27
28
  cancelled: boolean;
28
29
  }
29
30
 
31
+ interface RemoteResultDetails {
32
+ remote: true;
33
+ channel: string;
34
+ timed_out: boolean;
35
+ promptId?: string;
36
+ threadUrl?: string;
37
+ status?: string;
38
+ questions?: Question[];
39
+ response?: import("./remote-questions/types.js").RemoteAnswer;
40
+ error?: boolean;
41
+ }
42
+
43
+ type AskUserQuestionsDetails = LocalResultDetails | RemoteResultDetails;
44
+
30
45
  // ─── Schema ───────────────────────────────────────────────────────────────────
31
46
 
32
47
  const OptionSchema = Type.Object({
@@ -104,7 +119,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
104
119
  ],
105
120
  parameters: AskUserQuestionsParams,
106
121
 
107
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
122
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
108
123
  // Validation
109
124
  if (params.questions.length === 0 || params.questions.length > 3) {
110
125
  return errorResult("Error: questions must contain 1-3 items", params.questions);
@@ -120,6 +135,9 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
120
135
  }
121
136
 
122
137
  if (!ctx.hasUI) {
138
+ const { tryRemoteQuestions } = await import("./remote-questions/manager.js");
139
+ const remoteResult = await tryRemoteQuestions(params.questions, signal);
140
+ if (remoteResult) return remoteResult;
123
141
  return errorResult("Error: UI not available (non-interactive mode)", params.questions);
124
142
  }
125
143
 
@@ -131,13 +149,13 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
131
149
  if (!hasAnswers) {
132
150
  return {
133
151
  content: [{ type: "text", text: "ask_user_questions was cancelled before receiving a response" }],
134
- details: { questions: params.questions, response: null, cancelled: true } as AskUserQuestionsDetails,
152
+ details: { questions: params.questions, response: null, cancelled: true } satisfies LocalResultDetails,
135
153
  };
136
154
  }
137
155
 
138
156
  return {
139
157
  content: [{ type: "text", text: formatForLLM(result) }],
140
- details: { questions: params.questions, response: result, cancelled: false } as AskUserQuestionsDetails,
158
+ details: { questions: params.questions, response: result, cancelled: false } satisfies LocalResultDetails,
141
159
  };
142
160
  },
143
161
 
@@ -171,13 +189,44 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
171
189
  return new Text(text?.type === "text" ? text.text : "", 0, 0);
172
190
  }
173
191
 
192
+ // Remote channel result (discriminated on details.remote === true)
193
+ if (details.remote) {
194
+ if (details.timed_out) {
195
+ return new Text(
196
+ `${theme.fg("warning", `${details.channel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`,
197
+ 0,
198
+ 0,
199
+ );
200
+ }
201
+
202
+ const questions = (details.questions ?? []) as Question[];
203
+ const lines: string[] = [];
204
+ lines.push(theme.fg("dim", details.channel));
205
+ if (details.response) {
206
+ for (const q of questions) {
207
+ const answer = details.response.answers[q.id];
208
+ if (!answer) {
209
+ lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`);
210
+ continue;
211
+ }
212
+ const answerText = answer.answers.length > 0 ? answer.answers.join(", ") : "(custom)";
213
+ let line = `${theme.fg("success", "✓ ")}${theme.fg("accent", q.header)}: ${answerText}`;
214
+ if (answer.user_note) {
215
+ line += ` ${theme.fg("muted", `[note: ${answer.user_note}]`)}`;
216
+ }
217
+ lines.push(line);
218
+ }
219
+ }
220
+ return new Text(lines.join("\n"), 0, 0);
221
+ }
222
+
174
223
  if (details.cancelled || !details.response) {
175
224
  return new Text(theme.fg("warning", "Cancelled"), 0, 0);
176
225
  }
177
226
 
178
227
  const lines: string[] = [];
179
228
  for (const q of details.questions) {
180
- const answer = details.response.answers[q.id];
229
+ const answer = (details.response as RoundResult).answers[q.id];
181
230
  if (!answer) {
182
231
  lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`);
183
232
  continue;
@@ -60,6 +60,8 @@ import { execSync } from "node:child_process";
60
60
  import {
61
61
  autoCommitCurrentBranch,
62
62
  ensureSliceBranch,
63
+ getCurrentBranch,
64
+ getSliceBranchName,
63
65
  switchToMain,
64
66
  mergeSliceToMain,
65
67
  } from "./worktree.ts";
@@ -247,6 +249,14 @@ export async function startAuto(
247
249
  if (!getLedger()) initMetrics(base);
248
250
  ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
249
251
  ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
252
+ // Rebuild disk state before resuming — user interaction during pause may have changed files
253
+ try { await rebuildState(base); } catch { /* non-fatal */ }
254
+ try {
255
+ const report = await runGSDDoctor(base, { fix: true });
256
+ if (report.fixesApplied.length > 0) {
257
+ ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info");
258
+ }
259
+ } catch { /* non-fatal */ }
250
260
  await dispatchNextUnit(ctx, pi);
251
261
  return;
252
262
  }
@@ -758,7 +768,12 @@ async function dispatchNextUnit(
758
768
  ctx: ExtensionContext,
759
769
  pi: ExtensionAPI,
760
770
  ): Promise<void> {
761
- if (!active || !cmdCtx) return;
771
+ if (!active || !cmdCtx) {
772
+ if (active && !cmdCtx) {
773
+ ctx.ui.notify("Auto-mode dispatch failed: no command context. Run /gsd auto to restart.", "error");
774
+ }
775
+ return;
776
+ }
762
777
 
763
778
  let state = await deriveState(basePath);
764
779
  let mid = state.activeMilestone?.id;
@@ -787,39 +802,53 @@ async function dispatchNextUnit(
787
802
  return;
788
803
  }
789
804
 
790
- // ── Post-completion merge: merge the slice branch after complete-slice finishes ──
791
- // The complete-slice unit writes the summary, UAT, marks roadmap [x], and commits.
792
- // Now we switch to main and squash-merge the slice branch.
793
- if (currentUnit?.type === "complete-slice") {
794
- try {
795
- const [completedMid, completedSid] = currentUnit.id.split("/");
796
- // Look up actual slice title from roadmap (on current branch, before switching)
797
- const roadmapFile = resolveMilestoneFile(basePath, completedMid!, "ROADMAP");
805
+ // ── General merge guard: merge completed slice branches before advancing ──
806
+ // If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]),
807
+ // merge to main before dispatching the next unit. This handles:
808
+ // - Normal complete-slice → merge → reassess flow
809
+ // - LLM writes summary during task execution, skipping complete-slice
810
+ // - Doctor post-hook marks everything done, skipping complete-slice
811
+ // - complete-milestone runs on a slice branch (last slice bypass)
812
+ {
813
+ const currentBranch = getCurrentBranch(basePath);
814
+ const branchMatch = currentBranch.match(/^gsd\/(M\d+)\/(S\d+)$/);
815
+ if (branchMatch) {
816
+ const branchMid = branchMatch[1]!;
817
+ const branchSid = branchMatch[2]!;
818
+ // Check if this slice is marked done in the roadmap
819
+ const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP");
798
820
  const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
799
- let sliceTitleForMerge = completedSid!;
800
821
  if (roadmapContent) {
801
822
  const roadmap = parseRoadmap(roadmapContent);
802
- const sliceEntry = roadmap.slices.find(s => s.id === completedSid);
803
- if (sliceEntry) sliceTitleForMerge = sliceEntry.title;
823
+ const sliceEntry = roadmap.slices.find(s => s.id === branchSid);
824
+ if (sliceEntry?.done) {
825
+ try {
826
+ const sliceTitleForMerge = sliceEntry.title || branchSid;
827
+ switchToMain(basePath);
828
+ const mergeResult = mergeSliceToMain(
829
+ basePath, branchMid, branchSid, sliceTitleForMerge,
830
+ );
831
+ ctx.ui.notify(
832
+ `Merged ${mergeResult.branch} → main.`,
833
+ "info",
834
+ );
835
+ // Re-derive state from main so downstream logic sees merged state
836
+ state = await deriveState(basePath);
837
+ mid = state.activeMilestone?.id;
838
+ midTitle = state.activeMilestone?.title;
839
+ } catch (error) {
840
+ const message = error instanceof Error ? error.message : String(error);
841
+ ctx.ui.notify(
842
+ `Slice merge failed: ${message}`,
843
+ "error",
844
+ );
845
+ // Re-derive state so dispatch can figure out what to do
846
+ state = await deriveState(basePath);
847
+ mid = state.activeMilestone?.id;
848
+ midTitle = state.activeMilestone?.title;
849
+ }
850
+ }
804
851
  }
805
- switchToMain(basePath);
806
- const mergeResult = mergeSliceToMain(
807
- basePath, completedMid!, completedSid!, sliceTitleForMerge,
808
- );
809
- ctx.ui.notify(
810
- `Merged ${mergeResult.branch} → main.`,
811
- "info",
812
- );
813
- } catch (error) {
814
- const message = error instanceof Error ? error.message : String(error);
815
- ctx.ui.notify(
816
- `Slice merge failed: ${message}`,
817
- "error",
818
- );
819
- // Re-derive state so dispatch can figure out what to do
820
- state = await deriveState(basePath);
821
- mid = state.activeMilestone?.id;
822
- midTitle = state.activeMilestone?.title;
823
852
  }
824
853
  }
825
854
 
@@ -1086,7 +1115,7 @@ async function dispatchNextUnit(
1086
1115
  const allModels = ctx.modelRegistry.getAll();
1087
1116
  const model = allModels.find(m => m.id === preferredModelId);
1088
1117
  if (model) {
1089
- const ok = await pi.setModel(model);
1118
+ const ok = await pi.setModel(model, { persist: false });
1090
1119
  if (ok) {
1091
1120
  ctx.ui.notify(`Model: ${preferredModelId}`, "info");
1092
1121
  }
@@ -1186,7 +1215,8 @@ async function dispatchNextUnit(
1186
1215
  await pauseAuto(ctx, pi);
1187
1216
  }, hardTimeoutMs);
1188
1217
 
1189
- // Inject prompt
1218
+ // Inject prompt — verify auto-mode still active (guards against race with timeout/pause)
1219
+ if (!active) return;
1190
1220
  pi.sendMessage(
1191
1221
  { customType: "gsd-auto", content: finalPrompt, display: verbose },
1192
1222
  { triggerTurn: true },
@@ -31,6 +31,7 @@ import {
31
31
  } from "./doctor.js";
32
32
  import { loadPrompt } from "./prompt-loader.js";
33
33
  import { handleMigrate } from "./migrate/command.js";
34
+ import { handleRemote } from "../remote-questions/remote-command.js";
34
35
 
35
36
  function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
36
37
  const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
@@ -52,10 +53,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
52
53
 
53
54
  export function registerGSDCommand(pi: ExtensionAPI): void {
54
55
  pi.registerCommand("gsd", {
55
- description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate",
56
+ description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate|remote",
56
57
 
57
58
  getArgumentCompletions: (prefix: string) => {
58
- const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"];
59
+ const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate", "remote"];
59
60
  const parts = prefix.trim().split(/\s+/);
60
61
 
61
62
  if (parts.length <= 1) {
@@ -78,6 +79,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
78
79
  .map((cmd) => ({ value: `prefs ${cmd}`, label: cmd }));
79
80
  }
80
81
 
82
+ if (parts[0] === "remote" && parts.length <= 2) {
83
+ const subPrefix = parts[1] ?? "";
84
+ return ["slack", "discord", "status", "disconnect"]
85
+ .filter((cmd) => cmd.startsWith(subPrefix))
86
+ .map((cmd) => ({ value: `remote ${cmd}`, label: cmd }));
87
+ }
88
+
81
89
  if (parts[0] === "doctor") {
82
90
  const modePrefix = parts[1] ?? "";
83
91
  const modes = ["fix", "heal", "audit"];
@@ -148,6 +156,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
148
156
  return;
149
157
  }
150
158
 
159
+ if (trimmed === "remote" || trimmed.startsWith("remote ")) {
160
+ await handleRemote(trimmed.replace(/^remote\s*/, "").trim(), ctx, pi);
161
+ return;
162
+ }
163
+
151
164
  if (trimmed === "") {
152
165
  // Bare /gsd defaults to step mode
153
166
  await startAuto(ctx, pi, process.cwd(), false, { step: true });
@@ -155,7 +168,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
155
168
  }
156
169
 
157
170
  ctx.ui.notify(
158
- `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate <path>.`,
171
+ `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
159
172
  "warning",
160
173
  );
161
174
  },
@@ -22,7 +22,7 @@ import type {
22
22
  ExtensionAPI,
23
23
  ExtensionContext,
24
24
  } from "@mariozechner/pi-coding-agent";
25
- import { createBashTool } from "@mariozechner/pi-coding-agent";
25
+ import { createBashTool, createWriteTool, createReadTool, createEditTool } from "@mariozechner/pi-coding-agent";
26
26
 
27
27
  import { registerGSDCommand } from "./commands.js";
28
28
  import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js";
@@ -102,7 +102,60 @@ export default function (pi: ExtensionAPI) {
102
102
  };
103
103
  pi.registerTool(dynamicBash as any);
104
104
 
105
- // ── session_start: render branded GSD header ───────────────────────────
105
+ // ── Dynamic-cwd file tools (write, read, edit) ────────────────────────
106
+ // The built-in file tools capture cwd at startup. When process.chdir()
107
+ // moves us into a worktree, relative paths still resolve against the
108
+ // original launch directory. These replacements delegate to freshly-
109
+ // created tools on each call so that process.cwd() is read dynamically.
110
+ const baseWrite = createWriteTool(process.cwd());
111
+ const dynamicWrite = {
112
+ ...baseWrite,
113
+ execute: async (
114
+ toolCallId: string,
115
+ params: { path: string; content: string },
116
+ signal?: AbortSignal,
117
+ onUpdate?: any,
118
+ ctx?: any,
119
+ ) => {
120
+ const fresh = createWriteTool(process.cwd());
121
+ return fresh.execute(toolCallId, params, signal, onUpdate, ctx);
122
+ },
123
+ };
124
+ pi.registerTool(dynamicWrite as any);
125
+
126
+ const baseRead = createReadTool(process.cwd());
127
+ const dynamicRead = {
128
+ ...baseRead,
129
+ execute: async (
130
+ toolCallId: string,
131
+ params: { path: string; offset?: number; limit?: number },
132
+ signal?: AbortSignal,
133
+ onUpdate?: any,
134
+ ctx?: any,
135
+ ) => {
136
+ const fresh = createReadTool(process.cwd());
137
+ return fresh.execute(toolCallId, params, signal, onUpdate, ctx);
138
+ },
139
+ };
140
+ pi.registerTool(dynamicRead as any);
141
+
142
+ const baseEdit = createEditTool(process.cwd());
143
+ const dynamicEdit = {
144
+ ...baseEdit,
145
+ execute: async (
146
+ toolCallId: string,
147
+ params: { path: string; oldText: string; newText: string },
148
+ signal?: AbortSignal,
149
+ onUpdate?: any,
150
+ ctx?: any,
151
+ ) => {
152
+ const fresh = createEditTool(process.cwd());
153
+ return fresh.execute(toolCallId, params, signal, onUpdate, ctx);
154
+ },
155
+ };
156
+ pi.registerTool(dynamicEdit as any);
157
+
158
+ // ── session_start: render branded GSD header + remote channel status ──
106
159
  pi.on("session_start", async (_event, ctx) => {
107
160
  const theme = ctx.ui.theme;
108
161
  const version = process.env.GSD_VERSION || "0.0.0";
@@ -112,6 +165,22 @@ export default function (pi: ExtensionAPI) {
112
165
 
113
166
  const headerContent = `${logoText}\n${titleLine}`;
114
167
  ctx.ui.setHeader((_ui, _theme) => new Text(headerContent, 1, 0));
168
+
169
+ // Notify remote questions status if configured
170
+ try {
171
+ const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([
172
+ import("../remote-questions/config.js"),
173
+ import("../remote-questions/status.js"),
174
+ ]);
175
+ const status = getRemoteConfigStatus();
176
+ const latest = getLatestPromptSummary();
177
+ if (!status.includes("not configured")) {
178
+ const suffix = latest ? `\nLast remote prompt: ${latest.id} (${latest.status})` : "";
179
+ ctx.ui.notify(`${status}${suffix}`, status.includes("disabled") ? "warning" : "info");
180
+ }
181
+ } catch {
182
+ // Remote questions module not available — ignore
183
+ }
115
184
  });
116
185
 
117
186
  // ── Ctrl+Alt+G shortcut — GSD dashboard overlay ────────────────────────
@@ -31,6 +31,13 @@ export interface AutoSupervisorConfig {
31
31
  hard_timeout_minutes?: number;
32
32
  }
33
33
 
34
+ export interface RemoteQuestionsConfig {
35
+ channel: "slack" | "discord";
36
+ channel_id: string | number;
37
+ timeout_minutes?: number; // clamped to 1-30
38
+ poll_interval_seconds?: number; // clamped to 2-30
39
+ }
40
+
34
41
  export interface GSDPreferences {
35
42
  version?: number;
36
43
  always_use_skills?: string[];
@@ -43,6 +50,7 @@ export interface GSDPreferences {
43
50
  auto_supervisor?: AutoSupervisorConfig;
44
51
  uat_dispatch?: boolean;
45
52
  budget_ceiling?: number;
53
+ remote_questions?: RemoteQuestionsConfig;
46
54
  }
47
55
 
48
56
  export interface LoadedGSDPreferences {
@@ -430,7 +438,12 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences {
430
438
  function parseScalar(value: string): string | number | boolean {
431
439
  if (value === "true") return true;
432
440
  if (value === "false") return false;
433
- if (/^-?\d+$/.test(value)) return Number(value);
441
+ if (/^-?\d+$/.test(value)) {
442
+ const n = Number(value);
443
+ // Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss
444
+ if (Number.isSafeInteger(n)) return n;
445
+ return value;
446
+ }
434
447
  return value.replace(/^['\"]|['\"]$/g, "");
435
448
  }
436
449
 
@@ -495,6 +508,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
495
508
  auto_supervisor: { ...(base.auto_supervisor ?? {}), ...(override.auto_supervisor ?? {}) },
496
509
  uat_dispatch: override.uat_dispatch ?? base.uat_dispatch,
497
510
  budget_ceiling: override.budget_ceiling ?? base.budget_ceiling,
511
+ remote_questions: override.remote_questions
512
+ ? { ...(base.remote_questions ?? {}), ...override.remote_questions }
513
+ : base.remote_questions,
498
514
  };
499
515
  }
500
516