gsd-pi 2.3.6 → 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.
- package/dist/wizard.js +16 -0
- package/package.json +1 -1
- package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +60 -0
- package/src/resources/extensions/ask-user-questions.ts +54 -5
- package/src/resources/extensions/gsd/auto.ts +17 -3
- package/src/resources/extensions/gsd/commands.ts +16 -3
- package/src/resources/extensions/gsd/index.ts +17 -1
- package/src/resources/extensions/gsd/preferences.ts +17 -1
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
- package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
- package/src/resources/extensions/remote-questions/config.ts +81 -0
- package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
- package/src/resources/extensions/remote-questions/format.ts +163 -0
- package/src/resources/extensions/remote-questions/manager.ts +192 -0
- package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
- package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
- package/src/resources/extensions/remote-questions/status.ts +31 -0
- package/src/resources/extensions/remote-questions/store.ts +77 -0
- package/src/resources/extensions/remote-questions/types.ts +75 -0
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,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
|
|
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,
|
|
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 }
|
|
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 }
|
|
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;
|
|
@@ -247,6 +247,14 @@ export async function startAuto(
|
|
|
247
247
|
if (!getLedger()) initMetrics(base);
|
|
248
248
|
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
|
|
249
249
|
ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
|
|
250
|
+
// Rebuild disk state before resuming — user interaction during pause may have changed files
|
|
251
|
+
try { await rebuildState(base); } catch { /* non-fatal */ }
|
|
252
|
+
try {
|
|
253
|
+
const report = await runGSDDoctor(base, { fix: true });
|
|
254
|
+
if (report.fixesApplied.length > 0) {
|
|
255
|
+
ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info");
|
|
256
|
+
}
|
|
257
|
+
} catch { /* non-fatal */ }
|
|
250
258
|
await dispatchNextUnit(ctx, pi);
|
|
251
259
|
return;
|
|
252
260
|
}
|
|
@@ -758,7 +766,12 @@ async function dispatchNextUnit(
|
|
|
758
766
|
ctx: ExtensionContext,
|
|
759
767
|
pi: ExtensionAPI,
|
|
760
768
|
): Promise<void> {
|
|
761
|
-
if (!active || !cmdCtx)
|
|
769
|
+
if (!active || !cmdCtx) {
|
|
770
|
+
if (active && !cmdCtx) {
|
|
771
|
+
ctx.ui.notify("Auto-mode dispatch failed: no command context. Run /gsd auto to restart.", "error");
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
762
775
|
|
|
763
776
|
let state = await deriveState(basePath);
|
|
764
777
|
let mid = state.activeMilestone?.id;
|
|
@@ -1086,7 +1099,7 @@ async function dispatchNextUnit(
|
|
|
1086
1099
|
const allModels = ctx.modelRegistry.getAll();
|
|
1087
1100
|
const model = allModels.find(m => m.id === preferredModelId);
|
|
1088
1101
|
if (model) {
|
|
1089
|
-
const ok = await pi.setModel(model);
|
|
1102
|
+
const ok = await pi.setModel(model, { persist: false });
|
|
1090
1103
|
if (ok) {
|
|
1091
1104
|
ctx.ui.notify(`Model: ${preferredModelId}`, "info");
|
|
1092
1105
|
}
|
|
@@ -1186,7 +1199,8 @@ async function dispatchNextUnit(
|
|
|
1186
1199
|
await pauseAuto(ctx, pi);
|
|
1187
1200
|
}, hardTimeoutMs);
|
|
1188
1201
|
|
|
1189
|
-
// Inject prompt
|
|
1202
|
+
// Inject prompt — verify auto-mode still active (guards against race with timeout/pause)
|
|
1203
|
+
if (!active) return;
|
|
1190
1204
|
pi.sendMessage(
|
|
1191
1205
|
{ customType: "gsd-auto", content: finalPrompt, display: verbose },
|
|
1192
1206
|
{ 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##],
|
|
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
|
},
|
|
@@ -102,7 +102,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
102
102
|
};
|
|
103
103
|
pi.registerTool(dynamicBash as any);
|
|
104
104
|
|
|
105
|
-
// ── session_start: render branded GSD header
|
|
105
|
+
// ── session_start: render branded GSD header + remote channel status ──
|
|
106
106
|
pi.on("session_start", async (_event, ctx) => {
|
|
107
107
|
const theme = ctx.ui.theme;
|
|
108
108
|
const version = process.env.GSD_VERSION || "0.0.0";
|
|
@@ -112,6 +112,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
112
112
|
|
|
113
113
|
const headerContent = `${logoText}\n${titleLine}`;
|
|
114
114
|
ctx.ui.setHeader((_ui, _theme) => new Text(headerContent, 1, 0));
|
|
115
|
+
|
|
116
|
+
// Notify remote questions status if configured
|
|
117
|
+
try {
|
|
118
|
+
const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([
|
|
119
|
+
import("../remote-questions/config.js"),
|
|
120
|
+
import("../remote-questions/status.js"),
|
|
121
|
+
]);
|
|
122
|
+
const status = getRemoteConfigStatus();
|
|
123
|
+
const latest = getLatestPromptSummary();
|
|
124
|
+
if (!status.includes("not configured")) {
|
|
125
|
+
const suffix = latest ? `\nLast remote prompt: ${latest.id} (${latest.status})` : "";
|
|
126
|
+
ctx.ui.notify(`${status}${suffix}`, status.includes("disabled") ? "warning" : "info");
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Remote questions module not available — ignore
|
|
130
|
+
}
|
|
115
131
|
});
|
|
116
132
|
|
|
117
133
|
// ── 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))
|
|
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
|
|
|
@@ -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
|
+
}));
|