gsd-pi 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +4 -3
  2. package/dist/loader.js +21 -3
  3. package/dist/logo.d.ts +3 -3
  4. package/dist/logo.js +2 -2
  5. package/package.json +2 -2
  6. package/src/resources/GSD-WORKFLOW.md +7 -7
  7. package/src/resources/extensions/get-secrets-from-user.ts +63 -8
  8. package/src/resources/extensions/gsd/auto.ts +123 -34
  9. package/src/resources/extensions/gsd/docs/preferences-reference.md +28 -0
  10. package/src/resources/extensions/gsd/files.ts +70 -0
  11. package/src/resources/extensions/gsd/git-service.ts +151 -11
  12. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  13. package/src/resources/extensions/gsd/guided-flow.ts +6 -3
  14. package/src/resources/extensions/gsd/preferences.ts +59 -0
  15. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  16. package/src/resources/extensions/gsd/prompts/complete-slice.md +8 -6
  17. package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
  18. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -7
  19. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
  20. package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
  21. package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
  22. package/src/resources/extensions/gsd/prompts/replan-slice.md +1 -1
  23. package/src/resources/extensions/gsd/templates/plan.md +8 -10
  24. package/src/resources/extensions/gsd/templates/preferences.md +7 -0
  25. package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
  26. package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
  27. package/src/resources/extensions/gsd/tests/git-service.test.ts +421 -0
  28. package/src/resources/extensions/gsd/tests/parsers.test.ts +211 -65
  29. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +0 -2
  30. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
  31. package/src/resources/extensions/gsd/types.ts +20 -0
  32. package/src/resources/extensions/gsd/worktree-command.ts +48 -6
  33. package/src/resources/extensions/gsd/worktree.ts +40 -147
  34. package/src/resources/extensions/search-the-web/index.ts +16 -25
  35. package/src/resources/extensions/search-the-web/native-search.ts +157 -0
@@ -1,4 +1,4 @@
1
- import { parseRoadmap, parsePlan, parseSummary, parseContinue, parseRequirementCounts } from '../files.ts';
1
+ import { parseRoadmap, parsePlan, parseSummary, parseContinue, parseRequirementCounts, parseSecretsManifest, formatSecretsManifest } from '../files.ts';
2
2
 
3
3
  let passed = 0;
4
4
  let failed = 0;
@@ -1249,97 +1249,243 @@ console.log('\n=== parseRequirementCounts: total is sum of all section counts ==
1249
1249
  }
1250
1250
 
1251
1251
  // ═══════════════════════════════════════════════════════════════════════════
1252
- // parseSummary: bare scalar frontmatter fields (regression test for #91)
1252
+ // parseSecretsManifest / formatSecretsManifest tests
1253
1253
  // ═══════════════════════════════════════════════════════════════════════════
1254
1254
 
1255
- console.log('\n=== parseSummary: bare scalar "none" coerced to string array (#91) ===');
1255
+ console.log('\n=== parseSecretsManifest: full manifest with 3 keys ===');
1256
1256
  {
1257
- const content = `---
1258
- id: T04
1259
- parent: S03
1260
- milestone: M001
1261
- provides:
1262
- - iOS rules
1263
- key_files:
1264
- - .claude/rules/swift-style.md
1265
- key_decisions: none
1266
- patterns_established: none
1267
- drill_down_paths: none
1268
- observability_surfaces: none — static reference files
1269
- affects: single-value
1270
- ---
1257
+ const content = `# Secrets Manifest
1271
1258
 
1272
- # T04: iOS Rules
1259
+ **Milestone:** M003
1260
+ **Generated:** 2025-06-15T10:00:00Z
1273
1261
 
1274
- **Created iOS-specific rules.**
1262
+ ### OPENAI_API_KEY
1275
1263
 
1276
- ## What Happened
1264
+ **Service:** OpenAI
1265
+ **Dashboard:** https://platform.openai.com/api-keys
1266
+ **Format hint:** starts with sk-
1267
+ **Status:** pending
1268
+ **Destination:** dotenv
1277
1269
 
1278
- Added rules.
1270
+ 1. Go to https://platform.openai.com/api-keys
1271
+ 2. Click "Create new secret key"
1272
+ 3. Copy the key immediately — it won't be shown again
1279
1273
 
1280
- ## Deviations
1274
+ ### STRIPE_SECRET_KEY
1281
1275
 
1282
- None.
1276
+ **Service:** Stripe
1277
+ **Dashboard:** https://dashboard.stripe.com/apikeys
1278
+ **Format hint:** starts with sk_test_ or sk_live_
1279
+ **Status:** collected
1280
+ **Destination:** dotenv
1281
+
1282
+ 1. Go to https://dashboard.stripe.com/apikeys
1283
+ 2. Reveal the secret key
1284
+ 3. Copy it
1285
+
1286
+ ### SUPABASE_URL
1287
+
1288
+ **Service:** Supabase
1289
+ **Dashboard:** https://app.supabase.com/project/settings/api
1290
+ **Format hint:** https://<project-ref>.supabase.co
1291
+ **Status:** skipped
1292
+ **Destination:** vercel
1293
+
1294
+ 1. Go to project settings in Supabase
1295
+ 2. Copy the URL from the API section
1283
1296
  `;
1284
1297
 
1285
- const s = parseSummary(content);
1298
+ const m = parseSecretsManifest(content);
1299
+
1300
+ assertEq(m.milestone, 'M003', 'manifest milestone');
1301
+ assertEq(m.generatedAt, '2025-06-15T10:00:00Z', 'manifest generatedAt');
1302
+ assertEq(m.entries.length, 3, 'three entries');
1303
+
1304
+ // First entry
1305
+ assertEq(m.entries[0].key, 'OPENAI_API_KEY', 'entry 0 key');
1306
+ assertEq(m.entries[0].service, 'OpenAI', 'entry 0 service');
1307
+ assertEq(m.entries[0].dashboardUrl, 'https://platform.openai.com/api-keys', 'entry 0 dashboardUrl');
1308
+ assertEq(m.entries[0].formatHint, 'starts with sk-', 'entry 0 formatHint');
1309
+ assertEq(m.entries[0].status, 'pending', 'entry 0 status');
1310
+ assertEq(m.entries[0].destination, 'dotenv', 'entry 0 destination');
1311
+ assertEq(m.entries[0].guidance.length, 3, 'entry 0 guidance count');
1312
+ assertEq(m.entries[0].guidance[0], 'Go to https://platform.openai.com/api-keys', 'entry 0 guidance[0]');
1313
+ assertEq(m.entries[0].guidance[2], 'Copy the key immediately — it won\'t be shown again', 'entry 0 guidance[2]');
1314
+
1315
+ // Second entry
1316
+ assertEq(m.entries[1].key, 'STRIPE_SECRET_KEY', 'entry 1 key');
1317
+ assertEq(m.entries[1].service, 'Stripe', 'entry 1 service');
1318
+ assertEq(m.entries[1].status, 'collected', 'entry 1 status');
1319
+ assertEq(m.entries[1].formatHint, 'starts with sk_test_ or sk_live_', 'entry 1 formatHint');
1320
+ assertEq(m.entries[1].guidance.length, 3, 'entry 1 guidance count');
1321
+
1322
+ // Third entry
1323
+ assertEq(m.entries[2].key, 'SUPABASE_URL', 'entry 2 key');
1324
+ assertEq(m.entries[2].status, 'skipped', 'entry 2 status');
1325
+ assertEq(m.entries[2].destination, 'vercel', 'entry 2 destination');
1326
+ assertEq(m.entries[2].guidance.length, 2, 'entry 2 guidance count');
1327
+ }
1328
+
1329
+ console.log('\n=== parseSecretsManifest: single-key manifest ===');
1330
+ {
1331
+ const content = `# Secrets Manifest
1332
+
1333
+ **Milestone:** M001
1334
+ **Generated:** 2025-06-15T12:00:00Z
1286
1335
 
1287
- // Array fields should remain arrays
1288
- assertEq(s.frontmatter.provides.length, 1, '#91: provides array preserved');
1289
- assertEq(s.frontmatter.provides[0], 'iOS rules', '#91: provides value');
1290
- assertEq(s.frontmatter.key_files.length, 1, '#91: key_files array preserved');
1336
+ ### DATABASE_URL
1291
1337
 
1292
- // Bare scalar "none" must be coerced to ["none"], not crash
1293
- assertEq(Array.isArray(s.frontmatter.key_decisions), true, '#91: key_decisions is array');
1294
- assertEq(s.frontmatter.key_decisions.length, 1, '#91: key_decisions has 1 element');
1295
- assertEq(s.frontmatter.key_decisions[0], 'none', '#91: key_decisions[0] is "none"');
1338
+ **Service:** PostgreSQL
1339
+ **Dashboard:** https://console.neon.tech
1340
+ **Format hint:** postgresql://...
1341
+ **Status:** pending
1342
+ **Destination:** dotenv
1296
1343
 
1297
- assertEq(Array.isArray(s.frontmatter.patterns_established), true, '#91: patterns_established is array');
1298
- assertEq(s.frontmatter.patterns_established.length, 1, '#91: patterns_established coerced');
1344
+ 1. Create a database on Neon
1345
+ 2. Copy the connection string
1346
+ `;
1299
1347
 
1300
- assertEq(Array.isArray(s.frontmatter.drill_down_paths), true, '#91: drill_down_paths is array');
1301
- assertEq(s.frontmatter.drill_down_paths.length, 1, '#91: drill_down_paths coerced');
1348
+ const m = parseSecretsManifest(content);
1349
+ assertEq(m.milestone, 'M001', 'single-key milestone');
1350
+ assertEq(m.entries.length, 1, 'single entry');
1351
+ assertEq(m.entries[0].key, 'DATABASE_URL', 'single entry key');
1352
+ assertEq(m.entries[0].service, 'PostgreSQL', 'single entry service');
1353
+ assertEq(m.entries[0].guidance.length, 2, 'single entry guidance count');
1354
+ }
1302
1355
 
1303
- // Scalar with spaces: "none static reference files"
1304
- assertEq(Array.isArray(s.frontmatter.observability_surfaces), true, '#91: observability_surfaces is array');
1305
- assertEq(s.frontmatter.observability_surfaces.length, 1, '#91: observability_surfaces coerced');
1306
- assertEq(s.frontmatter.observability_surfaces[0], 'none — static reference files', '#91: full scalar preserved');
1356
+ console.log('\n=== parseSecretsManifest: empty/no-secrets manifest ===');
1357
+ {
1358
+ const content = `# Secrets Manifest
1307
1359
 
1308
- // Single value (not "none") also coerced
1309
- assertEq(Array.isArray(s.frontmatter.affects), true, '#91: affects is array');
1310
- assertEq(s.frontmatter.affects.length, 1, '#91: affects single value coerced');
1311
- assertEq(s.frontmatter.affects[0], 'single-value', '#91: affects value');
1360
+ **Milestone:** M002
1361
+ **Generated:** 2025-06-15T14:00:00Z
1362
+ `;
1312
1363
 
1313
- // .slice().join() must not crash (the original bug)
1314
- const decisions = s.frontmatter.key_decisions.slice(0, 2).join('; ');
1315
- assertEq(decisions, 'none', '#91: .slice().join() works on coerced array');
1364
+ const m = parseSecretsManifest(content);
1365
+ assertEq(m.milestone, 'M002', 'empty manifest milestone');
1366
+ assertEq(m.generatedAt, '2025-06-15T14:00:00Z', 'empty manifest generatedAt');
1367
+ assertEq(m.entries.length, 0, 'no entries in empty manifest');
1316
1368
  }
1317
1369
 
1318
- console.log('\n=== parseSummary: missing/empty frontmatter fields yield empty arrays ===');
1370
+ console.log('\n=== parseSecretsManifest: missing optional fields default correctly ===');
1319
1371
  {
1320
- const content = `---
1321
- id: T05
1322
- parent: S04
1323
- milestone: M001
1324
- ---
1372
+ const content = `# Secrets Manifest
1325
1373
 
1326
- # T05: Minimal Summary
1374
+ **Milestone:** M004
1375
+ **Generated:** 2025-06-15T16:00:00Z
1327
1376
 
1328
- **Minimal.**
1377
+ ### SOME_API_KEY
1329
1378
 
1330
- ## What Happened
1379
+ **Service:** SomeService
1331
1380
 
1332
- Nothing.
1381
+ 1. Get the key from the dashboard
1333
1382
  `;
1334
1383
 
1335
- const s = parseSummary(content);
1336
- assertEq(s.frontmatter.provides.length, 0, 'missing provides = empty array');
1337
- assertEq(s.frontmatter.key_decisions.length, 0, 'missing key_decisions = empty array');
1338
- assertEq(s.frontmatter.affects.length, 0, 'missing affects = empty array');
1339
- assertEq(s.frontmatter.key_files.length, 0, 'missing key_files = empty array');
1340
- assertEq(s.frontmatter.patterns_established.length, 0, 'missing patterns_established = empty array');
1341
- assertEq(s.frontmatter.drill_down_paths.length, 0, 'missing drill_down_paths = empty array');
1342
- assertEq(s.frontmatter.observability_surfaces.length, 0, 'missing observability_surfaces = empty array');
1384
+ const m = parseSecretsManifest(content);
1385
+ assertEq(m.entries.length, 1, 'one entry with missing fields');
1386
+ assertEq(m.entries[0].key, 'SOME_API_KEY', 'key parsed');
1387
+ assertEq(m.entries[0].service, 'SomeService', 'service parsed');
1388
+ assertEq(m.entries[0].dashboardUrl, '', 'missing dashboardUrl defaults to empty string');
1389
+ assertEq(m.entries[0].formatHint, '', 'missing formatHint defaults to empty string');
1390
+ assertEq(m.entries[0].status, 'pending', 'missing status defaults to pending');
1391
+ assertEq(m.entries[0].destination, 'dotenv', 'missing destination defaults to dotenv');
1392
+ assertEq(m.entries[0].guidance.length, 1, 'guidance still parsed');
1393
+ }
1394
+
1395
+ console.log('\n=== parseSecretsManifest: all three status values parse ===');
1396
+ {
1397
+ for (const status of ['pending', 'collected', 'skipped'] as const) {
1398
+ const content = `# Secrets Manifest
1399
+
1400
+ **Milestone:** M005
1401
+ **Generated:** 2025-06-15T18:00:00Z
1402
+
1403
+ ### TEST_KEY
1404
+
1405
+ **Service:** TestService
1406
+ **Status:** ${status}
1407
+
1408
+ 1. Do something
1409
+ `;
1410
+
1411
+ const m = parseSecretsManifest(content);
1412
+ assertEq(m.entries[0].status, status, `status variant: ${status}`);
1413
+ }
1414
+ }
1415
+
1416
+ console.log('\n=== parseSecretsManifest: invalid status defaults to pending ===');
1417
+ {
1418
+ const content = `# Secrets Manifest
1419
+
1420
+ **Milestone:** M006
1421
+ **Generated:** 2025-06-15T20:00:00Z
1422
+
1423
+ ### BAD_STATUS_KEY
1424
+
1425
+ **Service:** TestService
1426
+ **Status:** invalid_value
1427
+
1428
+ 1. Some step
1429
+ `;
1430
+
1431
+ const m = parseSecretsManifest(content);
1432
+ assertEq(m.entries[0].status, 'pending', 'invalid status defaults to pending');
1433
+ }
1434
+
1435
+ console.log('\n=== parseSecretsManifest + formatSecretsManifest: round-trip ===');
1436
+ {
1437
+ const original = `# Secrets Manifest
1438
+
1439
+ **Milestone:** M007
1440
+ **Generated:** 2025-06-16T10:00:00Z
1441
+
1442
+ ### OPENAI_API_KEY
1443
+
1444
+ **Service:** OpenAI
1445
+ **Dashboard:** https://platform.openai.com/api-keys
1446
+ **Format hint:** starts with sk-
1447
+ **Status:** pending
1448
+ **Destination:** dotenv
1449
+
1450
+ 1. Go to the API keys page
1451
+ 2. Create a new key
1452
+ 3. Copy it
1453
+
1454
+ ### REDIS_URL
1455
+
1456
+ **Service:** Upstash
1457
+ **Dashboard:** https://console.upstash.com
1458
+ **Format hint:** redis://...
1459
+ **Status:** collected
1460
+ **Destination:** vercel
1461
+
1462
+ 1. Open Upstash console
1463
+ 2. Copy the Redis URL
1464
+ `;
1465
+
1466
+ const parsed1 = parseSecretsManifest(original);
1467
+ const formatted = formatSecretsManifest(parsed1);
1468
+ const parsed2 = parseSecretsManifest(formatted);
1469
+
1470
+ // Verify semantic equality after round-trip
1471
+ assertEq(parsed2.milestone, parsed1.milestone, 'round-trip milestone');
1472
+ assertEq(parsed2.generatedAt, parsed1.generatedAt, 'round-trip generatedAt');
1473
+ assertEq(parsed2.entries.length, parsed1.entries.length, 'round-trip entry count');
1474
+
1475
+ for (let i = 0; i < parsed1.entries.length; i++) {
1476
+ const e1 = parsed1.entries[i];
1477
+ const e2 = parsed2.entries[i];
1478
+ assertEq(e2.key, e1.key, `round-trip entry ${i} key`);
1479
+ assertEq(e2.service, e1.service, `round-trip entry ${i} service`);
1480
+ assertEq(e2.dashboardUrl, e1.dashboardUrl, `round-trip entry ${i} dashboardUrl`);
1481
+ assertEq(e2.formatHint, e1.formatHint, `round-trip entry ${i} formatHint`);
1482
+ assertEq(e2.status, e1.status, `round-trip entry ${i} status`);
1483
+ assertEq(e2.destination, e1.destination, `round-trip entry ${i} destination`);
1484
+ assertEq(e2.guidance.length, e1.guidance.length, `round-trip entry ${i} guidance length`);
1485
+ for (let j = 0; j < e1.guidance.length; j++) {
1486
+ assertEq(e2.guidance[j], e1.guidance[j], `round-trip entry ${i} guidance[${j}]`);
1487
+ }
1488
+ }
1343
1489
  }
1344
1490
 
1345
1491
  // ═══════════════════════════════════════════════════════════════════════════
@@ -385,7 +385,6 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables
385
385
  sliceTitle: 'Test Slice',
386
386
  slicePath: '.gsd/milestones/M001/slices/S01',
387
387
  planPath: '.gsd/milestones/M001/slices/S01/S01-PLAN.md',
388
- blockerTaskId: 'T02',
389
388
  inlinedContext: '## Inlined Context\n\nTest context here.',
390
389
  });
391
390
 
@@ -393,7 +392,6 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables
393
392
  assert(prompt.includes('S01'), 'prompt contains sliceId');
394
393
  assert(prompt.includes('Test Slice'), 'prompt contains sliceTitle');
395
394
  assert(prompt.includes('.gsd/milestones/M001/slices/S01/S01-PLAN.md'), 'prompt contains planPath');
396
- assert(prompt.includes('T02'), 'prompt contains blockerTaskId');
397
395
  assert(prompt.includes('Test context here'), 'prompt contains inlined context');
398
396
  }
399
397
 
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Tests for secure_env_collect utility functions:
3
+ * - checkExistingEnvKeys: detects keys already present in .env file or process.env
4
+ * - detectDestination: infers write destination from project files
5
+ *
6
+ * Uses temp directories for filesystem isolation.
7
+ */
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+ import { checkExistingEnvKeys, detectDestination } from "../../get-secrets-from-user.ts";
15
+
16
+ function makeTempDir(prefix: string): string {
17
+ const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
18
+ mkdirSync(dir, { recursive: true });
19
+ return dir;
20
+ }
21
+
22
+ // ─── checkExistingEnvKeys ─────────────────────────────────────────────────────
23
+
24
+ test("secure_env_collect: checkExistingEnvKeys — key found in .env file", async () => {
25
+ const tmp = makeTempDir("sec-env-test");
26
+ try {
27
+ const envPath = join(tmp, ".env");
28
+ writeFileSync(envPath, "API_KEY=secret123\nOTHER=val\n");
29
+ const result = await checkExistingEnvKeys(["API_KEY"], envPath);
30
+ assert.deepStrictEqual(result, ["API_KEY"]);
31
+ } finally {
32
+ rmSync(tmp, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ test("secure_env_collect: checkExistingEnvKeys — key found in process.env", async () => {
37
+ const tmp = makeTempDir("sec-env-test");
38
+ const savedVal = process.env.GSD_TEST_ENV_KEY_12345;
39
+ try {
40
+ process.env.GSD_TEST_ENV_KEY_12345 = "some-value";
41
+ const envPath = join(tmp, ".env"); // file doesn't exist
42
+ const result = await checkExistingEnvKeys(["GSD_TEST_ENV_KEY_12345"], envPath);
43
+ assert.deepStrictEqual(result, ["GSD_TEST_ENV_KEY_12345"]);
44
+ } finally {
45
+ delete process.env.GSD_TEST_ENV_KEY_12345;
46
+ if (savedVal !== undefined) process.env.GSD_TEST_ENV_KEY_12345 = savedVal;
47
+ rmSync(tmp, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ test("secure_env_collect: checkExistingEnvKeys — key found in both .env and process.env", async () => {
52
+ const tmp = makeTempDir("sec-env-test");
53
+ const savedVal = process.env.GSD_TEST_BOTH_KEY;
54
+ try {
55
+ process.env.GSD_TEST_BOTH_KEY = "from-env";
56
+ const envPath = join(tmp, ".env");
57
+ writeFileSync(envPath, "GSD_TEST_BOTH_KEY=from-file\n");
58
+ const result = await checkExistingEnvKeys(["GSD_TEST_BOTH_KEY"], envPath);
59
+ assert.deepStrictEqual(result, ["GSD_TEST_BOTH_KEY"]);
60
+ } finally {
61
+ delete process.env.GSD_TEST_BOTH_KEY;
62
+ if (savedVal !== undefined) process.env.GSD_TEST_BOTH_KEY = savedVal;
63
+ rmSync(tmp, { recursive: true, force: true });
64
+ }
65
+ });
66
+
67
+ test("secure_env_collect: checkExistingEnvKeys — key not found anywhere", async () => {
68
+ const tmp = makeTempDir("sec-env-test");
69
+ try {
70
+ const envPath = join(tmp, ".env");
71
+ writeFileSync(envPath, "OTHER_KEY=val\n");
72
+ // Ensure it's not in process.env
73
+ delete process.env.DEFINITELY_NOT_SET_KEY_XYZ;
74
+ const result = await checkExistingEnvKeys(["DEFINITELY_NOT_SET_KEY_XYZ"], envPath);
75
+ assert.deepStrictEqual(result, []);
76
+ } finally {
77
+ rmSync(tmp, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ test("secure_env_collect: checkExistingEnvKeys — .env file doesn't exist (ENOENT), still checks process.env", async () => {
82
+ const tmp = makeTempDir("sec-env-test");
83
+ const savedVal = process.env.GSD_TEST_ENOENT_KEY;
84
+ try {
85
+ process.env.GSD_TEST_ENOENT_KEY = "exists-in-process";
86
+ const envPath = join(tmp, "nonexistent.env");
87
+ const result = await checkExistingEnvKeys(["GSD_TEST_ENOENT_KEY", "MISSING_KEY_XYZ"], envPath);
88
+ assert.deepStrictEqual(result, ["GSD_TEST_ENOENT_KEY"]);
89
+ } finally {
90
+ delete process.env.GSD_TEST_ENOENT_KEY;
91
+ if (savedVal !== undefined) process.env.GSD_TEST_ENOENT_KEY = savedVal;
92
+ rmSync(tmp, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("secure_env_collect: checkExistingEnvKeys — empty-string value in process.env counts as existing", async () => {
97
+ const tmp = makeTempDir("sec-env-test");
98
+ const savedVal = process.env.GSD_TEST_EMPTY_KEY;
99
+ try {
100
+ process.env.GSD_TEST_EMPTY_KEY = "";
101
+ const envPath = join(tmp, ".env");
102
+ writeFileSync(envPath, "");
103
+ const result = await checkExistingEnvKeys(["GSD_TEST_EMPTY_KEY"], envPath);
104
+ assert.deepStrictEqual(result, ["GSD_TEST_EMPTY_KEY"]);
105
+ } finally {
106
+ delete process.env.GSD_TEST_EMPTY_KEY;
107
+ if (savedVal !== undefined) process.env.GSD_TEST_EMPTY_KEY = savedVal;
108
+ rmSync(tmp, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ test("secure_env_collect: checkExistingEnvKeys — returns only existing keys from input list", async () => {
113
+ const tmp = makeTempDir("sec-env-test");
114
+ const saved1 = process.env.GSD_TEST_EXISTS_A;
115
+ const saved2 = process.env.GSD_TEST_EXISTS_B;
116
+ try {
117
+ process.env.GSD_TEST_EXISTS_A = "val-a";
118
+ delete process.env.GSD_TEST_EXISTS_B;
119
+ const envPath = join(tmp, ".env");
120
+ writeFileSync(envPath, "FILE_KEY=val\n");
121
+ const result = await checkExistingEnvKeys(
122
+ ["GSD_TEST_EXISTS_A", "GSD_TEST_EXISTS_B", "FILE_KEY", "NOPE_KEY"],
123
+ envPath,
124
+ );
125
+ assert.deepStrictEqual(result.sort(), ["FILE_KEY", "GSD_TEST_EXISTS_A"]);
126
+ } finally {
127
+ delete process.env.GSD_TEST_EXISTS_A;
128
+ delete process.env.GSD_TEST_EXISTS_B;
129
+ if (saved1 !== undefined) process.env.GSD_TEST_EXISTS_A = saved1;
130
+ if (saved2 !== undefined) process.env.GSD_TEST_EXISTS_B = saved2;
131
+ rmSync(tmp, { recursive: true, force: true });
132
+ }
133
+ });
134
+
135
+ // ─── detectDestination ────────────────────────────────────────────────────────
136
+
137
+ test("secure_env_collect: detectDestination — returns 'vercel' when vercel.json exists", () => {
138
+ const tmp = makeTempDir("sec-dest-test");
139
+ try {
140
+ writeFileSync(join(tmp, "vercel.json"), "{}");
141
+ assert.equal(detectDestination(tmp), "vercel");
142
+ } finally {
143
+ rmSync(tmp, { recursive: true, force: true });
144
+ }
145
+ });
146
+
147
+ test("secure_env_collect: detectDestination — returns 'convex' when convex/ dir exists", () => {
148
+ const tmp = makeTempDir("sec-dest-test");
149
+ try {
150
+ mkdirSync(join(tmp, "convex"));
151
+ assert.equal(detectDestination(tmp), "convex");
152
+ } finally {
153
+ rmSync(tmp, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("secure_env_collect: detectDestination — returns 'dotenv' when neither exists", () => {
158
+ const tmp = makeTempDir("sec-dest-test");
159
+ try {
160
+ assert.equal(detectDestination(tmp), "dotenv");
161
+ } finally {
162
+ rmSync(tmp, { recursive: true, force: true });
163
+ }
164
+ });
165
+
166
+ test("secure_env_collect: detectDestination — vercel takes priority when both exist", () => {
167
+ const tmp = makeTempDir("sec-dest-test");
168
+ try {
169
+ writeFileSync(join(tmp, "vercel.json"), "{}");
170
+ mkdirSync(join(tmp, "convex"));
171
+ assert.equal(detectDestination(tmp), "vercel");
172
+ } finally {
173
+ rmSync(tmp, { recursive: true, force: true });
174
+ }
175
+ });
176
+
177
+ test("secure_env_collect: detectDestination — convex file (not dir) does not trigger convex", () => {
178
+ const tmp = makeTempDir("sec-dest-test");
179
+ try {
180
+ writeFileSync(join(tmp, "convex"), "not a directory");
181
+ assert.equal(detectDestination(tmp), "dotenv");
182
+ } finally {
183
+ rmSync(tmp, { recursive: true, force: true });
184
+ }
185
+ });
@@ -116,6 +116,26 @@ export interface Continue {
116
116
  nextAction: string;
117
117
  }
118
118
 
119
+ // ─── Secrets Manifest ──────────────────────────────────────────────────────
120
+
121
+ export type SecretsManifestEntryStatus = 'pending' | 'collected' | 'skipped';
122
+
123
+ export interface SecretsManifestEntry {
124
+ key: string; // e.g. "OPENAI_API_KEY"
125
+ service: string; // e.g. "OpenAI"
126
+ dashboardUrl: string; // e.g. "https://platform.openai.com/api-keys" — empty if unknown
127
+ guidance: string[]; // numbered setup steps
128
+ formatHint: string; // e.g. "starts with sk-" — empty if unknown
129
+ status: SecretsManifestEntryStatus;
130
+ destination: string; // e.g. "dotenv", "vercel", "convex"
131
+ }
132
+
133
+ export interface SecretsManifest {
134
+ milestone: string; // e.g. "M001"
135
+ generatedAt: string; // ISO 8601 timestamp
136
+ entries: SecretsManifestEntry[];
137
+ }
138
+
119
139
  // ─── GSD State (Derived Dashboard) ────────────────────────────────────────
120
140
 
121
141
  export interface ActiveRef {
@@ -19,6 +19,7 @@ import {
19
19
  createWorktree,
20
20
  listWorktrees,
21
21
  removeWorktree,
22
+ mergeWorktreeToMain,
22
23
  diffWorktreeAll,
23
24
  diffWorktreeNumstat,
24
25
  getMainBranch,
@@ -28,7 +29,9 @@ import {
28
29
  worktreeBranchName,
29
30
  worktreePath,
30
31
  } from "./worktree-manager.js";
32
+ import { inferCommitType } from "./git-service.js";
31
33
  import type { FileLineStat } from "./worktree-manager.js";
34
+ import { execSync } from "node:child_process";
32
35
  import { existsSync, realpathSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs";
33
36
  import { join, resolve, sep } from "node:path";
34
37
 
@@ -349,13 +352,14 @@ async function handleCreate(
349
352
  ctx: ExtensionCommandContext,
350
353
  ): Promise<void> {
351
354
  try {
355
+ // Auto-commit dirty files before leaving current workspace (must happen
356
+ // before createWorktree so the new worktree forks from committed HEAD)
357
+ const commitMsg = autoCommitCurrentBranch(basePath, "worktree-switch", name);
358
+
352
359
  // Create from the main tree, not from inside another worktree
353
360
  const mainBase = originalCwd ?? basePath;
354
361
  const info = createWorktree(mainBase, name);
355
362
 
356
- // Auto-commit dirty files before leaving current workspace
357
- const commitMsg = autoCommitCurrentBranch(basePath, "worktree-switch", name);
358
-
359
363
  // Track original cwd before switching
360
364
  if (!originalCwd) originalCwd = basePath;
361
365
 
@@ -655,9 +659,8 @@ async function handleMerge(
655
659
  return;
656
660
  }
657
661
 
658
- // Switch to the main tree before dispatching the merge.
659
- // The LLM needs to run git merge --squash from the main branch, and if
660
- // it later removes the worktree, the agent's CWD must not be inside it.
662
+ // Switch to the main tree before merging.
663
+ // Must be on the main branch to run git merge --squash.
661
664
  if (originalCwd) {
662
665
  const prevCwd = process.cwd();
663
666
  process.chdir(basePath);
@@ -665,6 +668,45 @@ async function handleMerge(
665
668
  originalCwd = null;
666
669
  }
667
670
 
671
+ // --- Deterministic merge path (preferred) ---
672
+ // Try a direct squash-merge first. Only fall back to LLM on conflict.
673
+ const commitType = inferCommitType(name);
674
+ const commitMessage = `${commitType}(${name}): merge worktree ${name}`;
675
+ try {
676
+ mergeWorktreeToMain(basePath, name, commitMessage);
677
+ ctx.ui.notify(
678
+ [
679
+ `${CLR.ok("✓")} Merged ${CLR.name(name)} → ${CLR.branch(mainBranch)} ${CLR.muted("(deterministic squash)")}`,
680
+ "",
681
+ ` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${CLR.ok(`+${totalAdded}`)} ${RED}-${totalRemoved}${RESET} lines`,
682
+ ` ${CLR.muted("commit:")} ${commitMessage}`,
683
+ ].join("\n"),
684
+ "info",
685
+ );
686
+ return;
687
+ } catch (mergeErr) {
688
+ const mergeMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
689
+ const isConflict = /conflict/i.test(mergeMsg);
690
+
691
+ if (isConflict) {
692
+ // Abort the failed merge so the working tree is clean for LLM retry
693
+ try {
694
+ execSync("git merge --abort", { cwd: basePath, stdio: "pipe" });
695
+ } catch { /* already clean */ }
696
+
697
+ ctx.ui.notify(
698
+ `${CLR.muted("Deterministic merge hit conflicts — falling back to LLM-guided merge.")}`,
699
+ "warning",
700
+ );
701
+ // Fall through to LLM dispatch below
702
+ } else {
703
+ // Non-conflict error — surface it directly, don't fall back
704
+ ctx.ui.notify(`Failed to merge: ${mergeMsg}`, "error");
705
+ return;
706
+ }
707
+ }
708
+
709
+ // --- LLM fallback path (conflict resolution) ---
668
710
  // Format file lists for the prompt
669
711
  const formatFiles = (files: string[]) =>
670
712
  files.length > 0 ? files.map(f => `- \`${f}\``).join("\n") : "_(none)_";