@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
@@ -35,6 +35,14 @@ export function consumeAutoWakeup(
35
35
  return wakeup;
36
36
  }
37
37
 
38
+ export function clearAutoWakeup(
39
+ basePath: string,
40
+ unitType: string,
41
+ unitId: string,
42
+ ): void {
43
+ pendingWakeups.delete(wakeupKey(basePath, unitType, unitId));
44
+ }
45
+
38
46
  export function _resetAutoWakeupsForTest(): void {
39
47
  pendingWakeups.clear();
40
48
  }
@@ -236,7 +236,7 @@ import {
236
236
  getSlice,
237
237
  getTask,
238
238
  } from "./gsd-db.js";
239
- import { closeWorkflowDatabase } from "./db-workspace.js";
239
+ import { checkpointWorkflowDatabase, closeWorkflowDatabase } from "./db-workspace.js";
240
240
  import { markLatestActiveForWorkerCanceled } from "./db/unit-dispatches.js";
241
241
  import { writeUnitRuntimeRecord } from "./unit-runtime.js";
242
242
  import { countPendingCaptures } from "./captures.js";
@@ -1628,6 +1628,11 @@ export async function stopAuto(
1628
1628
  const installTerminalCloseoutOutcome = completionStopRequested && preserveCloseoutTranscript;
1629
1629
  const preserveCompletionSurface = completionStopRequested || preserveCloseoutTranscript;
1630
1630
  s.completionStopInProgress = preserveCompletionSurface;
1631
+ try {
1632
+ checkpointWorkflowDatabase();
1633
+ } catch (e) {
1634
+ debugLog("stop-checkpoint-failed", { error: e instanceof Error ? e.message : String(e) });
1635
+ }
1631
1636
  playNotificationBell("stop", loadedPreferences?.notifications);
1632
1637
 
1633
1638
  // #4764 — telemetry: record the exit reason, isolation mode, whether an auto
@@ -2312,7 +2317,7 @@ export function buildWorktreeLifecycleDeps(): WorktreeLifecycleDeps {
2312
2317
  // C4 (#5627) — GitServiceImpl constructor → gitServiceFactory
2313
2318
  //
2314
2319
  // Final WorktreeLifecycleDeps shape: 3 fields (gitServiceFactory,
2315
- // worktreeProjection, mergeMilestoneToMain transaction runner). Down from 18 at slice-7
2320
+ // worktreeProjection, mergeMilestone transaction runner). Down from 18 at slice-7
2316
2321
  // closure.
2317
2322
  return {
2318
2323
  gitServiceFactory: (basePath: string) => {
@@ -2321,7 +2326,7 @@ export function buildWorktreeLifecycleDeps(): WorktreeLifecycleDeps {
2321
2326
  return new GitServiceImpl(basePath, gitConfig);
2322
2327
  },
2323
2328
  worktreeProjection: new WorktreeStateProjection(),
2324
- mergeMilestoneToMain: createMilestoneMergeTransaction(mergeMilestoneToMain),
2329
+ mergeMilestone: createMilestoneMergeTransaction(mergeMilestoneToMain),
2325
2330
  };
2326
2331
  }
2327
2332
 
@@ -2370,6 +2375,7 @@ function buildLoopDeps(pi: ExtensionAPI, ctx: ExtensionContext): LoopDeps {
2370
2375
  stopAuto,
2371
2376
  pauseAuto,
2372
2377
  clearUnitTimeout,
2378
+ checkpointWorkflowDatabase,
2373
2379
  updateProgressWidget,
2374
2380
  ...cmux,
2375
2381
  handleLostSessionLock: (ctx: ExtensionContext | undefined, lockStatus: SessionLockStatus | undefined) => {
@@ -6,22 +6,85 @@ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
6
6
 
7
7
  import { getAutoRuntimeSnapshot } from "../auto-runtime-state.js";
8
8
  import { scheduleAutoWakeup } from "../auto/schedule-wakeup.js";
9
+ import { logWarning } from "../workflow-logger.js";
9
10
  import { resolveCtxCwd } from "./dynamic-tools.js";
10
11
 
11
12
  const MAX_WAKEUP_DELAY_SECONDS = 24 * 60 * 60;
13
+ const INTERACTIVE_WAKEUP_CUSTOM_TYPE = "gsd-schedule-wakeup";
14
+
15
+ // One pending interactive wakeup per session, keyed by base path — the same
16
+ // scoping auto-mode uses for its wakeup map (see `wakeupKey`). Re-arming cancels
17
+ // only that session's prior timer, so repeated polling never stacks overlapping
18
+ // wakeups, and concurrent projects in one host process don't cancel each other.
19
+ const pendingInteractiveWakeups = new Map<string, ReturnType<typeof setTimeout>>();
20
+
21
+ function scheduleInteractiveWakeup(
22
+ pi: ExtensionAPI,
23
+ key: string,
24
+ delaySeconds: number,
25
+ prompt: string,
26
+ reason: string,
27
+ ): void {
28
+ const existing = pendingInteractiveWakeups.get(key);
29
+ if (existing) clearTimeout(existing);
30
+
31
+ const handle = setTimeout(() => {
32
+ if (pendingInteractiveWakeups.get(key) === handle) {
33
+ pendingInteractiveWakeups.delete(key);
34
+ }
35
+ try {
36
+ void Promise.resolve(pi.sendMessage(
37
+ {
38
+ customType: INTERACTIVE_WAKEUP_CUSTOM_TYPE,
39
+ content: prompt,
40
+ display: true,
41
+ details: { delaySeconds, reason },
42
+ },
43
+ { triggerTurn: true },
44
+ )).catch((error) => {
45
+ logWarning(
46
+ "bootstrap",
47
+ `ScheduleWakeup interactive dispatch failed: ${error instanceof Error ? error.message : String(error)}`,
48
+ );
49
+ });
50
+ } catch (error) {
51
+ logWarning(
52
+ "bootstrap",
53
+ `ScheduleWakeup interactive dispatch failed: ${error instanceof Error ? error.message : String(error)}`,
54
+ );
55
+ }
56
+ }, delaySeconds * 1000);
57
+ pendingInteractiveWakeups.set(key, handle);
58
+ if (
59
+ typeof handle === "object" &&
60
+ handle !== null &&
61
+ "unref" in handle &&
62
+ typeof handle.unref === "function"
63
+ ) {
64
+ handle.unref();
65
+ }
66
+ }
67
+
68
+ export function _resetInteractiveWakeupsForTest(): void {
69
+ for (const handle of pendingInteractiveWakeups.values()) {
70
+ clearTimeout(handle);
71
+ }
72
+ pendingInteractiveWakeups.clear();
73
+ }
12
74
 
13
75
  export function registerScheduleWakeupTool(pi: ExtensionAPI): void {
14
76
  pi.registerTool({
15
77
  name: "ScheduleWakeup",
16
78
  label: "Schedule Wakeup",
17
79
  description:
18
- "In GSD auto-mode, pause the current unit and continue the same session after a delay. " +
19
- "Use this for long external processes that need polling without ending the unit as no-artifact.",
20
- promptSnippet: "Schedule a same-session auto-mode wakeup after a delay.",
80
+ "Schedule a delayed continuation turn. In GSD auto-mode, continue the current unit in the same session; " +
81
+ "outside auto-mode, start a new triggered turn with the supplied wakeup prompt.",
82
+ promptSnippet: "Schedule a wakeup prompt after a delay.",
21
83
  promptGuidelines: [
22
84
  "Use ScheduleWakeup at the end of an execute-task turn when waiting for a long external process.",
23
85
  "Include a prompt that says exactly what external state to check next and what artifact to write when done.",
24
86
  "Re-arm ScheduleWakeup on each polling turn if the external process is still running.",
87
+ "Outside auto-mode, use ScheduleWakeup when the user asks you to check back or poll later.",
25
88
  ],
26
89
  parameters: Type.Object({
27
90
  delaySeconds: Type.Number({
@@ -40,27 +103,32 @@ export function registerScheduleWakeupTool(pi: ExtensionAPI): void {
40
103
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
41
104
  const dash = getAutoRuntimeSnapshot();
42
105
  const currentUnit = dash.currentUnit;
43
- const basePath = dash.basePath || resolveCtxCwd(ctx);
106
+ const delaySeconds = Math.max(
107
+ 1,
108
+ Math.min(MAX_WAKEUP_DELAY_SECONDS, Math.floor(params.delaySeconds)),
109
+ );
110
+ const reason = params.reason ?? "";
44
111
 
45
112
  if (!dash.active || !currentUnit) {
113
+ const interactiveKey = dash.basePath || resolveCtxCwd(ctx);
114
+ scheduleInteractiveWakeup(pi, interactiveKey, delaySeconds, params.prompt, reason);
46
115
  return {
47
- content: [{ type: "text", text: "ScheduleWakeup is only available during an active GSD auto-mode unit." }],
48
- details: { operation: "schedule_wakeup", error: "auto_mode_inactive" },
49
- isError: true,
116
+ content: [{
117
+ type: "text",
118
+ text: `Wakeup scheduled for ${delaySeconds}s. GSD will start a new turn with the wakeup prompt.`,
119
+ }],
120
+ details: { operation: "schedule_wakeup", delaySeconds, mode: "interactive" },
50
121
  };
51
122
  }
52
123
 
53
- const delaySeconds = Math.max(
54
- 1,
55
- Math.min(MAX_WAKEUP_DELAY_SECONDS, Math.floor(params.delaySeconds)),
56
- );
124
+ const basePath = dash.basePath || resolveCtxCwd(ctx);
57
125
  scheduleAutoWakeup({
58
126
  basePath,
59
127
  unitType: currentUnit.type,
60
128
  unitId: currentUnit.id,
61
129
  delayMs: delaySeconds * 1000,
62
130
  prompt: params.prompt,
63
- reason: params.reason ?? "",
131
+ reason,
64
132
  createdAt: Date.now(),
65
133
  });
66
134
 
@@ -1,13 +1,15 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { isAbsolute, join, relative } from "node:path";
2
+ import { isAbsolute, join, relative, sep } from "node:path";
3
3
 
4
4
  import type { DoctorIssue } from "./doctor-types.js";
5
5
  import {
6
+ deleteArtifactByPath,
6
7
  getAllMilestones,
7
8
  getMilestoneSlices,
8
9
  getSliceTasks,
9
10
  isDbAvailable,
10
11
  isMemoriesFtsAvailable,
12
+ repairTaskCompletionFromSummary,
11
13
  _getAdapter,
12
14
  } from "./gsd-db.js";
13
15
  import { MEMORIES_FTS_REBUILT_KEY } from "./db-memory-fts-schema.js";
@@ -20,9 +22,14 @@ import { readEvents } from "./workflow-events.js";
20
22
  import { flushWorkflowProjections } from "./projection-flush.js";
21
23
  import { parseRoadmapSlices } from "./roadmap-slices.js";
22
24
  import { parsePlan } from "./parsers-legacy.js";
25
+ import { parseSummary } from "./files.js";
23
26
 
24
27
  const USER_AUTHORED_ARTIFACT_TYPES = new Set(["CONTEXT", "RESEARCH"]);
25
28
 
29
+ function escapeRegExp(value: string): string {
30
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ }
32
+
26
33
  function relativeFile(basePath: string, filePath: string): string {
27
34
  return relative(basePath, filePath).split("\\").join("/");
28
35
  }
@@ -44,6 +51,18 @@ function userContentMissingMessage(path: string, artifactType: string): string {
44
51
  return `Artifact \`${path}\` is a user-authored ${type} file recorded in the database but missing from disk. Re-run \`${userContentRecoveryCommand(type)}\` in this milestone to regenerate it.`;
45
52
  }
46
53
 
54
+ function artifactPathRelativeToGsd(artifactPath: string): string {
55
+ const parts = artifactPath.split(/[\\/]+/);
56
+ const gsdIndex = parts.lastIndexOf(".gsd");
57
+ if (gsdIndex < 0 || gsdIndex === parts.length - 1) return artifactPath;
58
+ return parts.slice(gsdIndex + 1).join("/");
59
+ }
60
+
61
+ function isPathInside(basePath: string, candidatePath: string): boolean {
62
+ const rel = relative(basePath, candidatePath);
63
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
64
+ }
65
+
47
66
  function reportCheckboxDbStatusDivergence(
48
67
  issues: DoctorIssue[],
49
68
  basePath: string,
@@ -142,11 +161,137 @@ function isClearedByMilestoneShellProjectionFlush(
142
161
  }
143
162
 
144
163
  function artifactExistsOnDisk(basePath: string, artifactPath: string): boolean {
145
- if (isAbsolute(artifactPath)) return existsSync(artifactPath);
146
- return [
147
- join(gsdProjectionRoot(basePath), artifactPath),
148
- join(gsdRoot(basePath), artifactPath),
149
- ].some((candidate) => existsSync(candidate));
164
+ return resolveArtifactDiskPath(basePath, artifactPath) !== null;
165
+ }
166
+
167
+ function resolveArtifactDiskPath(basePath: string, artifactPath: string): string | null {
168
+ const relativeArtifactPath = artifactPathRelativeToGsd(artifactPath);
169
+ if (isAbsolute(relativeArtifactPath)) {
170
+ return existsSync(relativeArtifactPath) ? relativeArtifactPath : null;
171
+ }
172
+ for (const root of [gsdProjectionRoot(basePath), gsdRoot(basePath)]) {
173
+ const candidate = join(root, relativeArtifactPath);
174
+ if (isPathInside(root, candidate) && existsSync(candidate)) return candidate;
175
+ }
176
+ return null;
177
+ }
178
+
179
+ function taskExistsInPlan(basePath: string, milestoneId: string, sliceId: string, taskId: string): boolean {
180
+ const planPath = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN");
181
+ if (!planPath || !existsSync(planPath)) return false;
182
+ try {
183
+ return parsePlan(readFileSync(planPath, "utf-8")).tasks.some((task) => task.id === taskId);
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ function isPassingVerificationResult(value: string): boolean {
190
+ const normalized = value.trim().toLowerCase();
191
+ if (!normalized) return false;
192
+ if (/\b(fail(?:ed|ing)?|error|blocked|mixed|untested)\b/.test(normalized)) return false;
193
+ if (
194
+ /\b(?:not|never|no|didn't|did\s+not|cannot|can't|won't|couldn't)\s+(?:\w+\s+){0,3}?(?:pass(?:ed|ing)?|success(?:ful)?|succeeded)\b/.test(
195
+ normalized,
196
+ )
197
+ ) {
198
+ return false;
199
+ }
200
+ return /\b(pass(?:ed|ing)?|success(?:ful)?|succeeded)\b/.test(normalized) || normalized === "all-pass";
201
+ }
202
+
203
+ function readTaskCompletionEvidenceFromSummary(
204
+ basePath: string,
205
+ row: {
206
+ path: string;
207
+ milestone_id: string;
208
+ slice_id: string | null;
209
+ task_id: string | null;
210
+ task_status: string | null;
211
+ task_count: number;
212
+ },
213
+ ): {
214
+ completedAt: string;
215
+ verificationResult: string;
216
+ title: string;
217
+ oneLiner: string;
218
+ narrative: string;
219
+ duration: string;
220
+ blockerDiscovered: boolean;
221
+ deviations: string;
222
+ knownIssues: string;
223
+ keyFiles: string[];
224
+ keyDecisions: string[];
225
+ fullSummaryMd: string;
226
+ } | null {
227
+ if (!row.slice_id || !row.task_id) return null;
228
+ if (!row.task_status && Number(row.task_count) > 0 && !taskExistsInPlan(basePath, row.milestone_id, row.slice_id, row.task_id)) {
229
+ return null;
230
+ }
231
+ const diskPath = resolveArtifactDiskPath(basePath, row.path);
232
+ if (!diskPath) return null;
233
+ try {
234
+ const fullSummaryMd = readFileSync(diskPath, "utf-8");
235
+ const summary = parseSummary(fullSummaryMd);
236
+ const fm = summary.frontmatter;
237
+ if (fm.id !== row.task_id || fm.parent !== row.slice_id || fm.milestone !== row.milestone_id) return null;
238
+ if (fm.blocker_discovered) return null;
239
+ if (!isPassingVerificationResult(fm.verification_result)) return null;
240
+ const completedAt = fm.completed_at.trim();
241
+ if (!completedAt || !Number.isFinite(Date.parse(completedAt))) return null;
242
+ return {
243
+ completedAt,
244
+ verificationResult: fm.verification_result.trim(),
245
+ title: summary.title.replace(new RegExp(`^${escapeRegExp(row.task_id)}:\\s*`), "").trim(),
246
+ oneLiner: summary.oneLiner,
247
+ narrative: summary.whatHappened,
248
+ duration: fm.duration,
249
+ blockerDiscovered: fm.blocker_discovered,
250
+ deviations: summary.deviations,
251
+ knownIssues: summary.knownLimitations,
252
+ keyFiles: fm.key_files.filter((file) => file !== "(none)"),
253
+ keyDecisions: fm.key_decisions.filter((decision) => decision !== "(none)"),
254
+ fullSummaryMd,
255
+ };
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+
261
+ function repairTaskArtifactDbStatusDivergence(
262
+ basePath: string,
263
+ row: {
264
+ path: string;
265
+ milestone_id: string;
266
+ slice_id: string | null;
267
+ task_id: string | null;
268
+ task_status: string | null;
269
+ task_count: number;
270
+ },
271
+ ): boolean {
272
+ const evidence = readTaskCompletionEvidenceFromSummary(basePath, row);
273
+ if (!evidence || !row.slice_id || !row.task_id) return false;
274
+ repairTaskCompletionFromSummary({
275
+ milestoneId: row.milestone_id,
276
+ sliceId: row.slice_id,
277
+ taskId: row.task_id,
278
+ ...evidence,
279
+ });
280
+ return true;
281
+ }
282
+
283
+ function artifactDbStatusDivergenceFixable(
284
+ basePath: string,
285
+ row: {
286
+ path: string;
287
+ milestone_id: string;
288
+ slice_id: string | null;
289
+ task_id: string | null;
290
+ task_status: string | null;
291
+ task_count: number;
292
+ },
293
+ ): boolean {
294
+ return readTaskCompletionEvidenceFromSummary(basePath, row) !== null;
150
295
  }
151
296
 
152
297
  function artifactUnitId(row: { milestone_id: string | null; slice_id: string | null; task_id: string | null }): string {
@@ -163,6 +308,51 @@ function artifactScope(row: { milestone_id: string | null; slice_id: string | nu
163
308
  return "project";
164
309
  }
165
310
 
311
+ type ArtifactRow = {
312
+ path: string;
313
+ artifact_type: string;
314
+ milestone_id: string | null;
315
+ slice_id: string | null;
316
+ task_id: string | null;
317
+ };
318
+
319
+ function sameArtifactIdentity(left: ArtifactRow, right: ArtifactRow): boolean {
320
+ return left.artifact_type === right.artifact_type &&
321
+ left.milestone_id === right.milestone_id &&
322
+ left.slice_id === right.slice_id &&
323
+ left.task_id === right.task_id;
324
+ }
325
+
326
+ function isMilestonesArtifactPath(artifactPath: string): boolean {
327
+ return artifactPathRelativeToGsd(artifactPath).startsWith("milestones/");
328
+ }
329
+
330
+ function expectedMilestonesArtifactPath(row: ArtifactRow): string | null {
331
+ if (!row.milestone_id) return null;
332
+ const artifactType = normalizedArtifactType(row.artifact_type);
333
+ if (!artifactType) return null;
334
+ if (row.slice_id && row.task_id) {
335
+ return `milestones/${row.milestone_id}/slices/${row.slice_id}/tasks/${row.task_id}-${artifactType}.md`;
336
+ }
337
+ if (row.slice_id) {
338
+ return `milestones/${row.milestone_id}/slices/${row.slice_id}/${row.slice_id}-${artifactType}.md`;
339
+ }
340
+ return `milestones/${row.milestone_id}/${row.milestone_id}-${artifactType}.md`;
341
+ }
342
+
343
+ function hasPresentMilestonesReplacement(basePath: string, row: ArtifactRow, artifactRows: ArtifactRow[]): boolean {
344
+ const expectedPath = expectedMilestonesArtifactPath(row);
345
+ if (expectedPath && artifactExistsOnDisk(basePath, expectedPath)) return true;
346
+
347
+ return artifactRows.some(
348
+ (other) =>
349
+ other.path !== row.path &&
350
+ isMilestonesArtifactPath(other.path) &&
351
+ sameArtifactIdentity(row, other) &&
352
+ artifactExistsOnDisk(basePath, other.path),
353
+ );
354
+ }
355
+
166
356
  export async function checkEngineHealth(
167
357
  basePath: string,
168
358
  issues: DoctorIssue[],
@@ -386,27 +576,30 @@ export async function checkEngineHealth(
386
576
  WHERE path != ''
387
577
  ORDER BY path`,
388
578
  )
389
- .all() as Array<{
390
- path: string;
391
- artifact_type: string;
392
- milestone_id: string | null;
393
- slice_id: string | null;
394
- task_id: string | null;
395
- }>;
579
+ .all() as ArtifactRow[];
396
580
 
397
581
  for (const row of artifactRows) {
398
582
  if (artifactExistsOnDisk(basePath, row.path)) continue;
399
583
  const unitId = artifactUnitId(row);
584
+ const issuePath = artifactPathRelativeToGsd(row.path);
585
+ if (options?.repair && issuePath.startsWith("phases/") && hasPresentMilestonesReplacement(basePath, row, artifactRows)) {
586
+ // Route the write through the Single Writer owner (gsd-db.ts) instead
587
+ // of issuing raw DELETE SQL here — doctor is a read-only consumer and
588
+ // the single-writer invariant forbids write SQL outside the allowlist.
589
+ deleteArtifactByPath(row.path);
590
+ fixesApplied.push(`pruned stale flat-phase artifact row ${row.path}`);
591
+ continue;
592
+ }
400
593
  if (isUserAuthoredArtifactType(row.artifact_type)) {
401
594
  const artifactType = normalizedArtifactType(row.artifact_type);
402
- missingUserContentArtifacts.push({ path: row.path, artifactType });
595
+ missingUserContentArtifacts.push({ path: issuePath, artifactType });
403
596
  issues.push({
404
597
  severity: "warning",
405
598
  code: "artifact_user_content_missing",
406
599
  scope: artifactScope(row),
407
600
  unitId,
408
- message: userContentMissingMessage(row.path, artifactType),
409
- file: row.path,
601
+ message: userContentMissingMessage(issuePath, artifactType),
602
+ file: issuePath,
410
603
  fixable: false,
411
604
  });
412
605
  continue;
@@ -416,9 +609,9 @@ export async function checkEngineHealth(
416
609
  code: "artifact_file_missing",
417
610
  scope: artifactScope(row),
418
611
  unitId,
419
- message: `Artifact ${row.path} is recorded in the database as ${row.artifact_type || "UNKNOWN"} but no matching file exists on disk`,
420
- file: row.path,
421
- fixable: false,
612
+ message: `Artifact ${issuePath} is recorded in the database as ${row.artifact_type || "UNKNOWN"} but no matching file exists on disk`,
613
+ file: issuePath,
614
+ fixable: issuePath.startsWith("phases/") && hasPresentMilestonesReplacement(basePath, row, artifactRows),
422
615
  });
423
616
  }
424
617
  } catch {
@@ -476,13 +669,29 @@ export async function checkEngineHealth(
476
669
  : row.milestone_id;
477
670
  if (seen.has(unitId)) continue;
478
671
  seen.add(unitId);
672
+ const fixable = artifactDbStatusDivergenceFixable(basePath, row);
673
+ let repairFailed = false;
674
+ if (options?.repair && fixable) {
675
+ try {
676
+ if (repairTaskArtifactDbStatusDivergence(basePath, row)) {
677
+ fixesApplied.push(`repaired task completion from SUMMARY artifact for ${unitId}`);
678
+ continue;
679
+ }
680
+ } catch {
681
+ repairFailed = true;
682
+ }
683
+ }
479
684
  issues.push({
480
685
  severity: "error",
481
686
  code: "artifact_db_status_divergence",
482
687
  scope: row.task_id ? "task" : row.slice_id ? "slice" : "milestone",
483
688
  unitId,
484
- message: `Completion artifact ${row.path} exists while DB state for ${unitId} is still open or missing. Runtime will not import it silently; run explicit recovery/repair after review.`,
485
- fixable: false,
689
+ message: repairFailed
690
+ ? `Completion artifact ${row.path} exists while DB state for ${unitId} is still open or missing. Doctor found valid SUMMARY completion evidence but could not repair the database state.`
691
+ : fixable
692
+ ? `Completion artifact ${row.path} exists while DB state for ${unitId} is still open or missing. Doctor can repair this from the SUMMARY completion evidence.`
693
+ : `Completion artifact ${row.path} exists while DB state for ${unitId} is still open or missing. Runtime will not import it silently; run explicit recovery/repair after review.`,
694
+ fixable,
486
695
  });
487
696
  }
488
697
  } catch {
@@ -535,6 +535,78 @@ export function updateTaskStatus(milestoneId: string, sliceId: string, taskId: s
535
535
  applyStatusTransition({ entity: "task", milestoneId, sliceId, taskId, status, completedAt });
536
536
  }
537
537
 
538
+ export function repairTaskCompletionFromSummary(t: {
539
+ milestoneId: string;
540
+ sliceId: string;
541
+ taskId: string;
542
+ title?: string;
543
+ oneLiner?: string;
544
+ narrative?: string;
545
+ verificationResult: string;
546
+ duration?: string;
547
+ completedAt: string;
548
+ blockerDiscovered?: boolean;
549
+ deviations?: string;
550
+ knownIssues?: string;
551
+ keyFiles?: string[];
552
+ keyDecisions?: string[];
553
+ fullSummaryMd: string;
554
+ }): void {
555
+ const db = getDbOrNull();
556
+ if (!db) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
557
+ transaction(() => {
558
+ const seq = db.prepare(
559
+ `SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence
560
+ FROM tasks
561
+ WHERE milestone_id = :mid AND slice_id = :sid`,
562
+ ).get({ ":mid": t.milestoneId, ":sid": t.sliceId }) as { next_sequence?: number } | undefined;
563
+ db.prepare(
564
+ `INSERT INTO tasks (
565
+ milestone_id, slice_id, id, title, status, one_liner, narrative,
566
+ verification_result, duration, completed_at, blocker_discovered,
567
+ deviations, known_issues, key_files, key_decisions, full_summary_md,
568
+ sequence
569
+ ) VALUES (
570
+ :milestone_id, :slice_id, :id, :title, 'complete', :one_liner, :narrative,
571
+ :verification_result, :duration, :completed_at, :blocker_discovered,
572
+ :deviations, :known_issues, :key_files, :key_decisions, :full_summary_md,
573
+ :sequence
574
+ )
575
+ ON CONFLICT(milestone_id, slice_id, id) DO UPDATE SET
576
+ title = CASE WHEN NULLIF(:title, '') IS NOT NULL THEN :title ELSE tasks.title END,
577
+ status = 'complete',
578
+ one_liner = CASE WHEN NULLIF(:one_liner, '') IS NOT NULL THEN :one_liner ELSE tasks.one_liner END,
579
+ narrative = CASE WHEN NULLIF(:narrative, '') IS NOT NULL THEN :narrative ELSE tasks.narrative END,
580
+ verification_result = :verification_result,
581
+ duration = CASE WHEN NULLIF(:duration, '') IS NOT NULL THEN :duration ELSE tasks.duration END,
582
+ completed_at = :completed_at,
583
+ blocker_discovered = :blocker_discovered,
584
+ deviations = CASE WHEN NULLIF(:deviations, '') IS NOT NULL THEN :deviations ELSE tasks.deviations END,
585
+ known_issues = CASE WHEN NULLIF(:known_issues, '') IS NOT NULL THEN :known_issues ELSE tasks.known_issues END,
586
+ key_files = :key_files,
587
+ key_decisions = :key_decisions,
588
+ full_summary_md = :full_summary_md`,
589
+ ).run({
590
+ ":milestone_id": t.milestoneId,
591
+ ":slice_id": t.sliceId,
592
+ ":id": t.taskId,
593
+ ":title": t.title ?? "",
594
+ ":one_liner": t.oneLiner ?? "",
595
+ ":narrative": t.narrative ?? "",
596
+ ":verification_result": t.verificationResult,
597
+ ":duration": t.duration ?? "",
598
+ ":completed_at": t.completedAt,
599
+ ":blocker_discovered": t.blockerDiscovered ? 1 : 0,
600
+ ":deviations": t.deviations ?? "",
601
+ ":known_issues": t.knownIssues ?? "",
602
+ ":key_files": JSON.stringify(t.keyFiles ?? []),
603
+ ":key_decisions": JSON.stringify(t.keyDecisions ?? []),
604
+ ":full_summary_md": t.fullSummaryMd,
605
+ ":sequence": seq?.next_sequence ?? 1,
606
+ });
607
+ });
608
+ }
609
+
538
610
  export function setTaskBlockerDiscovered(milestoneId: string, sliceId: string, taskId: string, discovered: boolean): void {
539
611
  if (!getDbOrNull()!) return;
540
612
  getDbOrNull()!.prepare(
@@ -9,7 +9,7 @@
9
9
  import { execFileSync } from "node:child_process";
10
10
  import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, cpSync, rmSync, statSync, symlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { externalGsdRoot, isInsideWorktree } from "./repo-identity.js";
12
+ import { externalGsdRoot, externalStateAlreadyExistsForProject, isInsideWorktree } from "./repo-identity.js";
13
13
  import { getErrorMessage } from "./error-utils.js";
14
14
  import { hasGitTrackedGsdFiles } from "./gitignore.js";
15
15
  import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
@@ -85,6 +85,16 @@ export function migrateToExternalState(basePath: string): MigrationResult {
85
85
  }
86
86
  }
87
87
 
88
+ // If external state already contains project data, this is not a legacy
89
+ // migration source. It is likely an already-migrated project whose symlink
90
+ // was replaced by a real directory, so copying would risk data loss.
91
+ if (externalStateAlreadyExistsForProject(basePath)) {
92
+ return {
93
+ migrated: false,
94
+ error: "External state already exists for this project; leaving local .gsd directory untouched to avoid overwriting authoritative state",
95
+ };
96
+ }
97
+
88
98
  const externalPath = externalGsdRoot(basePath);
89
99
  const migratingPath = join(basePath, ".gsd.migrating");
90
100
 
@@ -94,12 +104,12 @@ export function migrateToExternalState(basePath: string): MigrationResult {
94
104
 
95
105
  // Rename .gsd -> .gsd.migrating (atomic lock).
96
106
  // On Windows, NTFS may reject rename with EPERM if file descriptors are
97
- // open (VS Code watchers, antivirus on-access scan). Fall back to
98
- // copy+delete (#1292).
107
+ // open (VS Code watchers, antivirus on-access scan). WSL/DrvFs can report
108
+ // the same transient lock as EACCES. Fall back to copy+delete (#1292).
99
109
  try {
100
110
  renameSync(localGsd, migratingPath);
101
111
  } catch (renameErr: any) {
102
- if (renameErr?.code === "EPERM" || renameErr?.code === "EBUSY") {
112
+ if (renameErr?.code === "EPERM" || renameErr?.code === "EBUSY" || renameErr?.code === "EACCES") {
103
113
  try {
104
114
  cpSync(localGsd, migratingPath, { recursive: true, force: true });
105
115
  rmSync(localGsd, { recursive: true, force: true });
@@ -21,14 +21,14 @@ export type MilestoneMergeTransactionRunner = (
21
21
  ) => MilestoneMergeTransactionResult;
22
22
 
23
23
  export interface MilestoneMergeTransactionDeps {
24
- mergeMilestoneToMain: MilestoneMergeTransactionRunner;
24
+ mergeMilestone: MilestoneMergeTransactionRunner;
25
25
  }
26
26
 
27
27
  export function runMilestoneMergeTransaction(
28
28
  deps: MilestoneMergeTransactionDeps,
29
29
  input: MilestoneMergeTransactionInput,
30
30
  ): MilestoneMergeTransactionResult {
31
- return deps.mergeMilestoneToMain(
31
+ return deps.mergeMilestone(
32
32
  input.basePath,
33
33
  input.milestoneId,
34
34
  input.roadmapContent,
@@ -36,11 +36,11 @@ export function runMilestoneMergeTransaction(
36
36
  }
37
37
 
38
38
  export function createMilestoneMergeTransaction(
39
- mergeMilestoneToMain: MilestoneMergeTransactionRunner,
39
+ mergeMilestone: MilestoneMergeTransactionRunner,
40
40
  ): MilestoneMergeTransactionRunner {
41
41
  return function mergeMilestoneTransaction(basePath, milestoneId, roadmapContent) {
42
42
  return runMilestoneMergeTransaction(
43
- { mergeMilestoneToMain },
43
+ { mergeMilestone },
44
44
  { basePath, milestoneId, roadmapContent },
45
45
  );
46
46
  };