gsd-pi 2.5.0 → 2.6.0

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 (33) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +7 -1
  3. package/dist/loader.js +21 -3
  4. package/dist/logo.d.ts +3 -3
  5. package/dist/logo.js +2 -2
  6. package/package.json +1 -1
  7. package/src/resources/extensions/get-secrets-from-user.ts +331 -59
  8. package/src/resources/extensions/gsd/auto.ts +80 -18
  9. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  10. package/src/resources/extensions/gsd/doctor.ts +23 -4
  11. package/src/resources/extensions/gsd/files.ts +115 -1
  12. package/src/resources/extensions/gsd/git-service.ts +67 -105
  13. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  14. package/src/resources/extensions/gsd/guided-flow.ts +6 -3
  15. package/src/resources/extensions/gsd/preferences.ts +8 -0
  16. package/src/resources/extensions/gsd/prompts/complete-slice.md +7 -5
  17. package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
  18. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -6
  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/session-forensics.ts +19 -6
  23. package/src/resources/extensions/gsd/templates/plan.md +8 -10
  24. package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
  25. package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
  26. package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +196 -0
  27. package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +469 -0
  28. package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +170 -0
  29. package/src/resources/extensions/gsd/tests/git-service.test.ts +106 -0
  30. package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
  31. package/src/resources/extensions/gsd/tests/parsers.test.ts +401 -65
  32. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
  33. package/src/resources/extensions/gsd/types.ts +27 -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,433 @@ 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
+ }
1489
+ }
1490
+
1491
+ // ═══════════════════════════════════════════════════════════════════════════
1492
+ // LLM-style round-trip tests — realistic manifest variations
1493
+ // ═══════════════════════════════════════════════════════════════════════════
1494
+
1495
+ console.log('\n=== LLM round-trip: extra whitespace ===');
1496
+ {
1497
+ // LLMs often produce inconsistent indentation and trailing spaces
1498
+ const messy = `# Secrets Manifest
1499
+
1500
+ **Milestone:** M010
1501
+ **Generated:** 2025-07-01T12:00:00Z
1502
+
1503
+ ### OPENAI_API_KEY
1504
+
1505
+ **Service:** OpenAI
1506
+ **Dashboard:** https://platform.openai.com/api-keys
1507
+ **Format hint:** starts with sk-
1508
+ **Status:** pending
1509
+ **Destination:** dotenv
1510
+
1511
+ 1. Go to the API keys page
1512
+ 2. Create a new key
1513
+
1514
+ ### REDIS_URL
1515
+
1516
+ **Service:** Upstash
1517
+ **Status:** collected
1518
+ **Destination:** vercel
1519
+
1520
+ 1. Open console
1521
+ `;
1522
+
1523
+ const parsed1 = parseSecretsManifest(messy);
1524
+ const formatted = formatSecretsManifest(parsed1);
1525
+ const parsed2 = parseSecretsManifest(formatted);
1526
+
1527
+ assertEq(parsed2.milestone, parsed1.milestone, 'whitespace round-trip milestone');
1528
+ assertEq(parsed2.generatedAt, parsed1.generatedAt, 'whitespace round-trip generatedAt');
1529
+ assertEq(parsed2.entries.length, parsed1.entries.length, 'whitespace round-trip entry count');
1530
+ assertEq(parsed2.entries.length, 2, 'whitespace: two entries parsed');
1531
+
1532
+ for (let i = 0; i < parsed1.entries.length; i++) {
1533
+ const e1 = parsed1.entries[i];
1534
+ const e2 = parsed2.entries[i];
1535
+ assertEq(e2.key, e1.key, `whitespace round-trip entry ${i} key`);
1536
+ assertEq(e2.service, e1.service, `whitespace round-trip entry ${i} service`);
1537
+ assertEq(e2.dashboardUrl, e1.dashboardUrl, `whitespace round-trip entry ${i} dashboardUrl`);
1538
+ assertEq(e2.formatHint, e1.formatHint, `whitespace round-trip entry ${i} formatHint`);
1539
+ assertEq(e2.status, e1.status, `whitespace round-trip entry ${i} status`);
1540
+ assertEq(e2.destination, e1.destination, `whitespace round-trip entry ${i} destination`);
1541
+ assertEq(e2.guidance.length, e1.guidance.length, `whitespace round-trip entry ${i} guidance length`);
1542
+ for (let j = 0; j < e1.guidance.length; j++) {
1543
+ assertEq(e2.guidance[j], e1.guidance[j], `whitespace round-trip entry ${i} guidance[${j}]`);
1544
+ }
1545
+ }
1546
+
1547
+ // Verify the parser correctly stripped trailing whitespace
1548
+ assertEq(parsed1.milestone, 'M010', 'whitespace: milestone trimmed');
1549
+ assertEq(parsed1.entries[0].key, 'OPENAI_API_KEY', 'whitespace: key trimmed');
1550
+ assertEq(parsed1.entries[0].service, 'OpenAI', 'whitespace: service trimmed');
1551
+ }
1552
+
1553
+ console.log('\n=== LLM round-trip: missing optional fields ===');
1554
+ {
1555
+ // LLMs may omit Dashboard and Format hint lines entirely
1556
+ const minimal = `# Secrets Manifest
1557
+
1558
+ **Milestone:** M011
1559
+ **Generated:** 2025-07-02T08:00:00Z
1560
+
1561
+ ### DATABASE_URL
1562
+
1563
+ **Service:** Neon
1564
+ **Status:** pending
1565
+ **Destination:** dotenv
1566
+
1567
+ 1. Create a Neon project
1568
+ 2. Copy connection string
1569
+
1570
+ ### WEBHOOK_SECRET
1571
+
1572
+ **Service:** Stripe
1573
+ **Status:** collected
1574
+ **Destination:** dotenv
1575
+
1576
+ 1. Go to webhooks
1577
+ `;
1578
+
1579
+ const parsed1 = parseSecretsManifest(minimal);
1580
+
1581
+ // Verify missing optional fields get defaults
1582
+ assertEq(parsed1.entries[0].dashboardUrl, '', 'missing-optional: no dashboard → empty string');
1583
+ assertEq(parsed1.entries[0].formatHint, '', 'missing-optional: no format hint → empty string');
1584
+ assertEq(parsed1.entries[1].dashboardUrl, '', 'missing-optional: entry 2 no dashboard → empty string');
1585
+ assertEq(parsed1.entries[1].formatHint, '', 'missing-optional: entry 2 no format hint → empty string');
1586
+
1587
+ // Round-trip: formatter omits empty optional fields, re-parse preserves defaults
1588
+ const formatted = formatSecretsManifest(parsed1);
1589
+ const parsed2 = parseSecretsManifest(formatted);
1590
+
1591
+ assertEq(parsed2.entries.length, parsed1.entries.length, 'missing-optional round-trip entry count');
1592
+
1593
+ for (let i = 0; i < parsed1.entries.length; i++) {
1594
+ const e1 = parsed1.entries[i];
1595
+ const e2 = parsed2.entries[i];
1596
+ assertEq(e2.key, e1.key, `missing-optional round-trip entry ${i} key`);
1597
+ assertEq(e2.service, e1.service, `missing-optional round-trip entry ${i} service`);
1598
+ assertEq(e2.dashboardUrl, e1.dashboardUrl, `missing-optional round-trip entry ${i} dashboardUrl`);
1599
+ assertEq(e2.formatHint, e1.formatHint, `missing-optional round-trip entry ${i} formatHint`);
1600
+ assertEq(e2.status, e1.status, `missing-optional round-trip entry ${i} status`);
1601
+ assertEq(e2.destination, e1.destination, `missing-optional round-trip entry ${i} destination`);
1602
+ assertEq(e2.guidance.length, e1.guidance.length, `missing-optional round-trip entry ${i} guidance length`);
1603
+ }
1604
+ }
1605
+
1606
+ console.log('\n=== LLM round-trip: extra blank lines ===');
1607
+ {
1608
+ // LLMs sometimes insert excessive blank lines between sections
1609
+ const blanky = `# Secrets Manifest
1610
+
1611
+
1612
+ **Milestone:** M012
1613
+ **Generated:** 2025-07-03T14:00:00Z
1614
+
1615
+
1616
+
1617
+ ### API_KEY_ONE
1618
+
1619
+
1620
+ **Service:** ServiceOne
1621
+ **Dashboard:** https://one.example.com
1622
+
1623
+
1624
+ **Format hint:** key_...
1625
+ **Status:** pending
1626
+ **Destination:** dotenv
1627
+
1628
+
1629
+
1630
+ 1. Go to settings
1631
+
1632
+
1633
+ 2. Generate key
1634
+
1635
+
1636
+
1637
+ ### API_KEY_TWO
1638
+
1639
+
1640
+
1641
+ **Service:** ServiceTwo
1642
+ **Status:** skipped
1643
+ **Destination:** dotenv
1644
+
1645
+
1646
+ 1. Not needed
1647
+ `;
1648
+
1649
+ const parsed1 = parseSecretsManifest(blanky);
1650
+
1651
+ assertEq(parsed1.entries.length, 2, 'blank-lines: two entries parsed');
1652
+ assertEq(parsed1.milestone, 'M012', 'blank-lines: milestone parsed');
1653
+ assertEq(parsed1.entries[0].key, 'API_KEY_ONE', 'blank-lines: first key');
1654
+ assertEq(parsed1.entries[0].guidance.length, 2, 'blank-lines: first entry guidance count');
1655
+ assertEq(parsed1.entries[1].key, 'API_KEY_TWO', 'blank-lines: second key');
1656
+ assertEq(parsed1.entries[1].status, 'skipped', 'blank-lines: second entry status');
1657
+
1658
+ // Round-trip produces clean output
1659
+ const formatted = formatSecretsManifest(parsed1);
1660
+ const parsed2 = parseSecretsManifest(formatted);
1661
+
1662
+ assertEq(parsed2.entries.length, parsed1.entries.length, 'blank-lines round-trip entry count');
1663
+
1664
+ for (let i = 0; i < parsed1.entries.length; i++) {
1665
+ const e1 = parsed1.entries[i];
1666
+ const e2 = parsed2.entries[i];
1667
+ assertEq(e2.key, e1.key, `blank-lines round-trip entry ${i} key`);
1668
+ assertEq(e2.service, e1.service, `blank-lines round-trip entry ${i} service`);
1669
+ assertEq(e2.dashboardUrl, e1.dashboardUrl, `blank-lines round-trip entry ${i} dashboardUrl`);
1670
+ assertEq(e2.formatHint, e1.formatHint, `blank-lines round-trip entry ${i} formatHint`);
1671
+ assertEq(e2.status, e1.status, `blank-lines round-trip entry ${i} status`);
1672
+ assertEq(e2.destination, e1.destination, `blank-lines round-trip entry ${i} destination`);
1673
+ assertEq(e2.guidance.length, e1.guidance.length, `blank-lines round-trip entry ${i} guidance length`);
1674
+ }
1675
+
1676
+ // Verify the formatted output is cleaner (fewer consecutive blank lines)
1677
+ const consecutiveBlanks = formatted.match(/\n{4,}/g);
1678
+ assert(consecutiveBlanks === null, 'blank-lines: formatted output has no 4+ consecutive newlines');
1343
1679
  }
1344
1680
 
1345
1681
  // ═══════════════════════════════════════════════════════════════════════════