gsd-pi 2.4.0 → 2.5.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.
Files changed (35) hide show
  1. package/README.md +4 -3
  2. package/dist/loader.js +21 -3
  3. package/dist/logo.d.ts +3 -3
  4. package/dist/logo.js +2 -2
  5. package/package.json +2 -2
  6. package/src/resources/GSD-WORKFLOW.md +7 -7
  7. package/src/resources/extensions/get-secrets-from-user.ts +63 -8
  8. package/src/resources/extensions/gsd/auto.ts +123 -34
  9. package/src/resources/extensions/gsd/docs/preferences-reference.md +28 -0
  10. package/src/resources/extensions/gsd/files.ts +70 -0
  11. package/src/resources/extensions/gsd/git-service.ts +151 -11
  12. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  13. package/src/resources/extensions/gsd/guided-flow.ts +6 -3
  14. package/src/resources/extensions/gsd/preferences.ts +59 -0
  15. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  16. package/src/resources/extensions/gsd/prompts/complete-slice.md +8 -6
  17. package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
  18. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -7
  19. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
  20. package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
  21. package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
  22. package/src/resources/extensions/gsd/prompts/replan-slice.md +1 -1
  23. package/src/resources/extensions/gsd/templates/plan.md +8 -10
  24. package/src/resources/extensions/gsd/templates/preferences.md +7 -0
  25. package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
  26. package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
  27. package/src/resources/extensions/gsd/tests/git-service.test.ts +421 -0
  28. package/src/resources/extensions/gsd/tests/parsers.test.ts +211 -65
  29. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +0 -2
  30. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
  31. package/src/resources/extensions/gsd/types.ts +20 -0
  32. package/src/resources/extensions/gsd/worktree-command.ts +48 -6
  33. package/src/resources/extensions/gsd/worktree.ts +40 -147
  34. package/src/resources/extensions/search-the-web/index.ts +16 -25
  35. package/src/resources/extensions/search-the-web/native-search.ts +157 -0
@@ -1,40 +1,50 @@
1
1
  /**
2
- * GSD Slice Branch Management
2
+ * GSD Slice Branch Management — Thin Facade
3
3
  *
4
4
  * Simple branch-per-slice workflow. No worktrees, no registry.
5
5
  * Runtime state (metrics, activity, lock, STATE.md) is gitignored
6
6
  * so branch switches are clean.
7
7
  *
8
+ * All git-mutation functions delegate to GitServiceImpl from git-service.ts.
9
+ * Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
10
+ * SLICE_BRANCH_RE) remain standalone.
11
+ *
8
12
  * Flow:
9
13
  * 1. ensureSliceBranch() — create + checkout slice branch
10
14
  * 2. agent does work, commits
11
15
  * 3. mergeSliceToMain() — checkout main, squash-merge, delete branch
12
16
  */
13
17
 
14
- import { existsSync } from "node:fs";
15
- import { execSync } from "node:child_process";
16
18
  import { sep } from "node:path";
17
19
 
18
- export interface MergeSliceResult {
19
- branch: string;
20
- mergedCommitMessage: string;
21
- deletedBranch: boolean;
22
- }
20
+ import { GitServiceImpl } from "./git-service.ts";
21
+ import { loadEffectiveGSDPreferences } from "./preferences.ts";
23
22
 
24
- function runGit(basePath: string, args: string[], options: { allowFailure?: boolean } = {}): string {
25
- try {
26
- return execSync(`git ${args.join(" ")}`, {
27
- cwd: basePath,
28
- stdio: ["ignore", "pipe", "pipe"],
29
- encoding: "utf-8",
30
- }).trim();
31
- } catch (error) {
32
- if (options.allowFailure) return "";
33
- const message = error instanceof Error ? error.message : String(error);
34
- throw new Error(`git ${args.join(" ")} failed in ${basePath}: ${message}`);
23
+ // Re-export MergeSliceResult from the canonical source (D014 type-only re-export)
24
+ export type { MergeSliceResult } from "./git-service.ts";
25
+
26
+ // ─── Lazy GitServiceImpl Cache ─────────────────────────────────────────────
27
+
28
+ let cachedService: GitServiceImpl | null = null;
29
+ let cachedBasePath: string | null = null;
30
+
31
+ /**
32
+ * Get or create a GitServiceImpl for the given basePath.
33
+ * Resets the cache if basePath changes between calls.
34
+ * Lazy construction: only instantiated at call-time, never at module-evaluation.
35
+ */
36
+ function getService(basePath: string): GitServiceImpl {
37
+ if (cachedService === null || cachedBasePath !== basePath) {
38
+ const loaded = loadEffectiveGSDPreferences();
39
+ const gitPrefs = loaded?.preferences?.git ?? {};
40
+ cachedService = new GitServiceImpl(basePath, gitPrefs);
41
+ cachedBasePath = basePath;
35
42
  }
43
+ return cachedService;
36
44
  }
37
45
 
46
+ // ─── Pure Utility Functions (unchanged) ────────────────────────────────────
47
+
38
48
  /**
39
49
  * Detect the active worktree name from the current working directory.
40
50
  * Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
@@ -86,6 +96,8 @@ export function parseSliceBranch(branchName: string): {
86
96
  };
87
97
  }
88
98
 
99
+ // ─── Git-Mutation Functions (delegate to GitServiceImpl) ───────────────────
100
+
89
101
  /**
90
102
  * Get the "main" branch for GSD slice operations.
91
103
  *
@@ -98,46 +110,11 @@ export function parseSliceBranch(branchName: string): {
98
110
  * /worktree merge.
99
111
  */
100
112
  export function getMainBranch(basePath: string): string {
101
- // When inside a worktree, slice branches should merge into the worktree's
102
- // own branch (worktree/<name>), not main — main is checked out by the
103
- // parent working tree and git would refuse the checkout.
104
- const wtName = detectWorktreeName(basePath);
105
- if (wtName) {
106
- const wtBranch = `worktree/${wtName}`;
107
- // Verify the branch exists (it should — createWorktree made it)
108
- const exists = runGit(basePath, ["show-ref", "--verify", `refs/heads/${wtBranch}`], { allowFailure: true });
109
- if (exists) return wtBranch;
110
- // Worktree branch is gone — return current branch rather than falling
111
- // through to main/master which would cause a checkout conflict
112
- return runGit(basePath, ["branch", "--show-current"]);
113
- }
114
-
115
- const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true });
116
- if (symbolic) {
117
- const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
118
- if (match) return match[1]!;
119
- }
120
-
121
- const mainExists = runGit(basePath, ["show-ref", "--verify", "refs/heads/main"], { allowFailure: true });
122
- if (mainExists) return "main";
123
-
124
- const masterExists = runGit(basePath, ["show-ref", "--verify", "refs/heads/master"], { allowFailure: true });
125
- if (masterExists) return "master";
126
-
127
- return runGit(basePath, ["branch", "--show-current"]);
113
+ return getService(basePath).getMainBranch();
128
114
  }
129
115
 
130
116
  export function getCurrentBranch(basePath: string): string {
131
- return runGit(basePath, ["branch", "--show-current"]);
132
- }
133
-
134
- function branchExists(basePath: string, branch: string): boolean {
135
- try {
136
- runGit(basePath, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
137
- return true;
138
- } catch {
139
- return false;
140
- }
117
+ return getService(basePath).getCurrentBranch();
141
118
  }
142
119
 
143
120
  /**
@@ -150,49 +127,7 @@ function branchExists(basePath: string, branch: string): boolean {
150
127
  * Returns true if the branch was newly created.
151
128
  */
152
129
  export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId: string): boolean {
153
- const wtName = detectWorktreeName(basePath);
154
- const branch = getSliceBranchName(milestoneId, sliceId, wtName);
155
- const current = getCurrentBranch(basePath);
156
-
157
- if (current === branch) return false;
158
-
159
- let created = false;
160
-
161
- if (!branchExists(basePath, branch)) {
162
- // Branch from the current branch when it's a normal working branch
163
- // (not itself a slice branch). This ensures the new slice branch
164
- // inherits planning artifacts that may only exist on the working
165
- // branch and haven't been merged to main yet.
166
- // If we're already on a slice branch (e.g. creating S02 while S01
167
- // wasn't merged yet), fall back to main to avoid chaining slice branches.
168
- const mainBranch = getMainBranch(basePath);
169
- const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current;
170
- runGit(basePath, ["branch", branch, base]);
171
- created = true;
172
- } else {
173
- // Check if the branch is already checked out in another worktree
174
- const worktreeList = runGit(basePath, ["worktree", "list", "--porcelain"]);
175
- if (worktreeList.includes(`branch refs/heads/${branch}`)) {
176
- throw new Error(
177
- `Branch "${branch}" is already in use by another worktree. ` +
178
- `Remove that worktree first, or switch it to a different branch.`,
179
- );
180
- }
181
- }
182
-
183
- // Auto-commit dirty files before checkout to prevent "would be overwritten" errors.
184
- // This handles cases where doctor, STATE.md rebuild, or agent work left uncommitted changes.
185
- const status = runGit(basePath, ["status", "--short"]);
186
- if (status.trim()) {
187
- runGit(basePath, ["add", "-A"]);
188
- const staged = runGit(basePath, ["diff", "--cached", "--stat"]);
189
- if (staged.trim()) {
190
- runGit(basePath, ["commit", "-m", `"chore: auto-commit before switching to ${branch}"`]);
191
- }
192
- }
193
-
194
- runGit(basePath, ["checkout", branch]);
195
- return created;
130
+ return getService(basePath).ensureSliceBranch(milestoneId, sliceId);
196
131
  }
197
132
 
198
133
  /**
@@ -202,31 +137,14 @@ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId
202
137
  export function autoCommitCurrentBranch(
203
138
  basePath: string, unitType: string, unitId: string,
204
139
  ): string | null {
205
- const status = runGit(basePath, ["status", "--short"]);
206
- if (!status.trim()) return null;
207
-
208
- runGit(basePath, ["add", "-A"]);
209
-
210
- const staged = runGit(basePath, ["diff", "--cached", "--stat"]);
211
- if (!staged.trim()) return null;
212
-
213
- const message = `chore(${unitId}): auto-commit after ${unitType}`;
214
- runGit(basePath, ["commit", "-m", JSON.stringify(message)]);
215
- return message;
140
+ return getService(basePath).autoCommit(unitType, unitId);
216
141
  }
217
142
 
218
143
  /**
219
144
  * Switch to main, auto-committing any dirty files on the current branch first.
220
145
  */
221
146
  export function switchToMain(basePath: string): void {
222
- const mainBranch = getMainBranch(basePath);
223
- const current = getCurrentBranch(basePath);
224
- if (current === mainBranch) return;
225
-
226
- // Auto-commit if dirty
227
- autoCommitCurrentBranch(basePath, "pre-switch", current);
228
-
229
- runGit(basePath, ["checkout", mainBranch]);
147
+ getService(basePath).switchToMain();
230
148
  }
231
149
 
232
150
  /**
@@ -236,37 +154,12 @@ export function switchToMain(basePath: string): void {
236
154
  */
237
155
  export function mergeSliceToMain(
238
156
  basePath: string, milestoneId: string, sliceId: string, sliceTitle: string,
239
- ): MergeSliceResult {
240
- const wtName = detectWorktreeName(basePath);
241
- const branch = getSliceBranchName(milestoneId, sliceId, wtName);
242
- const mainBranch = getMainBranch(basePath);
243
-
244
- const current = getCurrentBranch(basePath);
245
- if (current !== mainBranch) {
246
- throw new Error(`Expected to be on ${mainBranch}, found ${current}`);
247
- }
248
-
249
- if (!branchExists(basePath, branch)) {
250
- throw new Error(`Slice branch ${branch} does not exist`);
251
- }
252
-
253
- const ahead = runGit(basePath, ["rev-list", "--count", `${mainBranch}..${branch}`]);
254
- if (Number(ahead) <= 0) {
255
- throw new Error(`Slice branch ${branch} has no commits ahead of ${mainBranch}`);
256
- }
257
-
258
- runGit(basePath, ["merge", "--squash", branch]);
259
- const mergedCommitMessage = `feat(${milestoneId}/${sliceId}): ${sliceTitle}`;
260
- runGit(basePath, ["commit", "-m", JSON.stringify(mergedCommitMessage)]);
261
- runGit(basePath, ["branch", "-D", branch]);
262
-
263
- return {
264
- branch,
265
- mergedCommitMessage,
266
- deletedBranch: true,
267
- };
157
+ ): import("./git-service.ts").MergeSliceResult {
158
+ return getService(basePath).mergeSliceToMain(milestoneId, sliceId, sliceTitle);
268
159
  }
269
160
 
161
+ // ─── Query Functions (delegate to GitServiceImpl) ──────────────────────────
162
+
270
163
  /**
271
164
  * Check if we're currently on a slice branch (not main).
272
165
  * Handles both plain (gsd/M001/S01) and worktree-namespaced (gsd/wt/M001/S01) branches.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Web Search Extension v3
2
+ * Web Search Extension v4
3
3
  *
4
4
  * Provides three tools for grounding the agent in real-world web content:
5
5
  *
@@ -15,6 +15,15 @@
15
15
  * Returns pre-extracted, relevance-scored page content.
16
16
  * Best when you need content, not just links.
17
17
  *
18
+ * v4: Native Anthropic web search
19
+ * - When using an Anthropic provider, injects the native `web_search_20250305`
20
+ * server-side tool via `before_provider_request`. This eliminates the need for
21
+ * a BRAVE_API_KEY when using Anthropic models — search is billed through the
22
+ * existing Anthropic API key ($0.01/search).
23
+ * - Custom Brave-based tools (search-the-web, search_and_read) are disabled when
24
+ * Anthropic + no BRAVE_API_KEY to avoid confusing the LLM with broken tools.
25
+ * - fetch_page (Jina) remains available — it works without a key at lower rate limits.
26
+ *
18
27
  * v3 improvements over v2:
19
28
  * - search_and_read: New tool — Brave LLM Context API (search + read in one call)
20
29
  * - Structured error taxonomy: auth_error, rate_limited, network_error, etc.
@@ -30,7 +39,8 @@
30
39
  * - Cache timer cleanup: purge timers use unref() to not block process exit
31
40
  *
32
41
  * Environment variables:
33
- * BRAVE_API_KEY — Required for search. Get one at brave.com/search/api
42
+ * BRAVE_API_KEY — Optional with Anthropic models (built-in search available).
43
+ * Required for non-Anthropic providers. Get one at brave.com/search/api
34
44
  * JINA_API_KEY — Optional. Higher rate limits for page extraction.
35
45
  */
36
46
 
@@ -39,36 +49,17 @@ import { registerSearchTool } from "./tool-search";
39
49
  import { registerFetchPageTool } from "./tool-fetch-page";
40
50
  import { registerLLMContextTool } from "./tool-llm-context";
41
51
  import { registerSearchProviderCommand } from "./command-search-provider.ts";
52
+ import { registerNativeSearchHooks } from "./native-search";
42
53
 
43
54
  export default function (pi: ExtensionAPI) {
44
- // Register all tools
45
55
  registerSearchTool(pi);
46
56
  registerFetchPageTool(pi);
47
57
  registerLLMContextTool(pi);
48
58
 
59
+
49
60
  // Register slash commands
50
61
  registerSearchProviderCommand(pi);
51
62
 
52
- // Startup diagnostics
53
- pi.on("session_start", async (_event, ctx) => {
54
- const hasBrave = !!process.env.BRAVE_API_KEY;
55
- const hasTavily = !!process.env.TAVILY_API_KEY;
56
- const hasJina = !!process.env.JINA_API_KEY;
57
- const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY;
58
-
59
- if (!hasBrave && !hasTavily) {
60
- ctx.ui.notify(
61
- "Web search: Set BRAVE_API_KEY or TAVILY_API_KEY for web search capability",
62
- "warning"
63
- );
64
- }
65
-
66
- const parts: string[] = ["Web search v3"];
67
- if (hasTavily) parts.push("Tavily ✓");
68
- if (hasBrave) parts.push("Search ✓");
69
- if (hasAnswers) parts.push("Answers ✓");
70
- if (hasJina) parts.push("Jina ✓");
71
-
72
- ctx.ui.notify(parts.join(" · "), "info");
73
- });
63
+ // Register native Anthropic web search hooks
64
+ registerNativeSearchHooks(pi);
74
65
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Native Anthropic web search hook logic.
3
+ *
4
+ * Extracted from index.ts so it can be unit-tested without importing
5
+ * the heavy tool-registration modules.
6
+ */
7
+
8
+ /** Tool names for the Brave-backed custom search tools */
9
+ export const BRAVE_TOOL_NAMES = ["search-the-web", "search_and_read"];
10
+
11
+ /** Thinking block types that require signature validation by the API */
12
+ const THINKING_TYPES = new Set(["thinking", "redacted_thinking"]);
13
+
14
+ /** Minimal interface matching the subset of ExtensionAPI we use */
15
+ export interface NativeSearchPI {
16
+ on(event: string, handler: (...args: any[]) => any): void;
17
+ getActiveTools(): string[];
18
+ setActiveTools(tools: string[]): void;
19
+ }
20
+
21
+ /**
22
+ * Strip thinking/redacted_thinking blocks from assistant messages in the
23
+ * conversation history.
24
+ *
25
+ * Why: The Pi SDK's streaming parser drops `server_tool_use` and
26
+ * `web_search_tool_result` content blocks (unknown types). When the
27
+ * conversation is replayed, the assistant messages are incomplete — missing
28
+ * those blocks. The Anthropic API detects the modification and rejects the
29
+ * request with "thinking blocks cannot be modified."
30
+ *
31
+ * Fix: Remove thinking blocks from all assistant messages in the history.
32
+ * In Anthropic's Messages API, the messages array always ends with a user
33
+ * message, so every assistant message is from a previous turn that has been
34
+ * through a store/replay cycle. The model generates fresh thinking for the
35
+ * current turn regardless.
36
+ */
37
+ export function stripThinkingFromHistory(
38
+ messages: Array<Record<string, unknown>>
39
+ ): void {
40
+ for (const msg of messages) {
41
+ if (msg.role !== "assistant") continue;
42
+
43
+ const content = msg.content;
44
+ if (!Array.isArray(content)) continue;
45
+
46
+ msg.content = content.filter(
47
+ (block: any) => !THINKING_TYPES.has(block?.type)
48
+ );
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Register model_select, before_provider_request, and session_start hooks
54
+ * for native Anthropic web search injection.
55
+ *
56
+ * Returns the isAnthropicProvider getter for testing.
57
+ */
58
+ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: () => boolean } {
59
+ let isAnthropicProvider = false;
60
+
61
+ // Track provider changes via model selection — also handles diagnostics
62
+ // since model_select fires AFTER session_start and knows the provider.
63
+ pi.on("model_select", async (event: any, ctx: any) => {
64
+ const wasAnthropic = isAnthropicProvider;
65
+ isAnthropicProvider = event.model.provider === "anthropic";
66
+
67
+ const hasBrave = !!process.env.BRAVE_API_KEY;
68
+
69
+ // When Anthropic + no Brave key: disable custom search tools (they'd fail)
70
+ if (isAnthropicProvider && !hasBrave) {
71
+ const active = pi.getActiveTools();
72
+ pi.setActiveTools(
73
+ active.filter((t: string) => !BRAVE_TOOL_NAMES.includes(t))
74
+ );
75
+ } else if (!isAnthropicProvider && wasAnthropic && !hasBrave) {
76
+ // Switching away from Anthropic without Brave — re-enable so the user
77
+ // sees the "missing key" error rather than tools silently vanishing.
78
+ // Only add tools not already active to avoid duplicates on repeated toggles.
79
+ const active = pi.getActiveTools();
80
+ const toAdd = BRAVE_TOOL_NAMES.filter((t) => !active.includes(t));
81
+ if (toAdd.length > 0) {
82
+ pi.setActiveTools([...active, ...toAdd]);
83
+ }
84
+ }
85
+
86
+ // Show provider-aware diagnostics on first selection or provider change
87
+ if (isAnthropicProvider && !wasAnthropic && event.source !== "restore") {
88
+ ctx.ui.notify("Native Anthropic web search active", "info");
89
+ } else if (!isAnthropicProvider && !hasBrave) {
90
+ ctx.ui.notify(
91
+ "Web search: Set BRAVE_API_KEY or use an Anthropic model for built-in search",
92
+ "warning"
93
+ );
94
+ }
95
+ });
96
+
97
+ // Inject native web search into Anthropic API requests
98
+ pi.on("before_provider_request", (event: any) => {
99
+ const payload = event.payload as Record<string, unknown>;
100
+ if (!payload) return;
101
+
102
+ // Detect Anthropic by model name prefix (works even before model_select fires)
103
+ const model = payload.model as string | undefined;
104
+ if (!model || !model.startsWith("claude")) return;
105
+
106
+ // Keep provider tracking in sync
107
+ isAnthropicProvider = true;
108
+
109
+ // Strip thinking blocks from history to avoid signature validation errors
110
+ // caused by the SDK dropping server_tool_use/web_search_tool_result blocks.
111
+ const messages = payload.messages as Array<Record<string, unknown>> | undefined;
112
+ if (Array.isArray(messages)) {
113
+ stripThinkingFromHistory(messages);
114
+ }
115
+
116
+ if (!Array.isArray(payload.tools)) payload.tools = [];
117
+
118
+ let tools = payload.tools as Array<Record<string, unknown>>;
119
+
120
+ // Don't double-inject if already present
121
+ if (tools.some((t) => t.type === "web_search_20250305")) return;
122
+
123
+ // When no Brave key, remove Brave-based search tool definitions from the
124
+ // payload so Claude doesn't see (and try to call) broken tools.
125
+ // This is more reliable than setActiveTools since model_select may not fire.
126
+ const hasBrave = !!process.env.BRAVE_API_KEY;
127
+ if (!hasBrave) {
128
+ tools = tools.filter(
129
+ (t) => !BRAVE_TOOL_NAMES.includes(t.name as string)
130
+ );
131
+ payload.tools = tools;
132
+ }
133
+
134
+ tools.push({
135
+ type: "web_search_20250305",
136
+ name: "web_search",
137
+ });
138
+
139
+ return payload;
140
+ });
141
+
142
+ // Basic startup diagnostics — provider-specific info comes from model_select
143
+ pi.on("session_start", async (_event: any, ctx: any) => {
144
+ const hasBrave = !!process.env.BRAVE_API_KEY;
145
+ const hasJina = !!process.env.JINA_API_KEY;
146
+ const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY;
147
+
148
+ const parts: string[] = ["Web search v4 loaded"];
149
+ if (hasBrave) parts.push("Brave ✓");
150
+ if (hasAnswers) parts.push("Answers ✓");
151
+ if (hasJina) parts.push("Jina ✓");
152
+
153
+ ctx.ui.notify(parts.join(" · "), "info");
154
+ });
155
+
156
+ return { getIsAnthropic: () => isAnthropicProvider };
157
+ }