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.
- package/README.md +4 -3
- package/dist/loader.js +21 -3
- package/dist/logo.d.ts +3 -3
- package/dist/logo.js +2 -2
- package/package.json +2 -2
- package/src/resources/GSD-WORKFLOW.md +7 -7
- package/src/resources/extensions/get-secrets-from-user.ts +63 -8
- package/src/resources/extensions/gsd/auto.ts +123 -34
- package/src/resources/extensions/gsd/docs/preferences-reference.md +28 -0
- package/src/resources/extensions/gsd/files.ts +70 -0
- package/src/resources/extensions/gsd/git-service.ts +151 -11
- package/src/resources/extensions/gsd/gitignore.ts +1 -0
- package/src/resources/extensions/gsd/guided-flow.ts +6 -3
- package/src/resources/extensions/gsd/preferences.ts +59 -0
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/complete-slice.md +8 -6
- package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
- package/src/resources/extensions/gsd/prompts/execute-task.md +3 -7
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
- package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
- package/src/resources/extensions/gsd/prompts/replan-slice.md +1 -1
- package/src/resources/extensions/gsd/templates/plan.md +8 -10
- package/src/resources/extensions/gsd/templates/preferences.md +7 -0
- package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
- package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
- package/src/resources/extensions/gsd/tests/git-service.test.ts +421 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +211 -65
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +0 -2
- package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
- package/src/resources/extensions/gsd/types.ts +20 -0
- package/src/resources/extensions/gsd/worktree-command.ts +48 -6
- package/src/resources/extensions/gsd/worktree.ts +40 -147
- package/src/resources/extensions/search-the-web/index.ts +16 -25
- 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
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
//
|
|
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/
|
|
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.
|
|
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/
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
569
|
+
chore(S01/T03): auto-commit after task
|
|
570
570
|
feat(S01/T02): markdown parser for plan files
|
|
571
|
-
|
|
571
|
+
chore(S01/T02): auto-commit after task
|
|
572
572
|
feat(S01/T01): core types and interfaces
|
|
573
|
-
|
|
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
|
-
|
|
|
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 | `
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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: ${
|
|
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
|
-
|
|
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 "
|
|
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
|
|
1045
|
+
`Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
|
|
1010
1046
|
"error",
|
|
1011
1047
|
);
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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.
|