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
package/README.md CHANGED
@@ -252,17 +252,18 @@ Branch-per-slice with squash merge. Fully automated.
252
252
 
253
253
  ```
254
254
  main:
255
- feat(M001/S03): auth and session management
255
+ docs(M001/S04): workflow documentation and examples
256
+ fix(M001/S03): bug fixes and doc corrections
256
257
  feat(M001/S02): API endpoints and middleware
257
258
  feat(M001/S01): data model and type system
258
259
 
259
- gsd/M001/S01 (preserved):
260
+ gsd/M001/S01 (deleted after merge):
260
261
  feat(S01/T03): file writer with round-trip fidelity
261
262
  feat(S01/T02): markdown parser for plan files
262
263
  feat(S01/T01): core types and interfaces
263
264
  ```
264
265
 
265
- One commit per slice on main. Per-task history preserved on branches. Git bisect works. Individual slices are revertable.
266
+ One commit per slice on main. Squash commits are the permanent record — branches are deleted after merge. Git bisect works. Individual slices are revertable.
266
267
 
267
268
  ### Verification
268
269
 
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.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "scripts": {
36
36
  "build": "tsc && npm run copy-themes",
37
37
  "copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"",
38
- "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test 'src/resources/extensions/gsd/tests/*.test.ts' 'src/resources/extensions/gsd/tests/*.test.mjs' 'src/tests/*.test.ts'",
38
+ "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
39
39
  "dev": "tsc --watch",
40
40
  "postinstall": "node scripts/postinstall.js",
41
41
  "pi:install-global": "node scripts/install-pi-global.js",
@@ -548,7 +548,7 @@ If files disagree, **pause and surface to the user**:
548
548
  1. **Slice starts** → create branch `gsd/M001/S01` from main
549
549
  2. **Per-task commits** on the branch — atomic, descriptive, bisectable
550
550
  3. **Slice completes** → squash merge to main as one clean commit
551
- 4. **Branch kept** — not deleted, available for per-task history
551
+ 4. **Branch deleted** — squash commit on main is the permanent record
552
552
 
553
553
  ### What Main Looks Like
554
554
 
@@ -566,21 +566,21 @@ One commit per slice. Individually revertable. Reads like a changelog.
566
566
  gsd/M001/S01:
567
567
  test(S01): round-trip tests passing
568
568
  feat(S01/T03): file writer with round-trip fidelity
569
- checkpoint(S01/T03): pre-task
569
+ chore(S01/T03): auto-commit after task
570
570
  feat(S01/T02): markdown parser for plan files
571
- checkpoint(S01/T02): pre-task
571
+ chore(S01/T02): auto-commit after task
572
572
  feat(S01/T01): core types and interfaces
573
- checkpoint(S01/T01): pre-task
573
+ chore(S01/T01): auto-commit after task
574
574
  ```
575
575
 
576
576
  ### Commit Conventions
577
577
 
578
578
  | When | Format | Example |
579
579
  |------|--------|---------|
580
- | Before each task | `checkpoint(S01/T02): pre-task` | Safety net for `git reset` |
580
+ | Auto-commit (dirty state) | `chore(S01/T02): auto-commit after task` | Automatic save of work in progress |
581
581
  | After task verified | `feat(S01/T02): <what was built>` | The real work |
582
582
  | Plan/docs committed | `docs(S01): add slice plan` | Bundled with first task |
583
- | Slice squash to main | `feat(M001/S01): <slice title>` | Clean one-liner on main |
583
+ | Slice squash to main | `type(M001/S01): <slice title>` | Type inferred from title (`feat`, `fix`, `docs`, etc.) |
584
584
 
585
585
  Commit types: `feat`, `fix`, `test`, `refactor`, `docs`, `chore`
586
586
 
@@ -601,7 +601,7 @@ Tasks completed:
601
601
 
602
602
  | Problem | Fix |
603
603
  |---------|-----|
604
- | Bad task | `git reset --hard` to checkpoint on the branch |
604
+ | Bad task | `git reset --hard HEAD~1` to previous commit on the branch |
605
605
  | Bad slice | `git revert <squash commit>` on main |
606
606
  | UAT failure after merge | Fix tasks on `gsd/M001/S01-fix` branch, squash as `fix(M001/S01): <fix>` |
607
607
 
@@ -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,17 +56,18 @@ 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,
63
63
  getCurrentBranch,
64
64
  getMainBranch,
65
- getSliceBranchName,
66
65
  parseSliceBranch,
67
66
  switchToMain,
68
67
  mergeSliceToMain,
69
68
  } from "./worktree.ts";
69
+ import { GitServiceImpl } from "./git-service.ts";
70
+ import type { GitPreferences } from "./git-service.ts";
70
71
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
71
72
  import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
72
73
  import { showNextAction } from "../shared/next-action-ui.js";
@@ -93,6 +94,18 @@ function persistCompletedKey(base: string, key: string): void {
93
94
  }
94
95
  }
95
96
 
97
+ /** Remove a stale completed unit key from disk. */
98
+ function removePersistedKey(base: string, key: string): void {
99
+ const file = completedKeysPath(base);
100
+ try {
101
+ if (existsSync(file)) {
102
+ let keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
103
+ keys = keys.filter(k => k !== key);
104
+ writeFileSync(file, JSON.stringify(keys), "utf-8");
105
+ }
106
+ } catch { /* non-fatal */ }
107
+ }
108
+
96
109
  /** Load all completed unit keys from disk into the in-memory set. */
97
110
  function loadPersistedKeys(base: string, target: Set<string>): void {
98
111
  const file = completedKeysPath(base);
@@ -112,6 +125,7 @@ let stepMode = false;
112
125
  let verbose = false;
113
126
  let cmdCtx: ExtensionCommandContext | null = null;
114
127
  let basePath = "";
128
+ let gitService: GitServiceImpl | null = null;
115
129
 
116
130
  /** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
117
131
  const unitDispatchCount = new Map<string, number>();
@@ -359,7 +373,8 @@ export async function startAuto(
359
373
  try {
360
374
  execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
361
375
  } catch {
362
- 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" });
363
378
  }
364
379
 
365
380
  // Ensure .gitignore has baseline patterns
@@ -376,6 +391,9 @@ export async function startAuto(
376
391
  } catch { /* nothing to commit */ }
377
392
  }
378
393
 
394
+ // Initialize GitServiceImpl — basePath is set and git repo confirmed
395
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
396
+
379
397
  // Check for crash from previous session
380
398
  const crashLock = readCrashLock(base);
381
399
  if (crashLock) {
@@ -658,7 +676,7 @@ function peekNext(unitType: string, state: GSDState): string {
658
676
  const sid = state.activeSlice?.id ?? "";
659
677
  switch (unitType) {
660
678
  case "research-milestone": return "plan milestone roadmap";
661
- case "plan-milestone": return "research first slice";
679
+ case "plan-milestone": return "plan or execute first slice";
662
680
  case "research-slice": return `plan ${sid}`;
663
681
  case "plan-slice": return "execute first task";
664
682
  case "execute-task": return `continue ${sid}`;
@@ -1005,14 +1023,35 @@ async function dispatchNextUnit(
1005
1023
  midTitle = state.activeMilestone?.title;
1006
1024
  } catch (error) {
1007
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
+
1008
1044
  ctx.ui.notify(
1009
- `Slice merge failed: ${message}`,
1045
+ `Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
1010
1046
  "error",
1011
1047
  );
1012
- // Re-derive state so dispatch can figure out what to do
1013
- state = await deriveState(basePath);
1014
- mid = state.activeMilestone?.id;
1015
- 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;
1016
1055
  }
1017
1056
  }
1018
1057
  }
@@ -1140,9 +1179,19 @@ async function dispatchNextUnit(
1140
1179
  const hasResearch = !!(researchFile && await loadFile(researchFile));
1141
1180
 
1142
1181
  if (!hasResearch) {
1143
- unitType = "research-slice";
1144
- unitId = `${mid}/${sid}`;
1145
- 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
+ }
1146
1195
  } else {
1147
1196
  unitType = "plan-slice";
1148
1197
  unitId = `${mid}/${sid}`;
@@ -1190,15 +1239,27 @@ async function dispatchNextUnit(
1190
1239
  // Idempotency: skip units already completed in a prior session.
1191
1240
  const idempotencyKey = `${unitType}/${unitId}`;
1192
1241
  if (completedKeySet.has(idempotencyKey)) {
1193
- ctx.ui.notify(
1194
- `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
1195
- "info",
1196
- );
1197
- // Yield to the event loop before re-dispatching to avoid tight recursion
1198
- // when many units are already completed (e.g., after crash recovery).
1199
- await new Promise(r => setImmediate(r));
1200
- await dispatchNextUnit(ctx, pi);
1201
- return;
1242
+ // Cross-validate: does the expected artifact actually exist?
1243
+ const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
1244
+ if (artifactExists) {
1245
+ ctx.ui.notify(
1246
+ `Skipping ${unitType} ${unitId} already completed in a prior session. Advancing.`,
1247
+ "info",
1248
+ );
1249
+ // Yield to the event loop before re-dispatching to avoid tight recursion
1250
+ // when many units are already completed (e.g., after crash recovery).
1251
+ await new Promise(r => setImmediate(r));
1252
+ await dispatchNextUnit(ctx, pi);
1253
+ return;
1254
+ } else {
1255
+ // Stale completion record — artifact missing. Remove and re-run.
1256
+ completedKeySet.delete(idempotencyKey);
1257
+ removePersistedKey(basePath, idempotencyKey);
1258
+ ctx.ui.notify(
1259
+ `Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`,
1260
+ "warning",
1261
+ );
1262
+ }
1202
1263
  }
1203
1264
 
1204
1265
  // Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
@@ -1234,20 +1295,26 @@ async function dispatchNextUnit(
1234
1295
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1235
1296
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1236
1297
 
1237
- // Persist completion to disk BEFORE updating memory — so a crash here is recoverable.
1298
+ // Only mark the previous unit as completed if:
1299
+ // 1. We're not about to re-dispatch the same unit (retry scenario)
1300
+ // 2. The expected artifact actually exists on disk
1238
1301
  const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
1239
- persistCompletedKey(basePath, closeoutKey);
1240
- completedKeySet.add(closeoutKey);
1241
-
1242
- completedUnits.push({
1243
- type: currentUnit.type,
1244
- id: currentUnit.id,
1245
- startedAt: currentUnit.startedAt,
1246
- finishedAt: Date.now(),
1247
- });
1248
- clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
1249
- unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1250
- unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1302
+ const incomingKey = `${unitType}/${unitId}`;
1303
+ const artifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
1304
+ if (closeoutKey !== incomingKey && artifactVerified) {
1305
+ persistCompletedKey(basePath, closeoutKey);
1306
+ completedKeySet.add(closeoutKey);
1307
+
1308
+ completedUnits.push({
1309
+ type: currentUnit.type,
1310
+ id: currentUnit.id,
1311
+ startedAt: currentUnit.startedAt,
1312
+ finishedAt: Date.now(),
1313
+ });
1314
+ clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
1315
+ unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1316
+ unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1317
+ }
1251
1318
  }
1252
1319
  currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1253
1320
  writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
@@ -1588,6 +1655,7 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
1588
1655
 
1589
1656
  const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
1590
1657
  const outputAbsPath = resolveMilestoneFile(base, mid, "ROADMAP") ?? join(base, outputRelPath);
1658
+ const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS");
1591
1659
  return loadPrompt("plan-milestone", {
1592
1660
  milestoneId: mid, milestoneTitle: midTitle,
1593
1661
  milestonePath: relMilestonePath(base, mid),
@@ -1595,6 +1663,7 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
1595
1663
  researchPath: researchRel,
1596
1664
  outputPath: outputRelPath,
1597
1665
  outputAbsPath,
1666
+ secretsOutputPath,
1598
1667
  inlinedContext,
1599
1668
  });
1600
1669
  }
@@ -2587,6 +2656,15 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
2587
2656
  const dir = resolveSlicePath(base, mid, sid!);
2588
2657
  return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
2589
2658
  }
2659
+ case "execute-task": {
2660
+ const tid = parts[2];
2661
+ const dir = resolveSlicePath(base, mid, sid!);
2662
+ return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
2663
+ }
2664
+ case "complete-slice": {
2665
+ const dir = resolveSlicePath(base, mid, sid!);
2666
+ return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null;
2667
+ }
2590
2668
  case "complete-milestone": {
2591
2669
  const dir = resolveMilestonePath(base, mid);
2592
2670
  return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
@@ -2596,6 +2674,17 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
2596
2674
  }
2597
2675
  }
2598
2676
 
2677
+ /**
2678
+ * Check whether the expected artifact for a unit exists on disk.
2679
+ * Returns true if the artifact file exists, or if the unit type has no
2680
+ * single verifiable artifact (e.g., replan-slice).
2681
+ */
2682
+ function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
2683
+ const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
2684
+ if (!absPath) return true;
2685
+ return existsSync(absPath);
2686
+ }
2687
+
2599
2688
  /**
2600
2689
  * Write a placeholder artifact so the pipeline can advance past a stuck unit.
2601
2690
  * Returns the relative path written, or null if the path couldn't be resolved.
@@ -40,6 +40,15 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md
40
40
  - `idle_timeout_minutes`: minutes of inactivity before the supervisor intervenes (default: 10).
41
41
  - `hard_timeout_minutes`: minutes before the supervisor forces termination (default: 30).
42
42
 
43
+ - `git`: configures GSD's git behavior. All fields are optional — omit any to use defaults. Keys:
44
+ - `auto_push`: boolean — automatically push commits to the remote after committing. Default: `false`.
45
+ - `push_branches`: boolean — push newly created slice branches to the remote. Default: `false`.
46
+ - `remote`: string — git remote name to push to. Default: `"origin"`.
47
+ - `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `false`.
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
+ - `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"`.
51
+
43
52
  ---
44
53
 
45
54
  ## Best Practices
@@ -101,3 +110,22 @@ skill_rules:
101
110
  - find-skills
102
111
  ---
103
112
  ```
113
+
114
+ ---
115
+
116
+ ## Git Preferences Example
117
+
118
+ ```yaml
119
+ ---
120
+ version: 1
121
+ git:
122
+ auto_push: true
123
+ push_branches: true
124
+ remote: origin
125
+ snapshots: true
126
+ pre_merge_check: auto
127
+ commit_type: feat
128
+ ---
129
+ ```
130
+
131
+ All git fields are optional. Omit any field to use the default behavior. Project-level preferences override global preferences on a per-field basis.