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.
- package/dist/loader.js +21 -3
- package/dist/logo.d.ts +3 -3
- package/dist/logo.js +2 -2
- package/package.json +1 -1
- package/src/resources/extensions/get-secrets-from-user.ts +63 -8
- package/src/resources/extensions/gsd/auto.ts +45 -11
- package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
- package/src/resources/extensions/gsd/files.ts +70 -0
- package/src/resources/extensions/gsd/git-service.ts +27 -106
- 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 +8 -0
- package/src/resources/extensions/gsd/prompts/complete-slice.md +7 -5
- package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
- package/src/resources/extensions/gsd/prompts/execute-task.md +2 -6
- 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/templates/plan.md +8 -10
- 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 +63 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +211 -65
- package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
- 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
|
-
//
|
|
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
|
@@ -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,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
|
-
|
|
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 "
|
|
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
|
|
1045
|
+
`Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
|
|
1027
1046
|
"error",
|
|
1028
1047
|
);
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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 {
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
//
|
|
541
|
-
this.git(["reset", "--hard", "HEAD"]);
|
|
542
|
-
const
|
|
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
|
-
`
|
|
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
|
-
|
|
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", {
|