gsd-pi 2.81.0-dev.3cddbbba2 → 2.81.0-dev.72a81bdf3

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 (89) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/phases.js +100 -95
  3. package/dist/resources/extensions/gsd/auto-recovery.js +6 -181
  4. package/dist/resources/extensions/gsd/auto.js +6 -3
  5. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +2 -5
  6. package/dist/resources/extensions/gsd/commands/handlers/parallel.js +9 -0
  7. package/dist/resources/extensions/gsd/gsd-db.js +7 -23
  8. package/dist/resources/extensions/gsd/markdown-renderer.js +0 -95
  9. package/dist/resources/extensions/gsd/recovery-classification.js +15 -1
  10. package/dist/resources/extensions/gsd/session-lock.js +40 -0
  11. package/dist/resources/extensions/gsd/state-reconciliation/drift/completion.js +131 -0
  12. package/dist/resources/extensions/gsd/state-reconciliation/drift/merge-state.js +247 -0
  13. package/dist/resources/extensions/gsd/state-reconciliation/drift/project-md.js +50 -0
  14. package/dist/resources/extensions/gsd/state-reconciliation/drift/roadmap.js +87 -0
  15. package/dist/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.js +50 -0
  16. package/dist/resources/extensions/gsd/state-reconciliation/drift/stale-render.js +124 -0
  17. package/dist/resources/extensions/gsd/state-reconciliation/drift/stale-worker.js +32 -0
  18. package/dist/resources/extensions/gsd/state-reconciliation/errors.js +41 -0
  19. package/dist/resources/extensions/gsd/state-reconciliation/index.js +99 -0
  20. package/dist/resources/extensions/gsd/state-reconciliation/registry.js +24 -0
  21. package/dist/resources/extensions/gsd/state-reconciliation/spawn-gate.js +43 -0
  22. package/dist/resources/extensions/gsd/state-reconciliation/types.js +3 -0
  23. package/dist/resources/extensions/gsd/state-reconciliation.js +5 -26
  24. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  25. package/dist/web/standalone/.next/BUILD_ID +1 -1
  26. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  27. package/dist/web/standalone/.next/build-manifest.json +2 -2
  28. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  29. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.html +1 -1
  46. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  53. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  54. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  55. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  56. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  57. package/package.json +1 -1
  58. package/src/resources/extensions/gsd/auto/phases.ts +25 -17
  59. package/src/resources/extensions/gsd/auto-recovery.ts +7 -209
  60. package/src/resources/extensions/gsd/auto.ts +7 -3
  61. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +2 -5
  62. package/src/resources/extensions/gsd/commands/handlers/parallel.ts +12 -0
  63. package/src/resources/extensions/gsd/gsd-db.ts +7 -23
  64. package/src/resources/extensions/gsd/markdown-renderer.ts +4 -95
  65. package/src/resources/extensions/gsd/recovery-classification.ts +18 -1
  66. package/src/resources/extensions/gsd/session-lock.ts +41 -0
  67. package/src/resources/extensions/gsd/state-reconciliation/drift/completion.ts +172 -0
  68. package/src/resources/extensions/gsd/state-reconciliation/drift/merge-state.ts +337 -0
  69. package/src/resources/extensions/gsd/state-reconciliation/drift/project-md.ts +69 -0
  70. package/src/resources/extensions/gsd/state-reconciliation/drift/roadmap.ts +109 -0
  71. package/src/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.ts +68 -0
  72. package/src/resources/extensions/gsd/state-reconciliation/drift/stale-render.ts +185 -0
  73. package/src/resources/extensions/gsd/state-reconciliation/drift/stale-worker.ts +46 -0
  74. package/src/resources/extensions/gsd/state-reconciliation/errors.ts +67 -0
  75. package/src/resources/extensions/gsd/state-reconciliation/index.ts +142 -0
  76. package/src/resources/extensions/gsd/state-reconciliation/registry.ts +27 -0
  77. package/src/resources/extensions/gsd/state-reconciliation/spawn-gate.ts +60 -0
  78. package/src/resources/extensions/gsd/state-reconciliation/types.ts +83 -0
  79. package/src/resources/extensions/gsd/state-reconciliation.ts +21 -53
  80. package/src/resources/extensions/gsd/tests/artifact-retry-cap.test.ts +1 -1
  81. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +81 -10
  82. package/src/resources/extensions/gsd/tests/integration/integration-proof.test.ts +1 -1
  83. package/src/resources/extensions/gsd/tests/markdown-renderer.test.ts +1 -1
  84. package/src/resources/extensions/gsd/tests/progressive-planning.test.ts +1 -1
  85. package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +6 -3
  86. package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +24 -0
  87. package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +952 -0
  88. /package/dist/web/standalone/.next/static/{F5x9E6H9k_52fjqyql93y → rIkMv4YSNlfSeqmGqWVns}/_buildManifest.js +0 -0
  89. /package/dist/web/standalone/.next/static/{F5x9E6H9k_52fjqyql93y → rIkMv4YSNlfSeqmGqWVns}/_ssgManifest.js +0 -0
@@ -734,101 +734,6 @@ export function detectStaleRenders(basePath) {
734
734
  }
735
735
  return stale;
736
736
  }
737
- // ─── Stale Repair ─────────────────────────────────────────────────────────
738
- /**
739
- * Repair all stale renders detected by `detectStaleRenders()`.
740
- *
741
- * For each stale entry, calls the appropriate render function:
742
- * - Roadmap checkbox mismatches → renderRoadmapCheckboxes()
743
- * - Plan checkbox mismatches → renderPlanCheckboxes()
744
- * - Missing task summaries → renderTaskSummary()
745
- * - Missing slice summaries/UATs → renderSliceSummary()
746
- *
747
- * Idempotent: calling twice with no DB changes produces zero repairs on the second call.
748
- *
749
- * @returns the number of files repaired
750
- */
751
- export async function repairStaleRenders(basePath) {
752
- const staleEntries = detectStaleRenders(basePath);
753
- if (staleEntries.length === 0)
754
- return 0;
755
- // Deduplicate: a single roadmap/plan file might appear multiple times
756
- // (once per mismatched checkbox). We only need to re-render it once.
757
- const repairedPaths = new Set();
758
- let repairCount = 0;
759
- for (const entry of staleEntries) {
760
- if (repairedPaths.has(entry.path))
761
- continue;
762
- // Normalize path separators for cross-platform regex matching
763
- const normPath = entry.path.replace(/\\/g, "/");
764
- try {
765
- // Determine repair action from the reason
766
- if (entry.reason.includes("in roadmap")) {
767
- // Roadmap checkbox mismatch — extract milestone ID from path
768
- const milestoneMatch = normPath.match(/milestones\/([^/]+)\//);
769
- if (milestoneMatch) {
770
- const ok = await renderRoadmapCheckboxes(basePath, milestoneMatch[1]);
771
- if (ok) {
772
- repairedPaths.add(entry.path);
773
- repairCount++;
774
- }
775
- }
776
- }
777
- else if (entry.reason.includes("in plan")) {
778
- // Plan checkbox mismatch — extract milestone + slice IDs from path
779
- const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
780
- if (pathMatch) {
781
- const ok = await renderPlanCheckboxes(basePath, pathMatch[1], pathMatch[2]);
782
- if (ok) {
783
- repairedPaths.add(entry.path);
784
- repairCount++;
785
- }
786
- }
787
- }
788
- else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^T\d+/)) {
789
- // Missing task summary — extract IDs from path
790
- const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\/tasks\//);
791
- const taskMatch = entry.reason.match(/^(T\d+)/);
792
- if (pathMatch && taskMatch) {
793
- const ok = await renderTaskSummary(basePath, pathMatch[1], pathMatch[2], taskMatch[1]);
794
- if (ok) {
795
- repairedPaths.add(entry.path);
796
- repairCount++;
797
- }
798
- }
799
- }
800
- else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^S\d+/)) {
801
- // Missing slice summary — extract IDs from path
802
- const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
803
- if (pathMatch) {
804
- const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]);
805
- if (ok) {
806
- repairedPaths.add(entry.path);
807
- repairCount++;
808
- }
809
- }
810
- }
811
- else if (entry.reason.includes("UAT.md missing")) {
812
- // Missing slice UAT — renderSliceSummary handles both SUMMARY + UAT
813
- const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
814
- if (pathMatch) {
815
- const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]);
816
- if (ok) {
817
- repairedPaths.add(entry.path);
818
- repairCount++;
819
- }
820
- }
821
- }
822
- }
823
- catch (err) {
824
- logWarning("renderer", `repair failed for ${entry.path}: ${err.message}`);
825
- }
826
- }
827
- if (repairCount > 0) {
828
- process.stderr.write(`markdown-renderer: repaired ${repairCount} stale render(s)\n`);
829
- }
830
- return repairCount;
831
- }
832
737
  export async function renderReplanFromDb(basePath, milestoneId, sliceId, replanData) {
833
738
  const slicePath = resolveSlicePath(basePath, milestoneId, sliceId)
834
739
  ?? join(gsdRoot(basePath), "milestones", milestoneId, "slices", sliceId);
@@ -1,9 +1,15 @@
1
1
  // Project/App: GSD-2
2
2
  // File Purpose: ADR-015 Recovery Classification module for runtime failure taxonomy.
3
3
  import { classifyError, isTransient } from "./error-classifier.js";
4
+ import { ReconciliationFailedError } from "./state-reconciliation.js";
4
5
  export function classifyFailure(input) {
5
6
  const message = errorMessage(input.error);
6
- const failureKind = input.failureKind ?? inferFailureKind(message);
7
+ // ADR-017: ReconciliationFailedError is a typed throw from the State
8
+ // Reconciliation Module. Recognize it by class regardless of caller-supplied
9
+ // failureKind so the taxonomy stays consistent.
10
+ const failureKind = input.error instanceof ReconciliationFailedError
11
+ ? "reconciliation-drift"
12
+ : input.failureKind ?? inferFailureKind(message);
7
13
  switch (failureKind) {
8
14
  case "tool-schema":
9
15
  return {
@@ -45,6 +51,14 @@ export function classifyFailure(input) {
45
51
  exitReason: "verification-drift",
46
52
  remediation: "Inspect the verification artifact and reconcile the state snapshot before resuming.",
47
53
  };
54
+ case "reconciliation-drift":
55
+ return {
56
+ failureKind,
57
+ action: "escalate",
58
+ reason: `Reconciliation drift${unitSuffix(input)}: ${message}`,
59
+ exitReason: "reconciliation-drift",
60
+ remediation: "Inspect the persistent or repair-failed drift kinds reported by the State Reconciliation Module before resuming.",
61
+ };
48
62
  case "provider": {
49
63
  const providerClass = classifyError(message, input.retryAfterMs);
50
64
  return {
@@ -521,6 +521,46 @@ export function readSessionLockData(basePath) {
521
521
  export function isSessionLockProcessAlive(data) {
522
522
  return isPidAlive(data.pid);
523
523
  }
524
+ /**
525
+ * ADR-017 raw primitive: remove orphaned lock artifacts (lock dir + lock file)
526
+ * when the recorded PID is dead or no metadata is present. Mirrors the
527
+ * pre-flight cleanup logic in acquireSessionLock so the stale-worker drift
528
+ * handler can clear the orphan proactively without going through the full
529
+ * acquire path. No-op when the lock is held by an alive process.
530
+ *
531
+ * Returns true when artifacts were removed (drift was present).
532
+ */
533
+ export function removeStaleSessionLock(basePath) {
534
+ const lp = lockPath(basePath);
535
+ const gsdDir = gsdRoot(basePath);
536
+ const lockTarget = effectiveLockTarget(gsdDir);
537
+ const lockDir = lockTarget + ".lock";
538
+ const existingData = readExistingLockData(lp);
539
+ const isOrphan = !existingData ||
540
+ (typeof existingData.pid === "number" && !isPidAlive(existingData.pid));
541
+ if (!isOrphan)
542
+ return false;
543
+ let removed = false;
544
+ if (existsSync(lockDir)) {
545
+ try {
546
+ rmSync(lockDir, { recursive: true, force: true });
547
+ removed = true;
548
+ }
549
+ catch {
550
+ /* best-effort */
551
+ }
552
+ }
553
+ if (existsSync(lp)) {
554
+ try {
555
+ unlinkSync(lp);
556
+ removed = true;
557
+ }
558
+ catch {
559
+ /* best-effort */
560
+ }
561
+ }
562
+ return removed;
563
+ }
524
564
  /**
525
565
  * Returns true if we currently hold a session lock for the given path.
526
566
  */
@@ -0,0 +1,131 @@
1
+ // Project/App: GSD-2
2
+ // File Purpose: ADR-017 missing-completion-timestamp drift handler. Detects
3
+ // tasks/slices/milestones marked complete (status = 'complete' | 'done') in
4
+ // the DB but whose `completed_at` column is null, and where the on-disk
5
+ // SUMMARY.md attests to completion. Backfills `completed_at` from the
6
+ // SUMMARY.md mtime — deterministic and idempotent (re-running yields the
7
+ // same value).
8
+ import { existsSync, statSync } from "node:fs";
9
+ import { getMilestone, getMilestoneSlices, getSliceTasks, isDbAvailable, updateMilestoneStatus, updateSliceStatus, updateTaskStatus, } from "../../gsd-db.js";
10
+ import { resolveMilestoneFile, resolveSliceFile, resolveTaskFile, } from "../../paths.js";
11
+ const COMPLETE_STATUSES = new Set(["complete", "done"]);
12
+ function summaryMtimeIso(path) {
13
+ try {
14
+ return statSync(path).mtime.toISOString();
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function detectMissingCompletionTimestampDrift(state, ctx) {
21
+ if (!isDbAvailable())
22
+ return [];
23
+ const mid = state.activeMilestone?.id;
24
+ if (!mid)
25
+ return [];
26
+ const milestone = getMilestone(mid);
27
+ if (!milestone)
28
+ return [];
29
+ const drifts = [];
30
+ // Milestone-level
31
+ if (COMPLETE_STATUSES.has(milestone.status) &&
32
+ milestone.completed_at === null) {
33
+ const summary = resolveMilestoneFile(ctx.basePath, mid, "SUMMARY");
34
+ if (summary && existsSync(summary)) {
35
+ drifts.push({
36
+ kind: "missing-completion-timestamp",
37
+ entity: "milestone",
38
+ ids: [mid],
39
+ });
40
+ }
41
+ }
42
+ // Slice and task levels iterate independently — tasks can complete before
43
+ // the parent slice closes, so task drift must be checked even when the
44
+ // slice is still pending.
45
+ for (const slice of getMilestoneSlices(mid)) {
46
+ if (COMPLETE_STATUSES.has(slice.status) &&
47
+ slice.completed_at === null) {
48
+ const summary = resolveSliceFile(ctx.basePath, mid, slice.id, "SUMMARY");
49
+ if (summary && existsSync(summary)) {
50
+ drifts.push({
51
+ kind: "missing-completion-timestamp",
52
+ entity: "slice",
53
+ ids: [`${mid}/${slice.id}`],
54
+ });
55
+ }
56
+ }
57
+ for (const task of getSliceTasks(mid, slice.id)) {
58
+ if (!COMPLETE_STATUSES.has(task.status))
59
+ continue;
60
+ if (task.completed_at !== null)
61
+ continue;
62
+ const taskSummary = resolveTaskFile(ctx.basePath, mid, slice.id, task.id, "SUMMARY");
63
+ if (taskSummary && existsSync(taskSummary)) {
64
+ drifts.push({
65
+ kind: "missing-completion-timestamp",
66
+ entity: "task",
67
+ ids: [`${mid}/${slice.id}/${task.id}`],
68
+ });
69
+ }
70
+ }
71
+ }
72
+ return drifts;
73
+ }
74
+ export function repairMissingCompletionTimestamp(record, ctx) {
75
+ const composite = record.ids[0];
76
+ if (!composite)
77
+ return;
78
+ const parts = composite.split("/");
79
+ if (record.entity === "milestone") {
80
+ const [mid] = parts;
81
+ if (!mid)
82
+ return;
83
+ const milestone = getMilestone(mid);
84
+ if (!milestone ||
85
+ milestone.completed_at !== null ||
86
+ !COMPLETE_STATUSES.has(milestone.status))
87
+ return;
88
+ const summary = resolveMilestoneFile(ctx.basePath, mid, "SUMMARY");
89
+ const ts = summary ? summaryMtimeIso(summary) : null;
90
+ if (!ts)
91
+ return;
92
+ updateMilestoneStatus(mid, milestone.status, ts);
93
+ return;
94
+ }
95
+ if (record.entity === "slice") {
96
+ const [mid, sid] = parts;
97
+ if (!mid || !sid)
98
+ return;
99
+ const slice = getMilestoneSlices(mid).find((s) => s.id === sid);
100
+ if (!slice ||
101
+ slice.completed_at !== null ||
102
+ !COMPLETE_STATUSES.has(slice.status))
103
+ return;
104
+ const summary = resolveSliceFile(ctx.basePath, mid, sid, "SUMMARY");
105
+ const ts = summary ? summaryMtimeIso(summary) : null;
106
+ if (!ts)
107
+ return;
108
+ updateSliceStatus(mid, sid, slice.status, ts);
109
+ return;
110
+ }
111
+ if (record.entity === "task") {
112
+ const [mid, sid, tid] = parts;
113
+ if (!mid || !sid || !tid)
114
+ return;
115
+ const task = getSliceTasks(mid, sid).find((t) => t.id === tid);
116
+ if (!task ||
117
+ task.completed_at !== null ||
118
+ !COMPLETE_STATUSES.has(task.status))
119
+ return;
120
+ const summary = resolveTaskFile(ctx.basePath, mid, sid, tid, "SUMMARY");
121
+ const ts = summary ? summaryMtimeIso(summary) : null;
122
+ if (!ts)
123
+ return;
124
+ updateTaskStatus(mid, sid, tid, task.status, ts);
125
+ }
126
+ }
127
+ export const completionTimestampHandler = {
128
+ kind: "missing-completion-timestamp",
129
+ detect: detectMissingCompletionTimestampDrift,
130
+ repair: repairMissingCompletionTimestamp,
131
+ };
@@ -0,0 +1,247 @@
1
+ // Project/App: GSD-2
2
+ // File Purpose: ADR-017 unmerged-merge-state drift handler. Relocated from
3
+ // auto-recovery.ts as part of issue #5701. Owns:
4
+ // - rebase/cherry-pick/revert leftover cleanup (#4980 HIGH-7)
5
+ // - MERGE_HEAD / SQUASH_MSG reconciliation with auto-resolve of .gsd/
6
+ // conflicts (#530, #2542)
7
+ import { execFileSync } from "node:child_process";
8
+ import { existsSync, unlinkSync } from "node:fs";
9
+ import { isAbsolute, join, resolve } from "node:path";
10
+ import { getErrorMessage } from "../../error-utils.js";
11
+ import { nativeAddPaths, nativeCheckoutTheirs, nativeCommit, nativeConflictFiles, nativeMergeAbort, nativeRebaseAbort, nativeResetHard, } from "../../native-git-bridge.js";
12
+ import { logError, logWarning } from "../../workflow-logger.js";
13
+ const SILENT_NOTIFY = () => { };
14
+ function resolveGitDir(basePath) {
15
+ try {
16
+ const gitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
17
+ cwd: basePath,
18
+ stdio: ["ignore", "pipe", "pipe"],
19
+ encoding: "utf-8",
20
+ }).trim();
21
+ if (gitDir.length > 0) {
22
+ return isAbsolute(gitDir) ? gitDir : resolve(basePath, gitDir);
23
+ }
24
+ }
25
+ catch (err) {
26
+ logWarning("recovery", `gitdir resolution failed: ${getErrorMessage(err)}`);
27
+ }
28
+ return join(basePath, ".git");
29
+ }
30
+ /**
31
+ * Best-effort abort of a pending merge/squash and hard-reset to HEAD.
32
+ * Handles both real merges (MERGE_HEAD) and squash merges (SQUASH_MSG).
33
+ */
34
+ function abortAndResetMerge(basePath, hasMergeHead, squashMsgPath) {
35
+ if (hasMergeHead) {
36
+ try {
37
+ nativeMergeAbort(basePath);
38
+ }
39
+ catch (err) {
40
+ /* best-effort */
41
+ logWarning("recovery", `git merge-abort failed: ${err instanceof Error ? err.message : String(err)}`);
42
+ }
43
+ }
44
+ else if (squashMsgPath) {
45
+ try {
46
+ unlinkSync(squashMsgPath);
47
+ }
48
+ catch (err) {
49
+ /* best-effort */
50
+ logWarning("recovery", `file unlink failed: ${err instanceof Error ? err.message : String(err)}`);
51
+ }
52
+ }
53
+ try {
54
+ nativeResetHard(basePath);
55
+ }
56
+ catch (err) {
57
+ /* best-effort */
58
+ logError("recovery", `git reset failed: ${err instanceof Error ? err.message : String(err)}`);
59
+ }
60
+ }
61
+ /**
62
+ * Detect and abort other in-progress git operations left behind by a SIGKILL'd
63
+ * worker (rebase, cherry-pick, revert). Without this, a killed worker
64
+ * mid-rebase leaves `.git/rebase-merge/` or `.git/CHERRY_PICK_HEAD` and the
65
+ * worktree is wedged until the user manually runs the matching `--abort`.
66
+ *
67
+ * Called before merge-state reconciliation because these states block any
68
+ * subsequent merge/commit operation. (#4980 HIGH-7)
69
+ */
70
+ function reconcileOtherInProgressGitOps(basePath, notify) {
71
+ const gitDir = resolveGitDir(basePath);
72
+ const states = [
73
+ {
74
+ label: "rebase",
75
+ indicators: [join(gitDir, "rebase-merge"), join(gitDir, "rebase-apply")],
76
+ abort: () => nativeRebaseAbort(basePath),
77
+ },
78
+ {
79
+ label: "cherry-pick",
80
+ indicators: [join(gitDir, "CHERRY_PICK_HEAD")],
81
+ abort: () => {
82
+ try {
83
+ execFileSync("git", ["cherry-pick", "--abort"], {
84
+ cwd: basePath,
85
+ stdio: ["ignore", "pipe", "pipe"],
86
+ encoding: "utf-8",
87
+ });
88
+ }
89
+ catch (err) {
90
+ throw new Error(`cherry-pick --abort failed: ${getErrorMessage(err)}`);
91
+ }
92
+ },
93
+ },
94
+ {
95
+ label: "revert",
96
+ indicators: [join(gitDir, "REVERT_HEAD")],
97
+ abort: () => {
98
+ try {
99
+ execFileSync("git", ["revert", "--abort"], {
100
+ cwd: basePath,
101
+ stdio: ["ignore", "pipe", "pipe"],
102
+ encoding: "utf-8",
103
+ });
104
+ }
105
+ catch (err) {
106
+ throw new Error(`revert --abort failed: ${getErrorMessage(err)}`);
107
+ }
108
+ },
109
+ },
110
+ ];
111
+ let reconciled = false;
112
+ for (const s of states) {
113
+ const present = s.indicators.some((p) => existsSync(p));
114
+ if (!present)
115
+ continue;
116
+ try {
117
+ s.abort();
118
+ notify(`Detected leftover ${s.label} state from prior session — aborted.`, "warning");
119
+ reconciled = true;
120
+ }
121
+ catch (err) {
122
+ logError("recovery", `${s.label} abort failed: ${getErrorMessage(err)}`);
123
+ notify(`Detected leftover ${s.label} state but auto-abort failed. ` +
124
+ `Run \`git ${s.label} --abort\` manually before retrying.`, "error");
125
+ return "blocked";
126
+ }
127
+ }
128
+ return reconciled ? "reconciled" : "clean";
129
+ }
130
+ /**
131
+ * Core: detect leftover merge state and reconcile it. Takes a NotifyFn so the
132
+ * legacy reconcileMergeState(basePath, ctx) wrapper and the drift handler can
133
+ * both call it — the drift handler uses SILENT_NOTIFY.
134
+ */
135
+ function reconcileMergeStateCore(basePath, notify) {
136
+ // First, abort any rebase/cherry-pick/revert left over from a SIGKILL'd
137
+ // worker. Doing this before the merge-state check unblocks any merge that
138
+ // would otherwise refuse with "you have unfinished operation". (HIGH-7)
139
+ const otherOpsResult = reconcileOtherInProgressGitOps(basePath, notify);
140
+ if (otherOpsResult === "blocked")
141
+ return "blocked";
142
+ const gitDir = resolveGitDir(basePath);
143
+ const mergeHeadPath = join(gitDir, "MERGE_HEAD");
144
+ const squashMsgPath = join(gitDir, "SQUASH_MSG");
145
+ const hasMergeHead = existsSync(mergeHeadPath);
146
+ const hasSquashMsg = existsSync(squashMsgPath);
147
+ if (!hasMergeHead && !hasSquashMsg) {
148
+ return otherOpsResult === "reconciled" ? "reconciled" : "clean";
149
+ }
150
+ const conflictedFiles = nativeConflictFiles(basePath);
151
+ if (conflictedFiles.length === 0) {
152
+ // All conflicts resolved — finalize the merge/squash commit
153
+ try {
154
+ const commitSha = nativeCommit(basePath, "chore(gsd): reconcile merge state");
155
+ if (commitSha) {
156
+ const mode = hasMergeHead ? "merge" : "squash commit";
157
+ notify(`Finalized leftover ${mode} from prior session.`, "info");
158
+ }
159
+ else {
160
+ notify("No new commit needed for leftover merge/squash state — already committed.", "info");
161
+ }
162
+ }
163
+ catch (err) {
164
+ const errorMessage = getErrorMessage(err);
165
+ notify(`Failed to finalize leftover merge/squash commit: ${errorMessage}`, "error");
166
+ return "blocked";
167
+ }
168
+ }
169
+ else {
170
+ // Still conflicted — try auto-resolving .gsd/ state file conflicts (#530)
171
+ const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/"));
172
+ const codeConflicts = conflictedFiles.filter((f) => !f.startsWith(".gsd/"));
173
+ if (gsdConflicts.length > 0 && codeConflicts.length === 0) {
174
+ let resolved = true;
175
+ try {
176
+ nativeCheckoutTheirs(basePath, gsdConflicts);
177
+ nativeAddPaths(basePath, gsdConflicts);
178
+ }
179
+ catch (e) {
180
+ logError("recovery", `auto-resolve .gsd/ conflicts failed: ${e.message}`);
181
+ resolved = false;
182
+ }
183
+ if (resolved) {
184
+ try {
185
+ nativeCommit(basePath, "chore: auto-resolve .gsd/ state file conflicts");
186
+ notify(`Auto-resolved ${gsdConflicts.length} .gsd/ state file conflict(s) from prior merge.`, "info");
187
+ }
188
+ catch (e) {
189
+ logError("recovery", `auto-commit .gsd/ conflict resolution failed: ${e.message}`);
190
+ resolved = false;
191
+ }
192
+ }
193
+ if (!resolved) {
194
+ abortAndResetMerge(basePath, hasMergeHead, squashMsgPath);
195
+ notify("Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.", "warning");
196
+ }
197
+ }
198
+ else {
199
+ // Code conflicts present — fail safe and preserve any manual resolution
200
+ // work instead of discarding it with merge --abort/reset --hard.
201
+ notify("Detected leftover merge state with unresolved code conflicts. Auto-mode will pause without modifying the worktree so manual conflict resolution is preserved.", "error");
202
+ return "blocked";
203
+ }
204
+ }
205
+ return "reconciled";
206
+ }
207
+ /**
208
+ * Legacy entry point preserved for existing callers (auto.ts, auto/phases.ts
209
+ * via loop-deps, integration tests). New code prefers the drift handler.
210
+ */
211
+ export function reconcileMergeState(basePath, ctx) {
212
+ return reconcileMergeStateCore(basePath, (message, severity) => ctx.ui.notify(message, severity));
213
+ }
214
+ function hasMergeStateLeftovers(basePath) {
215
+ const gitDir = resolveGitDir(basePath);
216
+ return (existsSync(join(gitDir, "MERGE_HEAD")) ||
217
+ existsSync(join(gitDir, "SQUASH_MSG")) ||
218
+ existsSync(join(gitDir, "rebase-merge")) ||
219
+ existsSync(join(gitDir, "rebase-apply")) ||
220
+ existsSync(join(gitDir, "CHERRY_PICK_HEAD")) ||
221
+ existsSync(join(gitDir, "REVERT_HEAD")));
222
+ }
223
+ export function detectMergeStateDrift(_state, ctx) {
224
+ if (hasMergeStateLeftovers(ctx.basePath)) {
225
+ return [{ kind: "unmerged-merge-state", basePath: ctx.basePath }];
226
+ }
227
+ return [];
228
+ }
229
+ /**
230
+ * Repair: invoke the reconciliation core with a silent notify. If the
231
+ * underlying reconciliation reports "blocked" (e.g., unresolved code
232
+ * conflicts present), throw so reconcileBeforeDispatch surfaces the drift
233
+ * via ReconciliationFailedError.
234
+ */
235
+ export function repairMergeStateDrift(record) {
236
+ const result = reconcileMergeStateCore(record.basePath, SILENT_NOTIFY);
237
+ if (result === "blocked") {
238
+ throw new Error(`Merge state reconciliation blocked for ${record.basePath} — likely unresolved code conflicts. Manual intervention required.`);
239
+ }
240
+ }
241
+ export const mergeStateHandler = {
242
+ kind: "unmerged-merge-state",
243
+ detect: detectMergeStateDrift,
244
+ repair: (record) => {
245
+ repairMergeStateDrift(record);
246
+ },
247
+ };
@@ -0,0 +1,50 @@
1
+ // Project/App: GSD-2
2
+ // File Purpose: ADR-017 unregistered-milestone drift handler. Detects
3
+ // milestones whose on-disk directory has meaningful content (ROADMAP/
4
+ // CONTEXT/SUMMARY) but no DB row, then runs the markdown importer to
5
+ // reconcile. PROJECT.md is the human-facing index — the importer's source
6
+ // of truth is the .gsd/milestones/ directory tree.
7
+ import { existsSync } from "node:fs";
8
+ import { getMilestone, isDbAvailable } from "../../gsd-db.js";
9
+ import { migrateHierarchyToDb } from "../../md-importer.js";
10
+ import { findMilestoneIds } from "../../milestone-ids.js";
11
+ import { resolveMilestoneFile } from "../../paths.js";
12
+ function milestoneHasContent(basePath, milestoneId) {
13
+ const roadmap = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
14
+ const context = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
15
+ const summary = resolveMilestoneFile(basePath, milestoneId, "SUMMARY");
16
+ return ((roadmap !== null && existsSync(roadmap)) ||
17
+ (context !== null && existsSync(context)) ||
18
+ (summary !== null && existsSync(summary)));
19
+ }
20
+ export function detectUnregisteredMilestoneDrift(_state, ctx) {
21
+ if (!isDbAvailable())
22
+ return [];
23
+ const drifts = [];
24
+ for (const milestoneId of findMilestoneIds(ctx.basePath)) {
25
+ if (getMilestone(milestoneId))
26
+ continue;
27
+ if (!milestoneHasContent(ctx.basePath, milestoneId))
28
+ continue;
29
+ drifts.push({ kind: "unregistered-milestone", milestoneId });
30
+ }
31
+ return drifts;
32
+ }
33
+ /**
34
+ * Repair: invoke the markdown importer. migrateHierarchyToDb walks the same
35
+ * findMilestoneIds list the detector uses and INSERTs OR IGNOREs every
36
+ * missing milestone (and its slices/tasks) — idempotent under cap=2 retry.
37
+ *
38
+ * Note: even though we receive one record at a time, the importer is a
39
+ * project-wide operation. Repeated invocation across multiple drift records
40
+ * in the same pass is wasteful but safe; a future optimization could
41
+ * coalesce by checking whether the importer has already run this pass.
42
+ */
43
+ export function repairUnregisteredMilestone(_record, ctx) {
44
+ migrateHierarchyToDb(ctx.basePath);
45
+ }
46
+ export const unregisteredMilestoneHandler = {
47
+ kind: "unregistered-milestone",
48
+ detect: detectUnregisteredMilestoneDrift,
49
+ repair: repairUnregisteredMilestone,
50
+ };