gsd-pi 2.23.0 → 2.24.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 (121) hide show
  1. package/dist/cli.js +12 -3
  2. package/dist/headless.d.ts +4 -0
  3. package/dist/headless.js +118 -10
  4. package/dist/help-text.js +22 -7
  5. package/dist/resource-loader.js +64 -9
  6. package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
  7. package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
  8. package/dist/resources/extensions/gsd/auto-recovery.ts +41 -2
  9. package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
  10. package/dist/resources/extensions/gsd/auto.ts +123 -41
  11. package/dist/resources/extensions/gsd/commands.ts +176 -10
  12. package/dist/resources/extensions/gsd/complexity.ts +1 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  14. package/dist/resources/extensions/gsd/doctor.ts +56 -11
  15. package/dist/resources/extensions/gsd/exit-command.ts +2 -2
  16. package/dist/resources/extensions/gsd/gitignore.ts +1 -0
  17. package/dist/resources/extensions/gsd/guided-flow.ts +75 -0
  18. package/dist/resources/extensions/gsd/index.ts +34 -1
  19. package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  20. package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
  21. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  22. package/dist/resources/extensions/gsd/preferences.ts +65 -1
  23. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  24. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  25. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  26. package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
  27. package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
  28. package/dist/resources/extensions/gsd/state.ts +72 -30
  29. package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  30. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  31. package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  32. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
  33. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  34. package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  35. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  36. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  37. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  38. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  39. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  40. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  41. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  42. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  43. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  44. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  45. package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  46. package/dist/resources/extensions/gsd/types.ts +15 -1
  47. package/dist/resources/extensions/subagent/index.ts +5 -0
  48. package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
  49. package/dist/update-check.d.ts +9 -0
  50. package/dist/update-check.js +97 -0
  51. package/package.json +6 -1
  52. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  53. package/packages/pi-ai/dist/providers/anthropic.js +16 -7
  54. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  55. package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
  56. package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
  57. package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
  58. package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
  59. package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
  60. package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
  61. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  62. package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
  63. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  64. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  65. package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
  66. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  67. package/packages/pi-ai/src/providers/anthropic.ts +21 -8
  68. package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
  69. package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
  70. package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
  71. package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
  72. package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
  73. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  74. package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
  75. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  76. package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
  77. package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
  78. package/scripts/postinstall.js +7 -109
  79. package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
  80. package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
  81. package/src/resources/extensions/gsd/auto-recovery.ts +41 -2
  82. package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
  83. package/src/resources/extensions/gsd/auto.ts +123 -41
  84. package/src/resources/extensions/gsd/commands.ts +176 -10
  85. package/src/resources/extensions/gsd/complexity.ts +1 -0
  86. package/src/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  87. package/src/resources/extensions/gsd/doctor.ts +56 -11
  88. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  89. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  90. package/src/resources/extensions/gsd/guided-flow.ts +75 -0
  91. package/src/resources/extensions/gsd/index.ts +34 -1
  92. package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  93. package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
  94. package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  95. package/src/resources/extensions/gsd/preferences.ts +65 -1
  96. package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  97. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  98. package/src/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  99. package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
  100. package/src/resources/extensions/gsd/session-status-io.ts +197 -0
  101. package/src/resources/extensions/gsd/state.ts +72 -30
  102. package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  103. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  104. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  105. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
  106. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  107. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  108. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  109. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  110. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  111. package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  112. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  113. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  114. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  115. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  116. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  117. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  118. package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  119. package/src/resources/extensions/gsd/types.ts +15 -1
  120. package/src/resources/extensions/subagent/index.ts +5 -0
  121. package/src/resources/extensions/subagent/worker-registry.ts +99 -0
@@ -17,6 +17,7 @@ writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-PLAN.md"), `
17
17
  writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# T01: Old Task\n\n**Done**\n\n## What Happened\nDone.\n\n## Diagnostics\n- log\n`);
18
18
  writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"), `---\nid: S01\nparent: M001\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# S01: Old Slice\n\n**Done**\n\n## What Happened\nDone.\n\n## Verification\nDone.\n\n## Deviations\nNone\n\n## Known Limitations\nNone\n\n## Follow-ups\nNone\n\n## Files Created/Modified\n- \`x\` — x\n\n## Forward Intelligence\n\n### What the next slice should know\n- x\n\n### What's fragile\n- x\n\n### Authoritative diagnostics\n- x\n\n### What assumptions changed\n- x\n`);
19
19
 
20
+ writeFileSync(join(gsd, "milestones", "M001", "M001-VALIDATION.md"), `---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nPassed.\n`);
20
21
  writeFileSync(join(gsd, "milestones", "M001", "M001-SUMMARY.md"), `---\nid: M001\nstatus: complete\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# M001: Historical\n\nComplete.\n`);
21
22
 
22
23
  writeFileSync(join(gsd, "milestones", "M009", "M009-ROADMAP.md"), `# M009: Active\n\n## Slices\n- [ ] **S01: Active Slice** \`risk:low\` \`depends:[]\`\n > After this: active works\n`);
@@ -10,6 +10,7 @@ import {
10
10
  verifyExpectedArtifact,
11
11
  diagnoseExpectedArtifact,
12
12
  buildLoopRemediationSteps,
13
+ selfHealRuntimeRecords,
13
14
  completedKeysPath,
14
15
  persistCompletedKey,
15
16
  removePersistedKey,
@@ -273,6 +274,68 @@ test("removePersistedKey is safe when file doesn't exist", () => {
273
274
  }
274
275
  });
275
276
 
277
+ // ─── Dual-load across worktree boundary (#769) ───────────────────────────
278
+
279
+ test("loadPersistedKeys unions keys from project root and worktree", () => {
280
+ // Simulate two separate .gsd directories (project root + worktree)
281
+ // each with a different set of completed keys. Loading from both
282
+ // into the same Set should produce the union.
283
+ const projectRoot = makeTmpBase();
284
+ const worktree = makeTmpBase();
285
+ try {
286
+ // Persist different keys in each location
287
+ persistCompletedKey(projectRoot, "execute-task/M001/S01/T01");
288
+ persistCompletedKey(projectRoot, "plan-slice/M001/S02");
289
+
290
+ persistCompletedKey(worktree, "execute-task/M001/S01/T02");
291
+ persistCompletedKey(worktree, "plan-slice/M001/S02"); // overlap
292
+
293
+ // Load from both into the same set (mimicking startup dual-load)
294
+ const keys = new Set<string>();
295
+ loadPersistedKeys(projectRoot, keys);
296
+ loadPersistedKeys(worktree, keys);
297
+
298
+ assert.ok(keys.has("execute-task/M001/S01/T01"), "key from project root");
299
+ assert.ok(keys.has("plan-slice/M001/S02"), "shared key");
300
+ assert.ok(keys.has("execute-task/M001/S01/T02"), "key from worktree");
301
+ assert.equal(keys.size, 3, "union should deduplicate overlapping keys");
302
+ } finally {
303
+ cleanup(projectRoot);
304
+ cleanup(worktree);
305
+ }
306
+ });
307
+
308
+ test("completed-units.json set-union merge produces correct result", () => {
309
+ // Verify that a manual set-union merge (as done in syncStateToProjectRoot)
310
+ // correctly merges two JSON arrays of keys.
311
+ const projectRoot = makeTmpBase();
312
+ const worktree = makeTmpBase();
313
+ try {
314
+ // Write keys to both locations
315
+ const prKeysFile = join(projectRoot, ".gsd", "completed-units.json");
316
+ const wtKeysFile = join(worktree, ".gsd", "completed-units.json");
317
+
318
+ writeFileSync(prKeysFile, JSON.stringify(["a", "b"]));
319
+ writeFileSync(wtKeysFile, JSON.stringify(["b", "c", "d"]));
320
+
321
+ // Perform the same merge logic used in syncStateToProjectRoot
322
+ const srcKeys: string[] = JSON.parse(readFileSync(wtKeysFile, "utf8"));
323
+ let dstKeys: string[] = [];
324
+ if (existsSync(prKeysFile)) {
325
+ dstKeys = JSON.parse(readFileSync(prKeysFile, "utf8"));
326
+ }
327
+ const merged = [...new Set([...dstKeys, ...srcKeys])];
328
+ writeFileSync(prKeysFile, JSON.stringify(merged, null, 2));
329
+
330
+ // Verify the merged result
331
+ const result: string[] = JSON.parse(readFileSync(prKeysFile, "utf8"));
332
+ assert.deepStrictEqual(result.sort(), ["a", "b", "c", "d"]);
333
+ } finally {
334
+ cleanup(projectRoot);
335
+ cleanup(worktree);
336
+ }
337
+ });
338
+
276
339
  // ─── verifyExpectedArtifact: parse cache collision regression ─────────────
277
340
 
278
341
  test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", () => {
@@ -343,7 +406,8 @@ test("verifyExpectedArtifact accepts plan-slice with actual tasks", () => {
343
406
  const base = makeTmpBase();
344
407
  try {
345
408
  const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
346
- mkdirSync(sliceDir, { recursive: true });
409
+ const tasksDir = join(sliceDir, "tasks");
410
+ mkdirSync(tasksDir, { recursive: true });
347
411
  writeFileSync(join(sliceDir, "S01-PLAN.md"), [
348
412
  "# S01: Test Slice",
349
413
  "",
@@ -352,6 +416,8 @@ test("verifyExpectedArtifact accepts plan-slice with actual tasks", () => {
352
416
  "- [ ] **T01: Implement feature** `est:2h`",
353
417
  "- [ ] **T02: Write tests** `est:1h`",
354
418
  ].join("\n"));
419
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
420
+ writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan");
355
421
  assert.strictEqual(
356
422
  verifyExpectedArtifact("plan-slice", "M001/S01", base),
357
423
  true,
@@ -366,7 +432,8 @@ test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => {
366
432
  const base = makeTmpBase();
367
433
  try {
368
434
  const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
369
- mkdirSync(sliceDir, { recursive: true });
435
+ const tasksDir = join(sliceDir, "tasks");
436
+ mkdirSync(tasksDir, { recursive: true });
370
437
  writeFileSync(join(sliceDir, "S01-PLAN.md"), [
371
438
  "# S01: Test Slice",
372
439
  "",
@@ -375,6 +442,8 @@ test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => {
375
442
  "- [x] **T01: Implement feature** `est:2h`",
376
443
  "- [ ] **T02: Write tests** `est:1h`",
377
444
  ].join("\n"));
445
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
446
+ writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan");
378
447
  assert.strictEqual(
379
448
  verifyExpectedArtifact("plan-slice", "M001/S01", base),
380
449
  true,
@@ -384,3 +453,134 @@ test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => {
384
453
  cleanup(base);
385
454
  }
386
455
  });
456
+
457
+ // ─── verifyExpectedArtifact: plan-slice task plan check (#739) ────────────
458
+
459
+ test("verifyExpectedArtifact plan-slice passes when all task plan files exist", () => {
460
+ const base = makeTmpBase();
461
+ try {
462
+ const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
463
+ const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
464
+ const planContent = [
465
+ "# S01: Test Slice",
466
+ "",
467
+ "## Tasks",
468
+ "",
469
+ "- [ ] **T01: First task** `est:1h`",
470
+ "- [ ] **T02: Second task** `est:2h`",
471
+ ].join("\n");
472
+ writeFileSync(planPath, planContent);
473
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.");
474
+ writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan\n\nDo the other thing.");
475
+
476
+ const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
477
+ assert.equal(result, true, "should pass when all task plan files exist");
478
+ } finally {
479
+ cleanup(base);
480
+ }
481
+ });
482
+
483
+ test("verifyExpectedArtifact plan-slice fails when a task plan file is missing (#739)", () => {
484
+ const base = makeTmpBase();
485
+ try {
486
+ const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
487
+ const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
488
+ const planContent = [
489
+ "# S01: Test Slice",
490
+ "",
491
+ "## Tasks",
492
+ "",
493
+ "- [ ] **T01: First task** `est:1h`",
494
+ "- [ ] **T02: Second task** `est:2h`",
495
+ ].join("\n");
496
+ writeFileSync(planPath, planContent);
497
+ // Only write T01-PLAN.md — T02 is missing
498
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.");
499
+
500
+ const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
501
+ assert.equal(result, false, "should fail when T02-PLAN.md is missing");
502
+ } finally {
503
+ cleanup(base);
504
+ }
505
+ });
506
+
507
+ test("verifyExpectedArtifact plan-slice fails for plan with no tasks (#699)", () => {
508
+ const base = makeTmpBase();
509
+ try {
510
+ const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
511
+ const planContent = [
512
+ "# S01: Test Slice",
513
+ "",
514
+ "## Goal",
515
+ "",
516
+ "Just some documentation updates, no tasks.",
517
+ ].join("\n");
518
+ writeFileSync(planPath, planContent);
519
+
520
+ const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
521
+ assert.equal(result, false, "should fail when plan has no task entries (empty scaffold, #699)");
522
+ } finally {
523
+ cleanup(base);
524
+ }
525
+ });
526
+
527
+ // ─── selfHealRuntimeRecords — worktree base path (#769) ──────────────────
528
+
529
+ test("selfHealRuntimeRecords clears stale record when artifact exists at worktree base (#769)", async () => {
530
+ // Simulate worktree layout: the runtime record AND the artifact both live
531
+ // under the worktree's .gsd/, not the main project root.
532
+ const worktreeBase = makeTmpBase();
533
+ const mainBase = makeTmpBase();
534
+ try {
535
+ const { writeUnitRuntimeRecord, readUnitRuntimeRecord } = await import("../unit-runtime.ts");
536
+
537
+ // Write a stale runtime record in the worktree .gsd/runtime/units/
538
+ writeUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01", Date.now() - 7200_000, {
539
+ phase: "dispatched",
540
+ });
541
+
542
+ // Write the UAT result artifact in the worktree .gsd/milestones/
543
+ const uatPath = join(worktreeBase, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT-RESULT.md");
544
+ writeFileSync(uatPath, "---\nresult: pass\n---\n# UAT Result\nAll tests passed.\n");
545
+
546
+ // Verify the runtime record exists before heal
547
+ const before = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01");
548
+ assert.ok(before, "runtime record should exist before heal");
549
+
550
+ // Mock ExtensionContext with minimal notify
551
+ const notifications: string[] = [];
552
+ const mockCtx = {
553
+ ui: { notify: (msg: string) => { notifications.push(msg); } },
554
+ } as any;
555
+
556
+ // Call selfHeal with worktreeBase — this is the fix: using the worktree path
557
+ // so both the runtime record and artifact are found
558
+ const completedKeys = new Set<string>();
559
+ await selfHealRuntimeRecords(worktreeBase, mockCtx, completedKeys);
560
+
561
+ // The stale record should be cleared
562
+ const after = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01");
563
+ assert.equal(after, null, "runtime record should be cleared after heal");
564
+
565
+ // The completion key should be persisted
566
+ assert.ok(completedKeys.has("run-uat/M001/S01"), "completion key should be added");
567
+ assert.ok(notifications.some(n => n.includes("Self-heal")), "should emit self-heal notification");
568
+
569
+ // Now verify that calling with mainBase does NOT find/clear anything (the old bug)
570
+ // Write a stale record at mainBase but NO artifact there
571
+ writeUnitRuntimeRecord(mainBase, "run-uat", "M001/S01", Date.now() - 7200_000, {
572
+ phase: "dispatched",
573
+ });
574
+ const mainKeys = new Set<string>();
575
+ await selfHealRuntimeRecords(mainBase, mockCtx, mainKeys);
576
+
577
+ // The record at mainBase should be cleared by the stale timeout (>1h),
578
+ // but the completion key should NOT be set (artifact doesn't exist at mainBase)
579
+ const afterMain = readUnitRuntimeRecord(mainBase, "run-uat", "M001/S01");
580
+ assert.equal(afterMain, null, "stale record at main base should be cleared by timeout");
581
+ assert.ok(!mainKeys.has("run-uat/M001/S01"), "completion key should NOT be set when artifact is missing");
582
+ } finally {
583
+ cleanup(worktreeBase);
584
+ cleanup(mainBase);
585
+ }
586
+ });
@@ -290,6 +290,40 @@ async function main(): Promise<void> {
290
290
  assertTrue(existsSync(join(repo, "feature.ts")), "feature.ts merged to main");
291
291
  }
292
292
 
293
+ // ─── Test 6: Skip checkout when main already current (#757) ───────
294
+ console.log("\n=== skip checkout when main already current (#757) ===");
295
+ {
296
+ const repo = freshRepo();
297
+ const wtPath = createAutoWorktree(repo, "M060");
298
+
299
+ addSliceToMilestone(repo, wtPath, "M060", "S01", "Skip checkout test", [
300
+ { file: "skip-checkout.ts", content: "export const skip = true;\n", message: "add skip-checkout" },
301
+ ]);
302
+
303
+ const roadmap = makeRoadmap("M060", "Skip checkout verification", [
304
+ { id: "S01", title: "Skip checkout test" },
305
+ ]);
306
+
307
+ // Verify main is already checked out at repo root (worktree default)
308
+ const branchAtRoot = run("git rev-parse --abbrev-ref HEAD", repo);
309
+ assertEq(branchAtRoot, "main", "main is already checked out at project root");
310
+
311
+ // mergeMilestoneToMain should succeed without attempting to checkout main
312
+ // (which would fail with "already used by worktree" error)
313
+ let threw = false;
314
+ try {
315
+ const result = mergeMilestoneToMain(repo, "M060", roadmap);
316
+ assertTrue(result.commitMessage.includes("feat(M060)"), "merge commit created");
317
+ } catch (err) {
318
+ threw = true;
319
+ console.error("Unexpected error:", err);
320
+ }
321
+ assertTrue(!threw, "does not fail when main is already checked out at project root");
322
+
323
+ // Verify the merge actually happened
324
+ assertTrue(existsSync(join(repo, "skip-checkout.ts")), "skip-checkout.ts merged to main");
325
+ }
326
+
293
327
  } finally {
294
328
  process.chdir(savedCwd);
295
329
  for (const d of tempDirs) {
@@ -45,6 +45,12 @@ function writeMilestoneSummary(base: string, mid: string, content: string): void
45
45
  writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
46
46
  }
47
47
 
48
+ function writeMilestoneValidation(base: string, mid: string, verdict: string = "pass"): void {
49
+ const dir = join(base, ".gsd", "milestones", mid);
50
+ mkdirSync(dir, { recursive: true });
51
+ writeFileSync(join(dir, `${mid}-VALIDATION.md`), `---\nverdict: ${verdict}\nremediation_round: 0\n---\n\n# Validation\nValidated.`);
52
+ }
53
+
48
54
  function cleanup(base: string): void {
49
55
  rmSync(base, { recursive: true, force: true });
50
56
  }
@@ -176,7 +182,8 @@ async function main(): Promise<void> {
176
182
  const roadmap = parseRoadmap(roadmapContent!);
177
183
  assertTrue(isMilestoneComplete(roadmap), "isMilestoneComplete returns true when all slices are [x]");
178
184
 
179
- // Verify deriveState returns completing-milestone phase
185
+ // Verify deriveState returns completing-milestone phase (with validation already done)
186
+ writeMilestoneValidation(base, "M001");
180
187
  const state = await deriveState(base);
181
188
  assertEq(state.phase, "completing-milestone", "deriveState returns completing-milestone when all slices done, no summary");
182
189
  assertEq(state.activeMilestone?.id, "M001", "active milestone is M001");
@@ -248,31 +248,24 @@ async function main(): Promise<void> {
248
248
  }
249
249
  }
250
250
 
251
- // ─── Test 5: Requirements counting from DB content ────────────────────
252
- console.log('\n=== derive-state-db: requirements from DB content ===');
251
+ // ─── Test 5: Requirements counting from disk (DB no longer used for content)
252
+ console.log('\n=== derive-state-db: requirements from disk content ===');
253
253
  {
254
254
  const base = createFixtureBase();
255
255
  try {
256
256
  // Write minimal milestone dir (needed for milestone discovery)
257
257
  mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
258
- // Do NOT write REQUIREMENTS.md to disk only in DB
259
-
260
- openDatabase(':memory:');
261
- insertArtifactRow('REQUIREMENTS.md', REQUIREMENTS_CONTENT, {
262
- artifact_type: 'requirements',
263
- });
258
+ // Write REQUIREMENTS.md to disk (DB content is no longer used by deriveState)
259
+ writeFile(base, 'REQUIREMENTS.md', REQUIREMENTS_CONTENT);
264
260
 
265
261
  invalidateStateCache();
266
262
  const state = await deriveState(base);
267
263
 
268
- // Requirements should come from DB
269
- assertEq(state.requirements?.active, 2, 'req-from-db: requirements.active = 2');
270
- assertEq(state.requirements?.validated, 1, 'req-from-db: requirements.validated = 1');
271
- assertEq(state.requirements?.total, 3, 'req-from-db: requirements.total = 3');
272
-
273
- closeDatabase();
264
+ // Requirements should come from disk
265
+ assertEq(state.requirements?.active, 2, 'req-from-disk: requirements.active = 2');
266
+ assertEq(state.requirements?.validated, 1, 'req-from-disk: requirements.validated = 1');
267
+ assertEq(state.requirements?.total, 3, 'req-from-disk: requirements.total = 3');
274
268
  } finally {
275
- closeDatabase();
276
269
  cleanup(base);
277
270
  }
278
271
  }
@@ -310,6 +303,7 @@ async function main(): Promise<void> {
310
303
  mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
311
304
  mkdirSync(join(base, '.gsd', 'milestones', 'M002'), { recursive: true });
312
305
  writeFile(base, 'milestones/M001/M001-ROADMAP.md', completedRoadmap);
306
+ writeFile(base, 'milestones/M001/M001-VALIDATION.md', `---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nPassed.`);
313
307
  writeFile(base, 'milestones/M001/M001-SUMMARY.md', summaryContent);
314
308
  writeFile(base, 'milestones/M002/M002-ROADMAP.md', activeRoadmap);
315
309
 
@@ -26,6 +26,12 @@ function writeMilestoneSummary(base: string, mid: string, content: string): void
26
26
  writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
27
27
  }
28
28
 
29
+ function writeMilestoneValidation(base: string, mid: string): void {
30
+ const dir = join(base, '.gsd', 'milestones', mid);
31
+ mkdirSync(dir, { recursive: true });
32
+ writeFileSync(join(dir, `${mid}-VALIDATION.md`), `---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nPassed.`);
33
+ }
34
+
29
35
  /**
30
36
  * Creates M00x-CONTEXT.md with a valid YAML frontmatter block.
31
37
  * frontmatter is the raw YAML lines between the --- delimiters.
@@ -120,6 +126,7 @@ async function main(): Promise<void> {
120
126
  - [x] **S01: Done** \`risk:low\` \`depends:[]\`
121
127
  > After this: Done.
122
128
  `);
129
+ writeMilestoneValidation(base, 'M001');
123
130
  writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nFirst milestone is complete.');
124
131
 
125
132
  // M002: depends on M001, now unblocked
@@ -252,6 +259,7 @@ async function main(): Promise<void> {
252
259
  - [x] **S01: Done** \`risk:low\` \`depends:[]\`
253
260
  > After this: Done.
254
261
  `);
262
+ writeMilestoneValidation(base, 'M002');
255
263
  writeMilestoneSummary(base, 'M002', '# M002 Summary\n\nSecond milestone is complete.');
256
264
 
257
265
  const state = await deriveState(base);
@@ -321,6 +329,7 @@ async function main(): Promise<void> {
321
329
  - [x] **S01: Done** \`risk:low\` \`depends:[]\`
322
330
  > After this: Done.
323
331
  `);
332
+ writeMilestoneValidation(base, 'M004-0zjrg0');
324
333
  writeMilestoneSummary(base, 'M004-0zjrg0', '# M004-0zjrg0 Summary\n\nComplete.');
325
334
 
326
335
  // M005-b0m2hl: depends on M004-0zjrg0 (lowercase hex suffix)
@@ -54,6 +54,12 @@ function writeMilestoneSummary(base: string, mid: string, content: string): void
54
54
  writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
55
55
  }
56
56
 
57
+ function writeMilestoneValidation(base: string, mid: string): void {
58
+ const dir = join(base, '.gsd', 'milestones', mid);
59
+ mkdirSync(dir, { recursive: true });
60
+ writeFileSync(join(dir, `${mid}-VALIDATION.md`), `---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nPassed.`);
61
+ }
62
+
57
63
  function cleanup(base: string): void {
58
64
  rmSync(base, { recursive: true, force: true });
59
65
  }
@@ -143,6 +149,7 @@ async function main(): Promise<void> {
143
149
  - [x] **S01: Done** \`risk:low\` \`depends:[]\`
144
150
  > After this: Done.
145
151
  `);
152
+ writeMilestoneValidation(base, 'M001');
146
153
  writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nFirst milestone complete.');
147
154
 
148
155
  // M002: only CONTEXT-DRAFT.md
@@ -178,6 +185,7 @@ async function main(): Promise<void> {
178
185
  - [x] **S01: Done** \`risk:low\` \`depends:[]\`
179
186
  > After this: Done.
180
187
  `);
188
+ writeMilestoneValidation(base, 'M001');
181
189
  writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nComplete.');
182
190
 
183
191
  // M002: draft only — should become active with needs-discussion
@@ -38,6 +38,12 @@ function writeMilestoneSummary(base: string, mid: string, content: string): void
38
38
  writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
39
39
  }
40
40
 
41
+ function writeMilestoneValidation(base: string, mid: string, verdict: string = 'pass'): void {
42
+ const dir = join(base, '.gsd', 'milestones', mid);
43
+ mkdirSync(dir, { recursive: true });
44
+ writeFileSync(join(dir, `${mid}-VALIDATION.md`), `---\nverdict: ${verdict}\nremediation_round: 0\n---\n\n# Validation\nValidated.`);
45
+ }
46
+
41
47
  function writeRequirements(base: string, content: string): void {
42
48
  writeFileSync(join(base, '.gsd', 'REQUIREMENTS.md'), content);
43
49
  }
@@ -285,6 +291,7 @@ Continue from step 2.
285
291
  > After this: Done.
286
292
  `);
287
293
 
294
+ writeMilestoneValidation(base, 'M001');
288
295
  writeMilestoneSummary(base, 'M001', `# M001 Summary\n\nMilestone complete.`);
289
296
 
290
297
  const state = await deriveState(base);
@@ -381,6 +388,7 @@ Continue from step 2.
381
388
  > After this: Done.
382
389
  `);
383
390
 
391
+ writeMilestoneValidation(base, 'M001');
384
392
  writeMilestoneSummary(base, 'M001', `# M001 Summary\n\nFirst milestone complete.`);
385
393
 
386
394
  // M002: active (has incomplete slices)
@@ -486,6 +494,8 @@ Continue from step 2.
486
494
  > After this: S02 complete.
487
495
  `);
488
496
 
497
+ writeMilestoneValidation(base, 'M001');
498
+
489
499
  const state = await deriveState(base);
490
500
 
491
501
  assertEq(state.phase, 'completing-milestone', 'completing-ms: phase is completing-milestone');
@@ -521,6 +531,7 @@ Continue from step 2.
521
531
  > After this: Done.
522
532
  `);
523
533
 
534
+ writeMilestoneValidation(base, 'M001');
524
535
  writeMilestoneSummary(base, 'M001', `# M001 Summary\n\nMilestone is complete.`);
525
536
 
526
537
  const state = await deriveState(base);
@@ -550,6 +561,7 @@ Continue from step 2.
550
561
  - [x] **S01: Done** \`risk:low\` \`depends:[]\`
551
562
  > After this: Done.
552
563
  `);
564
+ writeMilestoneValidation(base, 'M001');
553
565
  writeMilestoneSummary(base, 'M001', `# M001 Summary\n\nFirst milestone complete.`);
554
566
 
555
567
  // M002: all slices done, no summary → completing-milestone
@@ -566,6 +578,8 @@ Continue from step 2.
566
578
  > After this: Done.
567
579
  `);
568
580
 
581
+ writeMilestoneValidation(base, 'M002');
582
+
569
583
  // M003: has incomplete slices → pending (M002 is active)
570
584
  writeRoadmap(base, 'M003', `# M003: Third Milestone
571
585
 
@@ -51,6 +51,12 @@ function writeMilestoneSummary(base: string, mid: string, content: string): void
51
51
  writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
52
52
  }
53
53
 
54
+ function writeMilestoneValidation(base: string, mid: string): void {
55
+ const dir = join(base, '.gsd', 'milestones', mid);
56
+ mkdirSync(dir, { recursive: true });
57
+ writeFileSync(join(dir, `${mid}-VALIDATION.md`), `---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nPassed.`);
58
+ }
59
+
54
60
  function cleanup(base: string): void {
55
61
  rmSync(base, { recursive: true, force: true });
56
62
  }
@@ -166,6 +172,7 @@ async function main(): Promise<void> {
166
172
  Did it.
167
173
  `);
168
174
 
175
+ writeMilestoneValidation(base, 'M001');
169
176
  writeMilestoneSummary(base, 'M001', `# M001: Legacy Feature Summary
170
177
 
171
178
  **One-liner summary**
@@ -265,6 +272,7 @@ Everything worked.
265
272
  Did it.
266
273
  `);
267
274
 
275
+ writeMilestoneValidation(base, 'M001');
268
276
  writeMilestoneSummary(base, 'M001', `# M001: Legacy Feature Summary
269
277
 
270
278
  **One-liner summary**
@@ -263,12 +263,12 @@ async function main(): Promise<void> {
263
263
  // No REQUIREMENTS.md since empty requirements
264
264
  assertTrue(!existsSync(join(base, '.gsd', 'REQUIREMENTS.md')), 'complete: REQUIREMENTS.md NOT written (empty)');
265
265
 
266
- // deriveState: all slices done, all tasks done — needs milestone summary for 'complete'
267
- // Without milestone summary, it should be 'completing-milestone' or 'summarizing'
266
+ // deriveState: all slices done, all tasks done — needs validation then milestone summary
267
+ // Without VALIDATION file, it should be 'validating-milestone'
268
268
  const state = await deriveState(base);
269
- // All slices are done in roadmap. Milestone summary doesn't exist.
270
- // deriveState should return 'completing-milestone' since all slices done but no milestone summary.
271
- assertEq(state.phase, 'completing-milestone', 'complete: deriveState phase is completing-milestone');
269
+ // All slices are done in roadmap. No VALIDATION or SUMMARY exists.
270
+ // deriveState should return 'validating-milestone' since validation gate precedes completion.
271
+ assertEq(state.phase, 'validating-milestone', 'complete: deriveState phase is validating-milestone');
272
272
  assertTrue(state.activeMilestone !== null, 'complete: deriveState has activeMilestone');
273
273
  assertEq(state.activeMilestone!.id, 'M001', 'complete: deriveState activeMilestone is M001');
274
274