gsd-pi 2.5.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 (25) hide show
  1. package/dist/loader.js +21 -3
  2. package/dist/logo.d.ts +3 -3
  3. package/dist/logo.js +2 -2
  4. package/package.json +1 -1
  5. package/src/resources/extensions/get-secrets-from-user.ts +63 -8
  6. package/src/resources/extensions/gsd/auto.ts +45 -11
  7. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  8. package/src/resources/extensions/gsd/files.ts +70 -0
  9. package/src/resources/extensions/gsd/git-service.ts +27 -106
  10. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  11. package/src/resources/extensions/gsd/guided-flow.ts +6 -3
  12. package/src/resources/extensions/gsd/preferences.ts +8 -0
  13. package/src/resources/extensions/gsd/prompts/complete-slice.md +7 -5
  14. package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
  15. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -6
  16. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
  17. package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
  18. package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
  19. package/src/resources/extensions/gsd/templates/plan.md +8 -10
  20. package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
  21. package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
  22. package/src/resources/extensions/gsd/tests/git-service.test.ts +63 -0
  23. package/src/resources/extensions/gsd/tests/parsers.test.ts +211 -65
  24. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
  25. package/src/resources/extensions/gsd/types.ts +20 -0
package/dist/loader.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from 'url';
3
3
  import { dirname, resolve, join } from 'path';
4
- import { readFileSync } from 'fs';
5
- import { agentDir } from './app-paths.js';
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { agentDir, appRoot } from './app-paths.js';
6
+ import { renderLogo } from './logo.js';
6
7
  // pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's
7
8
  // theme assets (dist/modes/interactive/theme/) without a src/ directory.
8
9
  // This allows config.js to:
@@ -14,7 +15,24 @@ const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'pkg');
14
15
  process.env.PI_PACKAGE_DIR = pkgDir;
15
16
  process.env.PI_SKIP_VERSION_CHECK = '1'; // GSD ships its own update check — suppress pi's
16
17
  process.title = 'gsd';
17
- // First-launch branding is handled by the onboarding wizard (src/onboarding.ts)
18
+ // Print branded banner on first launch (before ~/.gsd/ exists)
19
+ if (!existsSync(appRoot)) {
20
+ const cyan = '\x1b[36m';
21
+ const green = '\x1b[32m';
22
+ const dim = '\x1b[2m';
23
+ const reset = '\x1b[0m';
24
+ const colorCyan = (s) => `${cyan}${s}${reset}`;
25
+ let version = '';
26
+ try {
27
+ const pkgJson = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
28
+ version = pkgJson.version ?? '';
29
+ }
30
+ catch { /* ignore */ }
31
+ process.stderr.write(renderLogo(colorCyan) +
32
+ '\n' +
33
+ ` Get Shit Done ${dim}v${version}${reset}\n` +
34
+ ` ${green}Welcome.${reset} Setting up your environment...\n\n`);
35
+ }
18
36
  // GSD_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.gsd/agent/ instead of ~/.gsd/agent/
19
37
  process.env.GSD_CODING_AGENT_DIR = agentDir;
20
38
  // NODE_PATH — make gsd's own node_modules available to extensions loaded via jiti.
package/dist/logo.d.ts CHANGED
@@ -3,14 +3,14 @@
3
3
  *
4
4
  * Single source of truth — imported by:
5
5
  * - scripts/postinstall.js (via dist/logo.js)
6
- * - src/onboarding.ts (via ./logo.js)
6
+ * - src/loader.ts (via ./logo.js)
7
7
  */
8
8
  /** Raw logo lines — no ANSI codes, no leading newline. */
9
- export declare const GSD_LOGO: string[];
9
+ export declare const GSD_LOGO: readonly string[];
10
10
  /**
11
11
  * Render the logo block with a color function applied to each line.
12
12
  *
13
- * @param color — e.g. picocolors.cyan or `(s) => `\x1b[36m${s}\x1b[0m``
13
+ * @param color — e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or picocolors.cyan
14
14
  * @returns Ready-to-write string with leading/trailing newlines.
15
15
  */
16
16
  export declare function renderLogo(color: (s: string) => string): string;
package/dist/logo.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Single source of truth — imported by:
5
5
  * - scripts/postinstall.js (via dist/logo.js)
6
- * - src/onboarding.ts (via ./logo.js)
6
+ * - src/loader.ts (via ./logo.js)
7
7
  */
8
8
  /** Raw logo lines — no ANSI codes, no leading newline. */
9
9
  export const GSD_LOGO = [
@@ -17,7 +17,7 @@ export const GSD_LOGO = [
17
17
  /**
18
18
  * Render the logo block with a color function applied to each line.
19
19
  *
20
- * @param color — e.g. picocolors.cyan or `(s) => `\x1b[36m${s}\x1b[0m``
20
+ * @param color — e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or picocolors.cyan
21
21
  * @returns Ready-to-write string with leading/trailing newlines.
22
22
  */
23
23
  export function renderLogo(color) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { readFile, writeFile } from "node:fs/promises";
10
+ import { existsSync, statSync } from "node:fs";
10
11
  import { resolve } from "node:path";
11
12
 
12
13
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -25,6 +26,8 @@ interface ToolResultDetails {
25
26
  environment?: string;
26
27
  applied: string[];
27
28
  skipped: string[];
29
+ existingSkipped?: string[];
30
+ detectedDestination?: string;
28
31
  }
29
32
 
30
33
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -91,6 +94,52 @@ async function writeEnvKey(filePath: string, key: string, value: string): Promis
91
94
  await writeFile(filePath, content, "utf8");
92
95
  }
93
96
 
97
+ // ─── Exported utilities ───────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Check which keys already exist in the .env file or process.env.
101
+ * Returns the subset of `keys` that are already set.
102
+ * Handles ENOENT gracefully (still checks process.env).
103
+ * Empty-string values count as existing.
104
+ */
105
+ export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise<string[]> {
106
+ let fileContent = "";
107
+ try {
108
+ fileContent = await readFile(envFilePath, "utf8");
109
+ } catch {
110
+ // ENOENT or other read error — proceed with empty content
111
+ }
112
+
113
+ const existing: string[] = [];
114
+ for (const key of keys) {
115
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
116
+ const regex = new RegExp(`^${escaped}\\s*=`, "m");
117
+ if (regex.test(fileContent) || key in process.env) {
118
+ existing.push(key);
119
+ }
120
+ }
121
+ return existing;
122
+ }
123
+
124
+ /**
125
+ * Detect the write destination based on project files in basePath.
126
+ * Priority: vercel.json → convex/ dir → fallback "dotenv".
127
+ */
128
+ export function detectDestination(basePath: string): "dotenv" | "vercel" | "convex" {
129
+ if (existsSync(resolve(basePath, "vercel.json"))) {
130
+ return "vercel";
131
+ }
132
+ const convexPath = resolve(basePath, "convex");
133
+ try {
134
+ if (existsSync(convexPath) && statSync(convexPath).isDirectory()) {
135
+ return "convex";
136
+ }
137
+ } catch {
138
+ // stat error — treat as not found
139
+ }
140
+ return "dotenv";
141
+ }
142
+
94
143
  // ─── Paged secure input UI ────────────────────────────────────────────────────
95
144
 
96
145
  /**
@@ -209,16 +258,17 @@ export default function secureEnv(pi: ExtensionAPI) {
209
258
  "Never echo, log, or repeat secret values in your responses. Only report key names and applied/skipped status.",
210
259
  ],
211
260
  parameters: Type.Object({
212
- destination: Type.Union([
261
+ destination: Type.Optional(Type.Union([
213
262
  Type.Literal("dotenv"),
214
263
  Type.Literal("vercel"),
215
264
  Type.Literal("convex"),
216
- ], { description: "Where to write the collected secrets" }),
265
+ ], { description: "Where to write the collected secrets" })),
217
266
  keys: Type.Array(
218
267
  Type.Object({
219
268
  key: Type.String({ description: "Env var name, e.g. OPENAI_API_KEY" }),
220
269
  hint: Type.Optional(Type.String({ description: "Format hint shown to user, e.g. 'starts with sk-'" })),
221
270
  required: Type.Optional(Type.Boolean()),
271
+ guidance: Type.Optional(Type.Array(Type.String(), { description: "Step-by-step guidance for finding this key" })),
222
272
  }),
223
273
  { minItems: 1 },
224
274
  ),
@@ -240,6 +290,10 @@ export default function secureEnv(pi: ExtensionAPI) {
240
290
  };
241
291
  }
242
292
 
293
+ // Auto-detect destination when not provided
294
+ const destinationAutoDetected = params.destination == null;
295
+ const destination = params.destination ?? detectDestination(ctx.cwd);
296
+
243
297
  const collected: CollectedSecret[] = [];
244
298
 
245
299
  // Collect one key per page
@@ -255,7 +309,7 @@ export default function secureEnv(pi: ExtensionAPI) {
255
309
  const errors: string[] = [];
256
310
 
257
311
  // Apply to destination
258
- if (params.destination === "dotenv") {
312
+ if (destination === "dotenv") {
259
313
  const filePath = resolve(ctx.cwd, params.envFilePath ?? ".env");
260
314
  for (const { key, value } of provided) {
261
315
  try {
@@ -267,7 +321,7 @@ export default function secureEnv(pi: ExtensionAPI) {
267
321
  }
268
322
  }
269
323
 
270
- if (params.destination === "vercel") {
324
+ if (destination === "vercel") {
271
325
  const env = params.environment ?? "development";
272
326
  for (const { key, value } of provided) {
273
327
  try {
@@ -286,7 +340,7 @@ export default function secureEnv(pi: ExtensionAPI) {
286
340
  }
287
341
  }
288
342
 
289
- if (params.destination === "convex") {
343
+ if (destination === "convex") {
290
344
  for (const { key, value } of provided) {
291
345
  try {
292
346
  const result = await pi.exec("sh", [
@@ -305,14 +359,15 @@ export default function secureEnv(pi: ExtensionAPI) {
305
359
  }
306
360
 
307
361
  const details: ToolResultDetails = {
308
- destination: params.destination,
362
+ destination,
309
363
  environment: params.environment,
310
364
  applied,
311
365
  skipped,
366
+ ...(destinationAutoDetected ? { detectedDestination: destination } : {}),
312
367
  };
313
368
 
314
369
  const lines = [
315
- `destination: ${params.destination}${params.environment ? ` (${params.environment})` : ""}`,
370
+ `destination: ${destination}${destinationAutoDetected ? " (auto-detected)" : ""}${params.environment ? ` (${params.environment})` : ""}`,
316
371
  ...applied.map((k) => `✓ ${k}: applied`),
317
372
  ...skipped.map((k) => `• ${k}: skipped`),
318
373
  ...errors.map((e) => `✗ ${e}`),
@@ -329,7 +384,7 @@ export default function secureEnv(pi: ExtensionAPI) {
329
384
  const count = Array.isArray(args.keys) ? args.keys.length : 0;
330
385
  return new Text(
331
386
  theme.fg("toolTitle", theme.bold("secure_env_collect ")) +
332
- theme.fg("muted", `→ ${args.destination}`) +
387
+ theme.fg("muted", `→ ${args.destination ?? "auto"}`) +
333
388
  theme.fg("dim", ` ${count} key${count !== 1 ? "s" : ""}`),
334
389
  0, 0,
335
390
  );
@@ -56,7 +56,7 @@ import {
56
56
  } from "./metrics.js";
57
57
  import { join } from "node:path";
58
58
  import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
59
- import { execSync } from "node:child_process";
59
+ import { execSync, execFileSync } from "node:child_process";
60
60
  import {
61
61
  autoCommitCurrentBranch,
62
62
  ensureSliceBranch,
@@ -373,7 +373,8 @@ export async function startAuto(
373
373
  try {
374
374
  execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
375
375
  } catch {
376
- execSync("git init", { cwd: base, stdio: "pipe" });
376
+ const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
377
+ execFileSync("git", ["init", "-b", mainBranch], { cwd: base, stdio: "pipe" });
377
378
  }
378
379
 
379
380
  // Ensure .gitignore has baseline patterns
@@ -675,7 +676,7 @@ function peekNext(unitType: string, state: GSDState): string {
675
676
  const sid = state.activeSlice?.id ?? "";
676
677
  switch (unitType) {
677
678
  case "research-milestone": return "plan milestone roadmap";
678
- case "plan-milestone": return "research first slice";
679
+ case "plan-milestone": return "plan or execute first slice";
679
680
  case "research-slice": return `plan ${sid}`;
680
681
  case "plan-slice": return "execute first task";
681
682
  case "execute-task": return `continue ${sid}`;
@@ -1022,14 +1023,35 @@ async function dispatchNextUnit(
1022
1023
  midTitle = state.activeMilestone?.title;
1023
1024
  } catch (error) {
1024
1025
  const message = error instanceof Error ? error.message : String(error);
1026
+
1027
+ // Safety net: if mergeSliceToMain failed to clean up (or the error
1028
+ // came from switchToMain), ensure the working tree isn't left in a
1029
+ // conflicted/dirty merge state. Without this, state derivation reads
1030
+ // conflict-marker-filled files, produces a corrupt phase, and
1031
+ // dispatch loops forever (see: merge-bug-fix).
1032
+ try {
1033
+ const { runGit } = await import("./git-service.ts");
1034
+ const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
1035
+ if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
1036
+ runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
1037
+ ctx.ui.notify(
1038
+ `Cleaned up conflicted merge state after failed squash-merge.`,
1039
+ "warning",
1040
+ );
1041
+ }
1042
+ } catch { /* best-effort cleanup */ }
1043
+
1025
1044
  ctx.ui.notify(
1026
- `Slice merge failed: ${message}`,
1045
+ `Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
1027
1046
  "error",
1028
1047
  );
1029
- // Re-derive state so dispatch can figure out what to do
1030
- state = await deriveState(basePath);
1031
- mid = state.activeMilestone?.id;
1032
- midTitle = state.activeMilestone?.title;
1048
+ if (currentUnit) {
1049
+ const modelId = ctx.model?.id ?? "unknown";
1050
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1051
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1052
+ }
1053
+ await stopAuto(ctx, pi);
1054
+ return;
1033
1055
  }
1034
1056
  }
1035
1057
  }
@@ -1157,9 +1179,19 @@ async function dispatchNextUnit(
1157
1179
  const hasResearch = !!(researchFile && await loadFile(researchFile));
1158
1180
 
1159
1181
  if (!hasResearch) {
1160
- unitType = "research-slice";
1161
- unitId = `${mid}/${sid}`;
1162
- prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1182
+ // Skip slice research for S01 when milestone research already exists —
1183
+ // the milestone research already covers the same ground for the first slice.
1184
+ const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
1185
+ const hasMilestoneResearch = !!(milestoneResearchFile && await loadFile(milestoneResearchFile));
1186
+ if (hasMilestoneResearch && sid === "S01") {
1187
+ unitType = "plan-slice";
1188
+ unitId = `${mid}/${sid}`;
1189
+ prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1190
+ } else {
1191
+ unitType = "research-slice";
1192
+ unitId = `${mid}/${sid}`;
1193
+ prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1194
+ }
1163
1195
  } else {
1164
1196
  unitType = "plan-slice";
1165
1197
  unitId = `${mid}/${sid}`;
@@ -1623,6 +1655,7 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
1623
1655
 
1624
1656
  const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
1625
1657
  const outputAbsPath = resolveMilestoneFile(base, mid, "ROADMAP") ?? join(base, outputRelPath);
1658
+ const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS");
1626
1659
  return loadPrompt("plan-milestone", {
1627
1660
  milestoneId: mid, milestoneTitle: midTitle,
1628
1661
  milestonePath: relMilestonePath(base, mid),
@@ -1630,6 +1663,7 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
1630
1663
  researchPath: researchRel,
1631
1664
  outputPath: outputRelPath,
1632
1665
  outputAbsPath,
1666
+ secretsOutputPath,
1633
1667
  inlinedContext,
1634
1668
  });
1635
1669
  }
@@ -47,6 +47,7 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md
47
47
  - `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `false`.
48
48
  - `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a slice branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `false`.
49
49
  - `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content.
50
+ - `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`.
50
51
 
51
52
  ---
52
53
 
@@ -13,6 +13,7 @@ import type {
13
13
  Summary, SummaryFrontmatter, SummaryRequires, FileModified,
14
14
  Continue, ContinueFrontmatter, ContinueStatus,
15
15
  RequirementCounts,
16
+ SecretsManifest, SecretsManifestEntry, SecretsManifestEntryStatus,
16
17
  } from './types.ts';
17
18
 
18
19
  // ─── Helpers ───────────────────────────────────────────────────────────────
@@ -263,6 +264,75 @@ export function parseRoadmap(content: string): Roadmap {
263
264
  return { title, vision, successCriteria, slices, boundaryMap };
264
265
  }
265
266
 
267
+ // ─── Secrets Manifest Parser ───────────────────────────────────────────────
268
+
269
+ const VALID_STATUSES = new Set<SecretsManifestEntryStatus>(['pending', 'collected', 'skipped']);
270
+
271
+ export function parseSecretsManifest(content: string): SecretsManifest {
272
+ const milestone = extractBoldField(content, 'Milestone') || '';
273
+ const generatedAt = extractBoldField(content, 'Generated') || '';
274
+
275
+ const h3Sections = extractAllSections(content, 3);
276
+ const entries: SecretsManifestEntry[] = [];
277
+
278
+ for (const [heading, sectionContent] of h3Sections) {
279
+ const key = heading.trim();
280
+ if (!key) continue;
281
+
282
+ const service = extractBoldField(sectionContent, 'Service') || '';
283
+ const dashboardUrl = extractBoldField(sectionContent, 'Dashboard') || '';
284
+ const formatHint = extractBoldField(sectionContent, 'Format hint') || '';
285
+ const rawStatus = (extractBoldField(sectionContent, 'Status') || 'pending').toLowerCase().trim() as SecretsManifestEntryStatus;
286
+ const status: SecretsManifestEntryStatus = VALID_STATUSES.has(rawStatus) ? rawStatus : 'pending';
287
+ const destination = extractBoldField(sectionContent, 'Destination') || 'dotenv';
288
+
289
+ // Extract numbered guidance list (lines matching "1. ...", "2. ...", etc.)
290
+ const guidance: string[] = [];
291
+ for (const line of sectionContent.split('\n')) {
292
+ const numMatch = line.match(/^\s*\d+\.\s+(.+)/);
293
+ if (numMatch) {
294
+ guidance.push(numMatch[1].trim());
295
+ }
296
+ }
297
+
298
+ entries.push({ key, service, dashboardUrl, guidance, formatHint, status, destination });
299
+ }
300
+
301
+ return { milestone, generatedAt, entries };
302
+ }
303
+
304
+ // ─── Secrets Manifest Formatter ───────────────────────────────────────────
305
+
306
+ export function formatSecretsManifest(manifest: SecretsManifest): string {
307
+ const lines: string[] = [];
308
+
309
+ lines.push('# Secrets Manifest');
310
+ lines.push('');
311
+ lines.push(`**Milestone:** ${manifest.milestone}`);
312
+ lines.push(`**Generated:** ${manifest.generatedAt}`);
313
+
314
+ for (const entry of manifest.entries) {
315
+ lines.push('');
316
+ lines.push(`### ${entry.key}`);
317
+ lines.push('');
318
+ lines.push(`**Service:** ${entry.service}`);
319
+ if (entry.dashboardUrl) {
320
+ lines.push(`**Dashboard:** ${entry.dashboardUrl}`);
321
+ }
322
+ if (entry.formatHint) {
323
+ lines.push(`**Format hint:** ${entry.formatHint}`);
324
+ }
325
+ lines.push(`**Status:** ${entry.status}`);
326
+ lines.push(`**Destination:** ${entry.destination}`);
327
+ lines.push('');
328
+ for (let i = 0; i < entry.guidance.length; i++) {
329
+ lines.push(`${i + 1}. ${entry.guidance[i]}`);
330
+ }
331
+ }
332
+
333
+ return lines.join('\n') + '\n';
334
+ }
335
+
266
336
  // ─── Slice Plan Parser ─────────────────────────────────────────────────────
267
337
 
268
338
  export function parsePlan(content: string): SlicePlan {
@@ -9,8 +9,7 @@
9
9
  */
10
10
 
11
11
  import { execSync } from "node:child_process";
12
- import { existsSync, readFileSync } from "node:fs";
13
- import { join, sep } from "node:path";
12
+ import { sep } from "node:path";
14
13
 
15
14
  import {
16
15
  detectWorktreeName,
@@ -249,9 +248,6 @@ export class GitServiceImpl {
249
248
  * branch (preserves planning artifacts). Falls back to main when on another
250
249
  * slice branch (avoids chaining slice branches).
251
250
  *
252
- * When creating a new branch, fetches from remote first (best-effort) to
253
- * ensure the local main is up-to-date.
254
- *
255
251
  * Auto-commits dirty state via smart staging before checkout so runtime
256
252
  * files are never accidentally committed during branch switches.
257
253
  *
@@ -272,9 +268,8 @@ export class GitServiceImpl {
272
268
  if (remotes) {
273
269
  const remote = this.prefs.remote ?? "origin";
274
270
  const fetchResult = this.git(["fetch", "--prune", remote], { allowFailure: true });
275
- // fetchResult is empty string on both success and allowFailure-caught error.
276
- // Check if local is behind upstream (informational only).
277
- if (remotes.split("\n").includes(remote)) {
271
+ if (fetchResult === "" && remotes.split("\n").includes(remote)) {
272
+ // Check if local is behind upstream (informational only)
278
273
  const behind = this.git(
279
274
  ["rev-list", "--count", "HEAD..@{upstream}"],
280
275
  { allowFailure: true },
@@ -348,93 +343,12 @@ export class GitServiceImpl {
348
343
  /**
349
344
  * Run pre-merge verification check. Auto-detects test runner from project
350
345
  * files, or uses custom command from prefs.pre_merge_check.
351
- *
352
- * Gating:
353
- * - `false` → skip (return passed:true, skipped:true)
354
- * - non-empty string (not "auto") → use as custom command
355
- * - `true`, `"auto"`, or `undefined` → auto-detect from project files
356
- *
357
- * Auto-detection order:
358
- * package.json scripts.test → npm test
359
- * package.json scripts.build (only if no test) → npm run build
360
- * Cargo.toml → cargo test
361
- * Makefile with test: target → make test
362
- * pyproject.toml → python -m pytest
363
- *
364
- * If no runner detected in auto mode, returns passed:true (don't block).
346
+ * Gated on prefs.pre_merge_check (false = skip, string = custom command).
347
+ * Stub: to be implemented in T03.
365
348
  */
366
349
  runPreMergeCheck(): PreMergeCheckResult {
367
- const pref = this.prefs.pre_merge_check;
368
-
369
- // Explicitly disabled
370
- if (pref === false) {
371
- return { passed: true, skipped: true };
372
- }
373
-
374
- let command: string | null = null;
375
-
376
- // Custom string command (not "auto")
377
- if (typeof pref === "string" && pref !== "auto" && pref.trim() !== "") {
378
- command = pref.trim();
379
- }
380
-
381
- // Auto-detect (true, "auto", or undefined)
382
- if (command === null) {
383
- command = this.detectTestRunner();
384
- }
385
-
386
- if (command === null) {
387
- return { passed: true, command: "none", error: "no test runner detected" };
388
- }
389
-
390
- // Execute the command
391
- try {
392
- execSync(command, {
393
- cwd: this.basePath,
394
- timeout: 300_000,
395
- stdio: ["ignore", "pipe", "pipe"],
396
- encoding: "utf-8",
397
- });
398
- return { passed: true, command };
399
- } catch (err) {
400
- const stderr = err instanceof Error && "stderr" in err
401
- ? String((err as { stderr: unknown }).stderr).slice(0, 2000)
402
- : String(err).slice(0, 2000);
403
- return { passed: false, command, error: stderr };
404
- }
405
- }
406
-
407
- /**
408
- * Detect a test/build runner from project files in basePath.
409
- * Returns the command string or null if nothing detected.
410
- */
411
- private detectTestRunner(): string | null {
412
- const pkgPath = join(this.basePath, "package.json");
413
- if (existsSync(pkgPath)) {
414
- try {
415
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
416
- if (pkg?.scripts?.test) return "npm test";
417
- if (pkg?.scripts?.build) return "npm run build";
418
- } catch { /* invalid JSON — skip */ }
419
- }
420
-
421
- if (existsSync(join(this.basePath, "Cargo.toml"))) {
422
- return "cargo test";
423
- }
424
-
425
- const makefilePath = join(this.basePath, "Makefile");
426
- if (existsSync(makefilePath)) {
427
- try {
428
- const content = readFileSync(makefilePath, "utf-8");
429
- if (/^test\s*:/m.test(content)) return "make test";
430
- } catch { /* skip */ }
431
- }
432
-
433
- if (existsSync(join(this.basePath, "pyproject.toml"))) {
434
- return "python -m pytest";
435
- }
436
-
437
- return null;
350
+ // TODO(S05/T03): implement pre-merge check
351
+ return { passed: true, skipped: true };
438
352
  }
439
353
 
440
354
  // ─── Merge ─────────────────────────────────────────────────────────────
@@ -522,7 +436,8 @@ export class GitServiceImpl {
522
436
  );
523
437
  }
524
438
 
525
- // Snapshot the branch HEAD before merge (gated on prefs.snapshots)
439
+ // Snapshot the branch HEAD before merge (gated on prefs)
440
+ // We need to save the ref while the branch still exists
526
441
  this.createSnapshot(branch);
527
442
 
528
443
  // Build rich commit message before squash (needs branch history)
@@ -531,18 +446,20 @@ export class GitServiceImpl {
531
446
  commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch,
532
447
  );
533
448
 
534
- // Squash merge
535
- this.git(["merge", "--squash", branch]);
536
-
537
- // Pre-merge check: run after squash (tests merged result), reset on failure
538
- const checkResult = this.runPreMergeCheck();
539
- if (!checkResult.passed && !checkResult.skipped) {
540
- // Undo the squash merge nothing committed yet, reset staging area
541
- this.git(["reset", "--hard", "HEAD"]);
542
- const cmdInfo = checkResult.command ? ` (command: ${checkResult.command})` : "";
543
- const errInfo = checkResult.error ? `\n${checkResult.error}` : "";
449
+ // Squash merge — abort cleanly on conflict so the working tree is never
450
+ // left in a half-merged state (see: merge-bug-fix).
451
+ try {
452
+ this.git(["merge", "--squash", branch]);
453
+ } catch (mergeError) {
454
+ // git merge --squash exits non-zero on conflict. The working tree now
455
+ // has conflict markers and a dirty index. Reset to restore a clean state.
456
+ this.git(["reset", "--hard", "HEAD"], { allowFailure: true });
457
+ const msg = mergeError instanceof Error ? mergeError.message : String(mergeError);
544
458
  throw new Error(
545
- `Pre-merge check failed${cmdInfo}. Merge aborted.${errInfo}`,
459
+ `Squash-merge of "${branch}" into "${mainBranch}" failed with conflicts. ` +
460
+ `Working tree has been reset to a clean state. ` +
461
+ `Resolve manually: git checkout ${mainBranch} && git merge --squash ${branch}\n` +
462
+ `Original error: ${msg}`,
546
463
  );
547
464
  }
548
465
 
@@ -555,7 +472,11 @@ export class GitServiceImpl {
555
472
  // Auto-push to remote if enabled
556
473
  if (this.prefs.auto_push === true) {
557
474
  const remote = this.prefs.remote ?? "origin";
558
- this.git(["push", remote, mainBranch], { allowFailure: true });
475
+ const pushResult = this.git(["push", remote, mainBranch], { allowFailure: true });
476
+ if (pushResult === "") {
477
+ // push succeeded (empty stdout is normal) or failed silently
478
+ // Verify by checking if remote is reachable — the allowFailure handles errors
479
+ }
559
480
  }
560
481
 
561
482
  return {
@@ -145,6 +145,7 @@ See \`~/.gsd/agent/extensions/gsd/docs/preferences-reference.md\` for full field
145
145
  - \`models\`: Model preferences for specific task types
146
146
  - \`skill_discovery\`: Automatic skill detection preferences
147
147
  - \`auto_supervisor\`: Supervision and gating rules for autonomous modes
148
+ - \`git\`: Git preferences — \`main_branch\` (default branch name for new repos, e.g., "main", "master", "trunk"), \`auto_push\`, \`snapshots\`, etc.
148
149
 
149
150
  ## Examples
150
151
 
@@ -20,8 +20,9 @@ import {
20
20
  } from "./paths.js";
21
21
  import { join } from "node:path";
22
22
  import { readFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
23
- import { execSync } from "node:child_process";
23
+ import { execSync, execFileSync } from "node:child_process";
24
24
  import { ensureGitignore, ensurePreferences } from "./gitignore.js";
25
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
25
26
 
26
27
  // ─── Auto-start after discuss ─────────────────────────────────────────────────
27
28
 
@@ -444,7 +445,8 @@ export async function showSmartEntry(
444
445
  try {
445
446
  execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
446
447
  } catch {
447
- execSync("git init", { cwd: basePath, stdio: "pipe" });
448
+ const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
449
+ execFileSync("git", ["init", "-b", mainBranch], { cwd: basePath, stdio: "pipe" });
448
450
  }
449
451
 
450
452
  // ── Ensure .gitignore has baseline patterns ──────────────────────────
@@ -609,8 +611,9 @@ export async function showSmartEntry(
609
611
  });
610
612
 
611
613
  if (choice === "plan") {
614
+ const secretsOutputPath = relMilestoneFile(basePath, milestoneId, "SECRETS");
612
615
  dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
613
- milestoneId, milestoneTitle,
616
+ milestoneId, milestoneTitle, secretsOutputPath,
614
617
  }));
615
618
  } else if (choice === "discuss") {
616
619
  dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {