@opengsd/gsd-pi 1.4.0-dev.5fc22c6f → 1.4.0-dev.c18009cd

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 (135) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +58 -4
  3. package/dist/resources/extensions/gsd/artifact-verification.js +17 -5
  4. package/dist/resources/extensions/gsd/auto/closeout.js +45 -42
  5. package/dist/resources/extensions/gsd/auto/finalize.js +9 -0
  6. package/dist/resources/extensions/gsd/auto/orchestrator.js +1 -1
  7. package/dist/resources/extensions/gsd/auto/run-unit.js +5 -2
  8. package/dist/resources/extensions/gsd/auto/schedule-wakeup.js +3 -0
  9. package/dist/resources/extensions/gsd/auto.js +10 -3
  10. package/dist/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.js +58 -9
  11. package/dist/resources/extensions/gsd/doctor-engine-checks.js +174 -16
  12. package/dist/resources/extensions/gsd/gsd-db.js +52 -0
  13. package/dist/resources/extensions/gsd/migrate-external.js +13 -4
  14. package/dist/resources/extensions/gsd/milestone-merge-transaction.js +3 -3
  15. package/dist/resources/extensions/gsd/repo-identity.js +16 -0
  16. package/dist/resources/extensions/gsd/root-write-leak-guard.js +4 -2
  17. package/dist/resources/extensions/gsd/unit-registry.js +27 -2
  18. package/dist/resources/extensions/gsd/worktree-lifecycle.js +67 -5
  19. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  20. package/dist/web/standalone/.next/BUILD_ID +1 -1
  21. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  22. package/dist/web/standalone/.next/build-manifest.json +2 -2
  23. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  24. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.html +1 -1
  41. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  48. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  49. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  50. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  51. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  52. package/integrations/hermes/CONTEXT.md +47 -0
  53. package/integrations/hermes/README.md +21 -0
  54. package/integrations/hermes/docs/issue-1162-grilling.md +197 -0
  55. package/integrations/hermes/docs/setup.md +4 -0
  56. package/integrations/hermes/open_gsd_hermes/commands.py +141 -0
  57. package/integrations/hermes/open_gsd_hermes/config.py +2 -0
  58. package/integrations/hermes/open_gsd_hermes/gsd_client.py +594 -31
  59. package/integrations/hermes/open_gsd_hermes/notifications.py +10 -1
  60. package/integrations/hermes/plugin.yaml +4 -0
  61. package/integrations/hermes/tests/test_config.py +6 -0
  62. package/integrations/hermes/tests/test_gsd_client_cache.py +3 -0
  63. package/integrations/hermes/tests/test_gsd_client_execute.py +35 -0
  64. package/integrations/hermes/tests/test_gsd_client_process_cwd.py +22 -0
  65. package/integrations/hermes/tests/test_gsd_client_timeout.py +98 -0
  66. package/integrations/hermes/tests/test_milestone_client.py +447 -0
  67. package/integrations/hermes/tests/test_new_milestone_command.py +313 -0
  68. package/integrations/hermes/tests/test_supervisor_fsm.py +40 -0
  69. package/package.json +1 -1
  70. package/packages/cloud-mcp-gateway/package.json +2 -2
  71. package/packages/contracts/package.json +1 -1
  72. package/packages/daemon/dist/session-manager.js +1 -1
  73. package/packages/daemon/dist/session-manager.js.map +1 -1
  74. package/packages/daemon/package.json +4 -4
  75. package/packages/gsd-agent-core/dist/session/agent-session-navigation.d.ts.map +1 -1
  76. package/packages/gsd-agent-core/dist/session/agent-session-navigation.js +4 -1
  77. package/packages/gsd-agent-core/dist/session/agent-session-navigation.js.map +1 -1
  78. package/packages/gsd-agent-core/package.json +5 -5
  79. package/packages/gsd-agent-modes/package.json +7 -7
  80. package/packages/mcp-server/dist/server.d.ts +18 -5
  81. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  82. package/packages/mcp-server/dist/server.js +46 -5
  83. package/packages/mcp-server/dist/server.js.map +1 -1
  84. package/packages/mcp-server/dist/session-manager.js +1 -1
  85. package/packages/mcp-server/dist/session-manager.js.map +1 -1
  86. package/packages/mcp-server/package.json +4 -4
  87. package/packages/native/package.json +1 -1
  88. package/packages/pi-agent-core/package.json +1 -1
  89. package/packages/pi-ai/package.json +1 -1
  90. package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts +1 -0
  91. package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts.map +1 -1
  92. package/packages/pi-coding-agent/dist/core/session-manager-types.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/core/session-manager.js +3 -0
  95. package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  96. package/packages/pi-coding-agent/package.json +7 -7
  97. package/packages/pi-tui/package.json +2 -2
  98. package/packages/rpc-client/package.json +2 -2
  99. package/pkg/package.json +1 -1
  100. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +64 -3
  101. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +212 -39
  102. package/src/resources/extensions/gsd/artifact-verification.ts +16 -4
  103. package/src/resources/extensions/gsd/auto/closeout.ts +54 -56
  104. package/src/resources/extensions/gsd/auto/finalize.ts +8 -0
  105. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
  106. package/src/resources/extensions/gsd/auto/orchestrator.ts +1 -1
  107. package/src/resources/extensions/gsd/auto/run-unit.ts +5 -2
  108. package/src/resources/extensions/gsd/auto/schedule-wakeup.ts +8 -0
  109. package/src/resources/extensions/gsd/auto.ts +9 -3
  110. package/src/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.ts +80 -12
  111. package/src/resources/extensions/gsd/doctor-engine-checks.ts +230 -21
  112. package/src/resources/extensions/gsd/gsd-db.ts +72 -0
  113. package/src/resources/extensions/gsd/migrate-external.ts +14 -4
  114. package/src/resources/extensions/gsd/milestone-merge-transaction.ts +4 -4
  115. package/src/resources/extensions/gsd/repo-identity.ts +17 -0
  116. package/src/resources/extensions/gsd/root-write-leak-guard.ts +3 -2
  117. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +131 -5
  118. package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +43 -1
  119. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +54 -0
  120. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +335 -0
  121. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +4 -4
  122. package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +116 -0
  123. package/src/resources/extensions/gsd/tests/milestone-merge-stash-restore.test.ts +152 -22
  124. package/src/resources/extensions/gsd/tests/milestone-merge-transaction.test.ts +1 -1
  125. package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +50 -6
  126. package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +31 -9
  127. package/src/resources/extensions/gsd/tests/schedule-wakeup-tool.test.ts +167 -0
  128. package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +1 -1
  129. package/src/resources/extensions/gsd/tests/unit-registry.test.ts +56 -0
  130. package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +4 -4
  131. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +2 -2
  132. package/src/resources/extensions/gsd/unit-registry.ts +34 -2
  133. package/src/resources/extensions/gsd/worktree-lifecycle.ts +135 -11
  134. /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_buildManifest.js +0 -0
  135. /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_ssgManifest.js +0 -0
@@ -7,6 +7,7 @@ import {
7
7
  insertMilestone,
8
8
  insertSlice,
9
9
  insertTask,
10
+ getTask,
10
11
  openDatabase,
11
12
  } from "../gsd-db.ts";
12
13
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -335,6 +336,340 @@ test("checkEngineHealth reports artifact rows whose files are missing on disk",
335
336
  assert.equal(missing.fixable, false);
336
337
  });
337
338
 
339
+ test("checkEngineHealth resolves escaped .gsd artifact rows against the project .gsd directory", async (t) => {
340
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-escaped-artifact-"));
341
+ t.after(() => rmSync(base, { recursive: true, force: true }));
342
+
343
+ const gsdDir = join(base, ".gsd");
344
+ const artifactPath = "phases/01-m001/01-01-ASSESSMENT.md";
345
+ mkdirSync(join(gsdDir, "phases", "01-m001"), { recursive: true });
346
+ writeFileSync(join(gsdDir, artifactPath), "# Assessment\n", "utf-8");
347
+
348
+ openDatabase(join(gsdDir, "gsd.db"));
349
+ insertArtifact({
350
+ path: `../../../Documents/Projects/project/.gsd/${artifactPath}`,
351
+ artifact_type: "ASSESSMENT",
352
+ milestone_id: "M001",
353
+ slice_id: "S01",
354
+ task_id: null,
355
+ full_content: "# Assessment\n",
356
+ });
357
+
358
+ const issues: any[] = [];
359
+ await checkEngineHealth(base, issues, []);
360
+
361
+ assert.equal(
362
+ issues.some((issue) => issue.code === "artifact_file_missing"),
363
+ false,
364
+ "escaped .gsd paths should resolve to the project .gsd artifact",
365
+ );
366
+ });
367
+
368
+ test("checkEngineHealth reports escaped missing artifact rows with .gsd-relative paths", async (t) => {
369
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-escaped-missing-artifact-"));
370
+ t.after(() => rmSync(base, { recursive: true, force: true }));
371
+
372
+ const gsdDir = join(base, ".gsd");
373
+ mkdirSync(gsdDir, { recursive: true });
374
+
375
+ openDatabase(join(gsdDir, "gsd.db"));
376
+ insertArtifact({
377
+ path: "../../../Documents/Projects/project/.gsd/phases/01-m001/01-01-PLAN.md",
378
+ artifact_type: "PLAN",
379
+ milestone_id: "M001",
380
+ slice_id: "S01",
381
+ task_id: null,
382
+ full_content: "# Plan\n",
383
+ });
384
+
385
+ const issues: any[] = [];
386
+ await checkEngineHealth(base, issues, []);
387
+
388
+ const issue = issues.find((candidate) => candidate.code === "artifact_file_missing");
389
+ assert.ok(issue, "missing escaped artifact row should still be reported");
390
+ assert.equal(issue.file, "phases/01-m001/01-01-PLAN.md");
391
+ assert.doesNotMatch(issue.message, /\.\.\//);
392
+ });
393
+
394
+ test("checkEngineHealth repair prunes stale phases artifact rows with present milestones files", async (t) => {
395
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-prune-stale-phase-artifact-"));
396
+ t.after(() => rmSync(base, { recursive: true, force: true }));
397
+
398
+ const gsdDir = join(base, ".gsd");
399
+ const stalePath = "phases/01-m001/01-01-PLAN.md";
400
+ const replacementPath = "milestones/M001/slices/S01/S01-PLAN.md";
401
+ mkdirSync(join(gsdDir, "milestones", "M001", "slices", "S01"), { recursive: true });
402
+ writeFileSync(join(gsdDir, replacementPath), "# Plan\n", "utf-8");
403
+
404
+ openDatabase(join(gsdDir, "gsd.db"));
405
+ insertArtifact({
406
+ path: stalePath,
407
+ artifact_type: "PLAN",
408
+ milestone_id: "M001",
409
+ slice_id: "S01",
410
+ task_id: null,
411
+ full_content: "# stale plan\n",
412
+ });
413
+
414
+ const issues: any[] = [];
415
+ const fixes: string[] = [];
416
+ await checkEngineHealth(base, issues, fixes, { repair: true });
417
+
418
+ assert.equal(
419
+ issues.some((issue) => issue.code === "artifact_file_missing" && issue.file === stalePath),
420
+ false,
421
+ "repair should not report stale rows it pruned",
422
+ );
423
+ assert.ok(fixes.includes(`pruned stale flat-phase artifact row ${stalePath}`));
424
+
425
+ const rows = _getAdapter()!
426
+ .prepare("SELECT path FROM artifacts ORDER BY path")
427
+ .all() as Array<{ path: string }>;
428
+ assert.deepEqual(rows.map((row) => row.path), []);
429
+ });
430
+
431
+ test("checkEngineHealth repair prunes stale phases task rows against tasks/<T>-<TYPE>.md replacements", async (t) => {
432
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-prune-stale-task-phase-artifact-"));
433
+ t.after(() => rmSync(base, { recursive: true, force: true }));
434
+
435
+ const gsdDir = join(base, ".gsd");
436
+ const stalePath = "phases/01-m001/01-01-T01-SUMMARY.md";
437
+ // Canonical legacy task layout has no per-task subdirectory: the SUMMARY lives
438
+ // at tasks/<T>-<TYPE>.md, not tasks/<T>/<T>-<TYPE>.md. If the expected-path
439
+ // builder adds an extra tasks/<T>/ segment, task-scoped stale rows never match
440
+ // their on-disk replacement and leak unpruned.
441
+ const replacementPath = "milestones/M001/slices/S01/tasks/T01-SUMMARY.md";
442
+ mkdirSync(join(gsdDir, "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
443
+ writeFileSync(join(gsdDir, replacementPath), "# Summary\n", "utf-8");
444
+
445
+ openDatabase(join(gsdDir, "gsd.db"));
446
+ insertArtifact({
447
+ path: stalePath,
448
+ artifact_type: "SUMMARY",
449
+ milestone_id: "M001",
450
+ slice_id: "S01",
451
+ task_id: "T01",
452
+ full_content: "# stale summary\n",
453
+ });
454
+
455
+ const issues: any[] = [];
456
+ const fixes: string[] = [];
457
+ await checkEngineHealth(base, issues, fixes, { repair: true });
458
+
459
+ assert.equal(
460
+ issues.some((issue) => issue.code === "artifact_file_missing" && issue.file === stalePath),
461
+ false,
462
+ "task-scoped stale rows should prune against the tasks/<T>-<TYPE>.md replacement",
463
+ );
464
+ assert.ok(fixes.includes(`pruned stale flat-phase artifact row ${stalePath}`));
465
+
466
+ const rows = _getAdapter()!
467
+ .prepare("SELECT path FROM artifacts ORDER BY path")
468
+ .all() as Array<{ path: string }>;
469
+ assert.deepEqual(rows.map((row) => row.path), []);
470
+ });
471
+
472
+ test("checkEngineHealth repair prunes stale phases rows stored as escaped ../ paths", async (t) => {
473
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-prune-escaped-phase-artifact-"));
474
+ t.after(() => rmSync(base, { recursive: true, force: true }));
475
+
476
+ const gsdDir = join(base, ".gsd");
477
+ // Leaked row: the DB stores an escaped, absolute-ish path rather than a clean
478
+ // `phases/…` value. The prune must gate on the .gsd-relative path so the row
479
+ // this repair targets is not skipped just because the raw value starts with `../`.
480
+ const stalePath = "../../../Documents/Projects/project/.gsd/phases/01-m001/01-01-PLAN.md";
481
+ const replacementPath = "milestones/M001/slices/S01/S01-PLAN.md";
482
+ mkdirSync(join(gsdDir, "milestones", "M001", "slices", "S01"), { recursive: true });
483
+ writeFileSync(join(gsdDir, replacementPath), "# Plan\n", "utf-8");
484
+
485
+ openDatabase(join(gsdDir, "gsd.db"));
486
+ insertArtifact({
487
+ path: stalePath,
488
+ artifact_type: "PLAN",
489
+ milestone_id: "M001",
490
+ slice_id: "S01",
491
+ task_id: null,
492
+ full_content: "# stale plan\n",
493
+ });
494
+
495
+ const issues: any[] = [];
496
+ const fixes: string[] = [];
497
+ await checkEngineHealth(base, issues, fixes, { repair: true });
498
+
499
+ assert.equal(
500
+ issues.some((issue) => issue.code === "artifact_file_missing"),
501
+ false,
502
+ "escaped phases rows should prune against the milestones replacement, not be reported missing",
503
+ );
504
+ assert.ok(
505
+ fixes.some((fix) => fix.startsWith("pruned stale flat-phase artifact row") && fix.includes("phases/01-m001/01-01-PLAN.md")),
506
+ "repair should record a prune for the escaped stale phases row",
507
+ );
508
+
509
+ const rows = _getAdapter()!
510
+ .prepare("SELECT path FROM artifacts ORDER BY path")
511
+ .all() as Array<{ path: string }>;
512
+ assert.deepEqual(rows.map((row) => row.path), [], "escaped stale phases row should be deleted from the DB");
513
+ });
514
+
515
+ test("checkEngineHealth marks escaped phases rows fixable when a milestones replacement exists", async (t) => {
516
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-escaped-phase-fixable-"));
517
+ t.after(() => rmSync(base, { recursive: true, force: true }));
518
+
519
+ const gsdDir = join(base, ".gsd");
520
+ const replacementPath = "milestones/M001/slices/S01/S01-PLAN.md";
521
+ mkdirSync(join(gsdDir, "milestones", "M001", "slices", "S01"), { recursive: true });
522
+ writeFileSync(join(gsdDir, replacementPath), "# Plan\n", "utf-8");
523
+
524
+ openDatabase(join(gsdDir, "gsd.db"));
525
+ insertArtifact({
526
+ path: "../../../Documents/Projects/project/.gsd/phases/01-m001/01-01-PLAN.md",
527
+ artifact_type: "PLAN",
528
+ milestone_id: "M001",
529
+ slice_id: "S01",
530
+ task_id: null,
531
+ full_content: "# stale plan\n",
532
+ });
533
+
534
+ const issues: any[] = [];
535
+ await checkEngineHealth(base, issues, []);
536
+
537
+ const issue = issues.find((candidate) => candidate.code === "artifact_file_missing");
538
+ assert.ok(issue, "escaped stale row should still be reported when repair is off");
539
+ assert.equal(issue.file, "phases/01-m001/01-01-PLAN.md");
540
+ assert.equal(issue.fixable, true, "escaped phases rows with a milestones replacement must be marked fixable");
541
+ });
542
+
543
+ function taskSummary(id: string, verificationResult = "passed"): string {
544
+ return [
545
+ "---",
546
+ `id: ${id}`,
547
+ "parent: S01",
548
+ "milestone: M001",
549
+ "key_files:",
550
+ " - src/example.ts",
551
+ "key_decisions:",
552
+ " - Keep recovery conservative",
553
+ "duration: 5m",
554
+ `verification_result: ${verificationResult}`,
555
+ "completed_at: 2026-01-01T00:00:00.000Z",
556
+ "blocker_discovered: false",
557
+ "---",
558
+ "",
559
+ `# ${id}: Done`,
560
+ "",
561
+ "**Recovered task completion.**",
562
+ "",
563
+ "## What Happened",
564
+ "",
565
+ "The task finished and wrote its summary.",
566
+ "",
567
+ "## Deviations",
568
+ "",
569
+ "None.",
570
+ "",
571
+ "## Known Issues",
572
+ "",
573
+ "None.",
574
+ "",
575
+ ].join("\n");
576
+ }
577
+
578
+ test("checkEngineHealth marks valid task artifact DB divergence fixable and repairs it", async (t) => {
579
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-artifact-db-repair-"));
580
+ t.after(() => rmSync(base, { recursive: true, force: true }));
581
+
582
+ const gsdDir = join(base, ".gsd");
583
+ const tasksDir = join(gsdDir, "milestones", "M001", "slices", "S01", "tasks");
584
+ mkdirSync(tasksDir, { recursive: true });
585
+
586
+ openDatabase(join(gsdDir, "gsd.db"));
587
+ insertMilestone({ id: "M001", title: "Foundation", status: "active" });
588
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "active", risk: "low", depends: [], sequence: 1 });
589
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "Task", status: "pending", sequence: 1 });
590
+
591
+ const relSummaryPath = "milestones/M001/slices/S01/tasks/T01-SUMMARY.md";
592
+ const summary = taskSummary("T01");
593
+ writeFileSync(join(gsdDir, relSummaryPath), summary, "utf-8");
594
+ insertArtifact({
595
+ path: relSummaryPath,
596
+ artifact_type: "SUMMARY",
597
+ milestone_id: "M001",
598
+ slice_id: "S01",
599
+ task_id: "T01",
600
+ full_content: summary,
601
+ });
602
+
603
+ const detectIssues: any[] = [];
604
+ await checkEngineHealth(base, detectIssues, []);
605
+
606
+ const divergence = detectIssues.find((issue) => issue.code === "artifact_db_status_divergence" && issue.unitId === "M001/S01/T01");
607
+ assert.ok(divergence, "doctor should report the artifact/DB divergence");
608
+ assert.equal(divergence.fixable, true);
609
+
610
+ const repairIssues: any[] = [];
611
+ const fixes: string[] = [];
612
+ await checkEngineHealth(base, repairIssues, fixes, { repair: true });
613
+
614
+ assert.ok(
615
+ fixes.includes("repaired task completion from SUMMARY artifact for M001/S01/T01"),
616
+ "repair mode should report the task completion repair",
617
+ );
618
+ assert.equal(
619
+ repairIssues.some((issue) => issue.code === "artifact_db_status_divergence" && issue.unitId === "M001/S01/T01"),
620
+ false,
621
+ "repaired divergence should not be reported in the same doctor run",
622
+ );
623
+
624
+ const task = getTask("M001", "S01", "T01");
625
+ assert.equal(task?.status, "complete");
626
+ assert.equal(task?.completed_at, "2026-01-01T00:00:00.000Z");
627
+ assert.equal(task?.verification_result, "passed");
628
+ assert.match(task?.full_summary_md ?? "", /# T01: Done/);
629
+ });
630
+
631
+ test("checkEngineHealth keeps failed and negated-pass summaries non-fixable", async (t) => {
632
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-artifact-db-failure-"));
633
+ t.after(() => rmSync(base, { recursive: true, force: true }));
634
+
635
+ const gsdDir = join(base, ".gsd");
636
+ const tasksDir = join(gsdDir, "milestones", "M001", "slices", "S01", "tasks");
637
+ mkdirSync(tasksDir, { recursive: true });
638
+
639
+ openDatabase(join(gsdDir, "gsd.db"));
640
+ insertMilestone({ id: "M001", title: "Foundation", status: "active" });
641
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "active", risk: "low", depends: [], sequence: 1 });
642
+ const nonPassingResults = [
643
+ ["T02", "failed"],
644
+ ["T03", "not passed"],
645
+ ["T04", "not passing"],
646
+ ] as const;
647
+ for (const [taskId, verificationResult] of nonPassingResults) {
648
+ insertTask({ id: taskId, milestoneId: "M001", sliceId: "S01", title: "Task", status: "pending", sequence: 1 });
649
+
650
+ const relSummaryPath = `milestones/M001/slices/S01/tasks/${taskId}-SUMMARY.md`;
651
+ const summary = taskSummary(taskId, verificationResult);
652
+ writeFileSync(join(gsdDir, relSummaryPath), summary, "utf-8");
653
+ insertArtifact({
654
+ path: relSummaryPath,
655
+ artifact_type: "SUMMARY",
656
+ milestone_id: "M001",
657
+ slice_id: "S01",
658
+ task_id: taskId,
659
+ full_content: summary,
660
+ });
661
+ }
662
+
663
+ const issues: any[] = [];
664
+ await checkEngineHealth(base, issues, []);
665
+
666
+ for (const [taskId] of nonPassingResults) {
667
+ const divergence = issues.find((issue) => issue.code === "artifact_db_status_divergence" && issue.unitId === `M001/S01/${taskId}`);
668
+ assert.ok(divergence, "doctor should still report non-passing summary drift");
669
+ assert.equal(divergence.fixable, false);
670
+ }
671
+ });
672
+
338
673
  test("checkEngineHealth reports missing CONTEXT and RESEARCH artifacts as user-content warnings", async (t) => {
339
674
  const base = mkdtempSync(join(tmpdir(), "gsd-doctor-user-content-artifact-"));
340
675
  t.after(() => rmSync(base, { recursive: true, force: true }));
@@ -95,7 +95,7 @@ function makeDeps(
95
95
  isInAutoWorktree: () => true,
96
96
  shouldUseWorktreeIsolation: () => true,
97
97
  getIsolationMode: () => "worktree",
98
- mergeMilestoneToMain: () => ({ pushed: false, codeFilesChanged: true }),
98
+ mergeMilestone: () => ({ pushed: false, codeFilesChanged: true }),
99
99
  syncWorktreeStateBack: () => ({ synced: [] }),
100
100
  teardownAutoWorktree: () => undefined,
101
101
  createAutoWorktree: () => "",
@@ -185,7 +185,7 @@ describe("WorktreeResolver.mergeAndExit re-throws MergeConflictError (#2330)", (
185
185
  resolveMilestoneFile: (_base: string, _mid: string, type: string) =>
186
186
  type === "ROADMAP" ? roadmapPath : null,
187
187
  readFileSync: () => "# M001\n",
188
- mergeMilestoneToMain: () => {
188
+ mergeMilestone: () => {
189
189
  throw new MergeConflictError(conflicted, "squash", "worktree/M001", "main");
190
190
  },
191
191
  });
@@ -216,7 +216,7 @@ describe("WorktreeResolver.mergeAndExit re-throws MergeConflictError (#2330)", (
216
216
  resolveMilestoneFile: (_base: string, _mid: string, type: string) =>
217
217
  type === "ROADMAP" ? roadmapPath : null,
218
218
  readFileSync: () => "# M001\n",
219
- mergeMilestoneToMain: () => {
219
+ mergeMilestone: () => {
220
220
  throw new FakePermError("EACCES: permission denied");
221
221
  },
222
222
  });
@@ -242,7 +242,7 @@ describe("WorktreeResolver.mergeAndExit re-throws MergeConflictError (#2330)", (
242
242
  resolveMilestoneFile: (_base: string, _mid: string, type: string) =>
243
243
  type === "ROADMAP" ? roadmapPath : null,
244
244
  readFileSync: () => "# M001\n",
245
- mergeMilestoneToMain: () => ({ pushed: false, codeFilesChanged: true }),
245
+ mergeMilestone: () => ({ pushed: false, codeFilesChanged: true }),
246
246
  });
247
247
 
248
248
  const resolver = makeResolver(makeSession(baseDir), deps);
@@ -16,6 +16,7 @@ import { tmpdir } from "node:os";
16
16
  import { execSync } from "node:child_process";
17
17
 
18
18
  import { migrateToExternalState, recoverFailedMigration } from "../migrate-external.ts";
19
+ import { externalGsdRoot } from "../repo-identity.ts";
19
20
 
20
21
  function run(command: string, cwd: string): string {
21
22
  return execSync(command, {
@@ -119,6 +120,121 @@ describe("migrate-external worktree guard (#2970)", () => {
119
120
  });
120
121
  });
121
122
 
123
+ describe("migrateToExternalState rename fallback (#1179)", () => {
124
+ test("falls back to copy/delete when rename fails with EACCES", () => {
125
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-migrate-eacces-")));
126
+ const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-eacces-")));
127
+ const previousStateDir = process.env.GSD_STATE_DIR;
128
+ const previousProjectId = process.env.GSD_PROJECT_ID;
129
+ const originalRenameSync = fs.renameSync;
130
+ const localGsd = join(base, ".gsd");
131
+ const migratingPath = join(base, ".gsd.migrating");
132
+ let renameAttempts = 0;
133
+
134
+ process.env.GSD_STATE_DIR = stateDir;
135
+ process.env.GSD_PROJECT_ID = "rename-eacces";
136
+
137
+ try {
138
+ run("git init -b main", base);
139
+ mkdirSync(localGsd, { recursive: true });
140
+ writeFileSync(join(localGsd, "PREFERENCES.md"), "# prefs\n", "utf-8");
141
+
142
+ fs.renameSync = ((src, dst) => {
143
+ if (String(src) === localGsd && String(dst) === migratingPath) {
144
+ renameAttempts += 1;
145
+ const error = new Error("simulated WSL/DrvFs rename lock") as NodeJS.ErrnoException;
146
+ error.code = "EACCES";
147
+ throw error;
148
+ }
149
+ return originalRenameSync(src, dst);
150
+ }) as typeof fs.renameSync;
151
+ syncBuiltinESMExports();
152
+
153
+ const result = migrateToExternalState(base);
154
+
155
+ assert.equal(result.migrated, true, "EACCES rename failure should use copy/delete fallback");
156
+ assert.equal(result.error, undefined);
157
+ assert.equal(renameAttempts, 1, "should exercise the EACCES rename fallback once");
158
+ assert.equal(readFileSync(join(stateDir, "projects", "rename-eacces", "PREFERENCES.md"), "utf-8"), "# prefs\n");
159
+ assert.equal(readFileSync(join(base, ".gsd", "PREFERENCES.md"), "utf-8"), "# prefs\n");
160
+ assert.ok(!existsSync(migratingPath), "backup staging dir must be removed after successful migration");
161
+ } finally {
162
+ fs.renameSync = originalRenameSync;
163
+ syncBuiltinESMExports();
164
+ if (previousStateDir === undefined) {
165
+ delete process.env.GSD_STATE_DIR;
166
+ } else {
167
+ process.env.GSD_STATE_DIR = previousStateDir;
168
+ }
169
+ if (previousProjectId === undefined) {
170
+ delete process.env.GSD_PROJECT_ID;
171
+ } else {
172
+ process.env.GSD_PROJECT_ID = previousProjectId;
173
+ }
174
+ rmSync(base, { recursive: true, force: true });
175
+ rmSync(stateDir, { recursive: true, force: true });
176
+ }
177
+ });
178
+ });
179
+
180
+ describe("migrateToExternalState already-migrated external state guard (#1178)", () => {
181
+ test("skips migration when external state already exists for a real local .gsd directory", () => {
182
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-migrate-already-external-")));
183
+ const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-already-external-")));
184
+ const previousStateDir = process.env.GSD_STATE_DIR;
185
+ const previousProjectId = process.env.GSD_PROJECT_ID;
186
+ process.env.GSD_STATE_DIR = stateDir;
187
+
188
+ try {
189
+ run("git init -b main", base);
190
+ run('git config user.name "Test"', base);
191
+ run('git config user.email "test@example.com"', base);
192
+ writeFileSync(join(base, "README.md"), "# Test\n", "utf-8");
193
+ run("git add README.md", base);
194
+ run('git commit -m "init"', base);
195
+
196
+ const externalPath = externalGsdRoot(base);
197
+ const identity = externalPath.split(/[\\/]/).pop();
198
+ assert.ok(identity, "external state path must include an identity segment");
199
+ mkdirSync(externalPath, { recursive: true });
200
+ writeFileSync(join(base, ".gsd-id"), `${identity}\n`, "utf-8");
201
+ writeFileSync(join(externalPath, "gsd.db"), "newer external state\n", "utf-8");
202
+
203
+ mkdirSync(join(base, ".gsd"), { recursive: true });
204
+ writeFileSync(join(base, ".gsd", "gsd.db"), "stale local state\n", "utf-8");
205
+
206
+ const result = migrateToExternalState(base);
207
+
208
+ assert.equal(result.migrated, false, "already-migrated project must not re-run migration");
209
+ assert.match(result.error ?? "", /external state already exists/i);
210
+ assert.equal(
211
+ readFileSync(join(externalPath, "gsd.db"), "utf-8"),
212
+ "newer external state\n",
213
+ "newer external gsd.db must not be overwritten by stale local state",
214
+ );
215
+ assert.equal(
216
+ readFileSync(join(base, ".gsd", "gsd.db"), "utf-8"),
217
+ "stale local state\n",
218
+ "local real .gsd directory should be left for explicit recovery",
219
+ );
220
+ assert.ok(!existsSync(join(base, ".gsd.migrating")), "migration staging dir must not be created");
221
+ } finally {
222
+ if (previousStateDir === undefined) {
223
+ delete process.env.GSD_STATE_DIR;
224
+ } else {
225
+ process.env.GSD_STATE_DIR = previousStateDir;
226
+ }
227
+ if (previousProjectId === undefined) {
228
+ delete process.env.GSD_PROJECT_ID;
229
+ } else {
230
+ process.env.GSD_PROJECT_ID = previousProjectId;
231
+ }
232
+ rmSync(base, { recursive: true, force: true });
233
+ rmSync(stateDir, { recursive: true, force: true });
234
+ }
235
+ });
236
+ });
237
+
122
238
  describe("migrateToExternalState copy failure rollback (#1047)", () => {
123
239
  test("restores .gsd when a source entry fails to copy", () => {
124
240
  const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-migrate-copy-fail-")));