gsd-pi 2.35.0-dev.30eec3f → 2.35.0-dev.640d5c7

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 (88) hide show
  1. package/dist/cli.js +2 -7
  2. package/dist/resource-loader.d.ts +1 -1
  3. package/dist/resource-loader.js +1 -13
  4. package/dist/resources/extensions/async-jobs/await-tool.js +2 -0
  5. package/dist/resources/extensions/async-jobs/job-manager.js +6 -0
  6. package/dist/resources/extensions/bg-shell/output-formatter.js +19 -1
  7. package/dist/resources/extensions/bg-shell/process-manager.js +4 -0
  8. package/dist/resources/extensions/bg-shell/types.js +2 -0
  9. package/dist/resources/extensions/context7/index.js +0 -5
  10. package/dist/resources/extensions/get-secrets-from-user.js +30 -2
  11. package/dist/resources/extensions/google-search/index.js +0 -5
  12. package/dist/resources/extensions/gsd/auto-dispatch.js +1 -43
  13. package/dist/resources/extensions/gsd/auto-loop.js +1 -10
  14. package/dist/resources/extensions/gsd/auto-recovery.js +0 -35
  15. package/dist/resources/extensions/gsd/auto-start.js +2 -35
  16. package/dist/resources/extensions/gsd/auto.js +4 -59
  17. package/dist/resources/extensions/gsd/commands-handlers.js +2 -2
  18. package/dist/resources/extensions/gsd/doctor-environment.js +17 -26
  19. package/dist/resources/extensions/gsd/files.js +1 -9
  20. package/dist/resources/extensions/gsd/gitignore.js +7 -54
  21. package/dist/resources/extensions/gsd/guided-flow.js +1 -1
  22. package/dist/resources/extensions/gsd/health-widget.js +46 -97
  23. package/dist/resources/extensions/gsd/index.js +1 -10
  24. package/dist/resources/extensions/gsd/migrate-external.js +2 -55
  25. package/dist/resources/extensions/gsd/paths.js +7 -74
  26. package/dist/resources/extensions/gsd/post-unit-hooks.js +1 -4
  27. package/dist/resources/extensions/gsd/preferences-validation.js +1 -16
  28. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +0 -2
  29. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +0 -2
  30. package/dist/resources/extensions/gsd/session-lock.js +2 -26
  31. package/dist/resources/extensions/gsd/templates/plan.md +0 -8
  32. package/dist/resources/extensions/gsd/worktree-resolver.js +0 -12
  33. package/dist/resources/extensions/remote-questions/remote-command.js +22 -2
  34. package/dist/resources/extensions/shared/mod.js +1 -1
  35. package/dist/resources/extensions/shared/sanitize.js +0 -30
  36. package/dist/resources/extensions/shared/wizard-ui.js +478 -0
  37. package/dist/resources/extensions/subagent/index.js +14 -6
  38. package/package.json +1 -2
  39. package/packages/pi-coding-agent/dist/core/resource-loader.d.ts.map +1 -1
  40. package/packages/pi-coding-agent/dist/core/resource-loader.js +2 -13
  41. package/packages/pi-coding-agent/dist/core/resource-loader.js.map +1 -1
  42. package/packages/pi-coding-agent/src/core/resource-loader.ts +2 -13
  43. package/src/resources/extensions/async-jobs/await-tool.ts +2 -0
  44. package/src/resources/extensions/async-jobs/job-manager.ts +7 -0
  45. package/src/resources/extensions/bg-shell/output-formatter.ts +17 -0
  46. package/src/resources/extensions/bg-shell/process-manager.ts +4 -0
  47. package/src/resources/extensions/bg-shell/types.ts +12 -0
  48. package/src/resources/extensions/context7/index.ts +0 -7
  49. package/src/resources/extensions/get-secrets-from-user.ts +35 -2
  50. package/src/resources/extensions/google-search/index.ts +0 -7
  51. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -49
  52. package/src/resources/extensions/gsd/auto-loop.ts +1 -11
  53. package/src/resources/extensions/gsd/auto-recovery.ts +0 -39
  54. package/src/resources/extensions/gsd/auto-start.ts +2 -42
  55. package/src/resources/extensions/gsd/auto.ts +3 -61
  56. package/src/resources/extensions/gsd/commands-handlers.ts +2 -2
  57. package/src/resources/extensions/gsd/doctor-environment.ts +16 -26
  58. package/src/resources/extensions/gsd/files.ts +1 -10
  59. package/src/resources/extensions/gsd/gitignore.ts +7 -54
  60. package/src/resources/extensions/gsd/guided-flow.ts +1 -1
  61. package/src/resources/extensions/gsd/health-widget.ts +59 -103
  62. package/src/resources/extensions/gsd/index.ts +1 -10
  63. package/src/resources/extensions/gsd/migrate-external.ts +2 -47
  64. package/src/resources/extensions/gsd/paths.ts +7 -73
  65. package/src/resources/extensions/gsd/post-unit-hooks.ts +1 -5
  66. package/src/resources/extensions/gsd/preferences-validation.ts +1 -16
  67. package/src/resources/extensions/gsd/prompts/complete-milestone.md +0 -2
  68. package/src/resources/extensions/gsd/prompts/validate-milestone.md +0 -2
  69. package/src/resources/extensions/gsd/session-lock.ts +2 -29
  70. package/src/resources/extensions/gsd/templates/plan.md +0 -8
  71. package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -12
  72. package/src/resources/extensions/gsd/tests/validate-directory.test.ts +0 -15
  73. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +0 -2
  74. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +0 -32
  75. package/src/resources/extensions/gsd/worktree-resolver.ts +0 -11
  76. package/src/resources/extensions/remote-questions/remote-command.ts +23 -2
  77. package/src/resources/extensions/shared/mod.ts +1 -1
  78. package/src/resources/extensions/shared/sanitize.ts +0 -36
  79. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  80. package/src/resources/extensions/subagent/index.ts +12 -6
  81. package/dist/resources/extensions/gsd/health-widget-core.js +0 -96
  82. package/dist/resources/extensions/gsd/roadmap-mutations.js +0 -55
  83. package/src/resources/extensions/gsd/health-widget-core.ts +0 -129
  84. package/src/resources/extensions/gsd/roadmap-mutations.ts +0 -66
  85. package/src/resources/extensions/gsd/tests/gitignore-tracked-gsd.test.ts +0 -214
  86. package/src/resources/extensions/gsd/tests/health-widget.test.ts +0 -158
  87. package/src/resources/extensions/gsd/tests/paths.test.ts +0 -113
  88. package/src/resources/extensions/gsd/tests/test-utils.ts +0 -165
@@ -9,37 +9,41 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionContext } from "@gsd/pi-coding-agent";
12
- import type { GSDState } from "./types.js";
13
12
  import { runProviderChecks, summariseProviderIssues } from "./doctor-providers.js";
14
13
  import { runEnvironmentChecks } from "./doctor-environment.js";
15
14
  import { loadEffectiveGSDPreferences } from "./preferences.js";
16
15
  import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js";
17
- import { describeNextUnit, estimateTimeRemaining, updateSliceProgressCache } from "./auto-dashboard.js";
18
16
  import { projectRoot } from "./commands.js";
19
- import { deriveState, invalidateStateCache } from "./state.js";
20
- import {
21
- buildHealthLines,
22
- detectHealthWidgetProjectState,
23
- type HealthWidgetData,
24
- } from "./health-widget-core.js";
17
+
18
+ // ── Types ──────────────────────────────────────────────────────────────────────
19
+
20
+ interface HealthWidgetData {
21
+ hasProject: boolean;
22
+ budgetCeiling: number | undefined;
23
+ budgetSpent: number;
24
+ providerIssue: string | null; // compact summary from summariseProviderIssues()
25
+ environmentErrorCount: number;
26
+ environmentWarningCount: number;
27
+ lastRefreshed: number;
28
+ }
25
29
 
26
30
  // ── Data loader ────────────────────────────────────────────────────────────────
27
31
 
28
- function loadBaseHealthWidgetData(basePath: string): HealthWidgetData {
32
+ function loadHealthWidgetData(basePath: string): HealthWidgetData {
33
+ let hasProject = false;
29
34
  let budgetCeiling: number | undefined;
30
35
  let budgetSpent = 0;
31
36
  let providerIssue: string | null = null;
32
37
  let environmentErrorCount = 0;
33
38
  let environmentWarningCount = 0;
34
39
 
35
- const projectState = detectHealthWidgetProjectState(basePath);
36
-
37
40
  try {
38
41
  const prefs = loadEffectiveGSDPreferences();
39
42
  budgetCeiling = prefs?.preferences?.budget_ceiling;
40
43
 
41
44
  const ledger = loadLedgerFromDisk(basePath);
42
45
  if (ledger) {
46
+ hasProject = true;
43
47
  const totals = getProjectTotals(ledger.units ?? []);
44
48
  budgetSpent = totals.cost;
45
49
  }
@@ -59,7 +63,7 @@ function loadBaseHealthWidgetData(basePath: string): HealthWidgetData {
59
63
  } catch { /* non-fatal */ }
60
64
 
61
65
  return {
62
- projectState,
66
+ hasProject,
63
67
  budgetCeiling,
64
68
  budgetSpent,
65
69
  providerIssue,
@@ -69,88 +73,54 @@ function loadBaseHealthWidgetData(basePath: string): HealthWidgetData {
69
73
  };
70
74
  }
71
75
 
72
- function compactText(text: string, max = 64): string {
73
- const trimmed = text.replace(/\s+/g, " ").trim();
74
- if (trimmed.length <= max) return trimmed;
75
- return `${trimmed.slice(0, max - 1).trimEnd()}…`;
76
- }
76
+ // ── Rendering ──────────────────────────────────────────────────────────────────
77
77
 
78
- function summarizeExecutionStatus(state: GSDState): string {
79
- switch (state.phase) {
80
- case "blocked": return "Blocked";
81
- case "paused": return "Paused";
82
- case "complete": return "Complete";
83
- case "executing": return "Executing";
84
- case "planning": return "Planning";
85
- case "pre-planning": return "Pre-planning";
86
- case "summarizing": return "Summarizing";
87
- case "validating-milestone": return "Validating";
88
- case "completing-milestone": return "Completing";
89
- case "needs-discussion": return "Needs discussion";
90
- case "replanning-slice": return "Replanning";
91
- default: return "Active";
92
- }
78
+ function formatCost(n: number): string {
79
+ return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`;
93
80
  }
94
81
 
95
- function summarizeExecutionTarget(state: GSDState): string {
96
- switch (state.phase) {
97
- case "needs-discussion":
98
- return state.activeMilestone ? `Discuss ${state.activeMilestone.id}` : "Discuss milestone draft";
99
- case "pre-planning":
100
- return state.activeMilestone ? `Plan ${state.activeMilestone.id}` : "Research & plan milestone";
101
- case "planning":
102
- return state.activeSlice ? `Plan ${state.activeSlice.id}` : "Plan next slice";
103
- case "executing":
104
- return state.activeTask ? `Execute ${state.activeTask.id}` : "Execute next task";
105
- case "summarizing":
106
- return state.activeSlice ? `Complete ${state.activeSlice.id}` : "Complete current slice";
107
- case "validating-milestone":
108
- return state.activeMilestone ? `Validate ${state.activeMilestone.id}` : "Validate milestone";
109
- case "completing-milestone":
110
- return state.activeMilestone ? `Complete ${state.activeMilestone.id}` : "Complete milestone";
111
- case "replanning-slice":
112
- return state.activeSlice ? `Replan ${state.activeSlice.id}` : "Replan current slice";
113
- case "blocked":
114
- return `waiting on ${compactText(state.blockers[0] ?? state.nextAction, 56)}`;
115
- case "paused":
116
- return compactText(state.nextAction || "waiting to resume", 56);
117
- case "complete":
118
- return "All milestones complete";
119
- default:
120
- return compactText(describeNextUnit(state).label, 56);
82
+ /**
83
+ * Build compact health lines for the widget.
84
+ * Returns a string array suitable for setWidget().
85
+ */
86
+ export function buildHealthLines(data: HealthWidgetData): string[] {
87
+ if (!data.hasProject) {
88
+ return [" GSD No project loaded — run /gsd to start"];
121
89
  }
122
- }
123
90
 
124
- async function enrichHealthWidgetData(basePath: string, baseData: HealthWidgetData): Promise<HealthWidgetData> {
125
- if (baseData.projectState !== "active") return baseData;
91
+ const parts: string[] = [];
126
92
 
127
- try {
128
- invalidateStateCache();
129
- const state = await deriveState(basePath);
93
+ // System status signal
94
+ const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0);
95
+ if (totalIssues === 0) {
96
+ parts.push("● System OK");
97
+ } else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) {
98
+ parts.push(`✗ ${totalIssues} issue${totalIssues > 1 ? "s" : ""}`);
99
+ } else {
100
+ parts.push(`⚠ ${totalIssues} warning${totalIssues > 1 ? "s" : ""}`);
101
+ }
130
102
 
131
- if (state.activeMilestone) {
132
- // Warm the slice-progress cache so estimateTimeRemaining() has data
133
- updateSliceProgressCache(basePath, state.activeMilestone.id, state.activeSlice?.id);
134
- }
103
+ // Budget
104
+ if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) {
105
+ const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100);
106
+ parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`);
107
+ } else if (data.budgetSpent > 0) {
108
+ parts.push(`Spent: ${formatCost(data.budgetSpent)}`);
109
+ }
135
110
 
136
- return {
137
- ...baseData,
138
- executionPhase: state.phase,
139
- executionStatus: summarizeExecutionStatus(state),
140
- executionTarget: summarizeExecutionTarget(state),
141
- nextAction: state.nextAction,
142
- blocker: state.blockers[0] ?? null,
143
- activeMilestoneId: state.activeMilestone?.id,
144
- activeSliceId: state.activeSlice?.id,
145
- activeTaskId: state.activeTask?.id,
146
- progress: state.progress,
147
- eta: state.phase === "blocked" || state.phase === "paused" || state.phase === "complete"
148
- ? null
149
- : estimateTimeRemaining(),
150
- };
151
- } catch {
152
- return baseData;
111
+ // Provider issue (if any)
112
+ if (data.providerIssue) {
113
+ parts.push(data.providerIssue);
153
114
  }
115
+
116
+ // Environment issues
117
+ if (data.environmentErrorCount > 0) {
118
+ parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`);
119
+ } else if (data.environmentWarningCount > 0) {
120
+ parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`);
121
+ }
122
+
123
+ return [` ${parts.join(" │ ")}`];
154
124
  }
155
125
 
156
126
  // ── Widget init ────────────────────────────────────────────────────────────────
@@ -167,34 +137,20 @@ export function initHealthWidget(ctx: ExtensionContext): void {
167
137
  const basePath = projectRoot();
168
138
 
169
139
  // String-array fallback — used in RPC mode (factory is a no-op there)
170
- const initialData = loadBaseHealthWidgetData(basePath);
140
+ const initialData = loadHealthWidgetData(basePath);
171
141
  ctx.ui.setWidget("gsd-health", buildHealthLines(initialData), { placement: "belowEditor" });
172
142
 
173
143
  // Factory-based widget for TUI mode — replaces the string-array above
174
144
  ctx.ui.setWidget("gsd-health", (_tui, _theme) => {
175
145
  let data = initialData;
176
146
  let cachedLines: string[] | undefined;
177
- let refreshInFlight = false;
178
147
 
179
- const refresh = async () => {
180
- if (refreshInFlight) return;
181
- refreshInFlight = true;
148
+ const refreshTimer = setInterval(() => {
182
149
  try {
183
- const baseData = loadBaseHealthWidgetData(basePath);
184
- data = await enrichHealthWidgetData(basePath, baseData);
150
+ data = loadHealthWidgetData(basePath);
185
151
  cachedLines = undefined;
186
152
  _tui.requestRender();
187
- } catch { /* non-fatal */ } finally {
188
- refreshInFlight = false;
189
- }
190
- };
191
-
192
- // Fire first enrichment immediately. requestRender() inside is a no-op
193
- // if the widget has not yet rendered, so this is safe before factory return.
194
- void refresh();
195
-
196
- const refreshTimer = setInterval(() => {
197
- void refresh();
153
+ } catch { /* non-fatal */ }
198
154
  }, REFRESH_INTERVAL_MS);
199
155
 
200
156
  return {
@@ -175,16 +175,7 @@ export default function (pi: ExtensionAPI) {
175
175
  // Pipe closed — nothing we can write; just exit cleanly
176
176
  process.exit(0);
177
177
  }
178
- if ((err as NodeJS.ErrnoException).code === "ENOENT" &&
179
- (err as any).syscall?.startsWith("spawn")) {
180
- // spawn ENOENT — command not found (e.g., npx on Windows).
181
- // This surfaces as an uncaught exception from child_process but
182
- // is not a fatal process error. Log and continue instead of
183
- // crashing auto-mode (#1384).
184
- process.stderr.write(`[gsd] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`);
185
- return;
186
- }
187
- // Re-throw anything that isn't EPIPE/ENOENT so real crashes still surface
178
+ // Re-throw anything that isn't EPIPE so real crashes still surface
188
179
  throw err;
189
180
  };
190
181
  process.on("uncaughtException", _gsdEpipeGuard);
@@ -6,11 +6,10 @@
6
6
  * symlink replaces the original directory so all paths remain valid.
7
7
  */
8
8
 
9
- import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, cpSync, rmSync, symlinkSync } from "node:fs";
9
+ import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, cpSync, rmSync, symlinkSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { externalGsdRoot } from "./repo-identity.js";
12
12
  import { getErrorMessage } from "./error-utils.js";
13
- import { hasGitTrackedGsdFiles } from "./gitignore.js";
14
13
 
15
14
  export interface MigrationResult {
16
15
  migrated: boolean;
@@ -52,28 +51,6 @@ export function migrateToExternalState(basePath: string): MigrationResult {
52
51
  return { migrated: false, error: `Cannot stat .gsd: ${getErrorMessage(err)}` };
53
52
  }
54
53
 
55
- // Skip if .gsd/ contains git-tracked files — the project intentionally
56
- // keeps .gsd/ in version control and migration would destroy that.
57
- if (hasGitTrackedGsdFiles(basePath)) {
58
- return { migrated: false };
59
- }
60
-
61
- // Skip if .gsd/worktrees/ has active worktree directories (#1337).
62
- // On Windows, active git worktrees hold OS-level directory handles that
63
- // prevent rename/delete. Attempting migration causes EBUSY and data loss.
64
- const worktreesDir = join(localGsd, "worktrees");
65
- if (existsSync(worktreesDir)) {
66
- try {
67
- const entries = readdirSync(worktreesDir, { withFileTypes: true });
68
- if (entries.some(e => e.isDirectory())) {
69
- return { migrated: false };
70
- }
71
- } catch {
72
- // Can't read worktrees dir — skip migration to be safe
73
- return { migrated: false };
74
- }
75
- }
76
-
77
54
  const externalPath = externalGsdRoot(basePath);
78
55
  const migratingPath = join(basePath, ".gsd.migrating");
79
56
 
@@ -122,29 +99,7 @@ export function migrateToExternalState(basePath: string): MigrationResult {
122
99
  // Create symlink .gsd -> external path
123
100
  symlinkSync(externalPath, localGsd, "junction");
124
101
 
125
- // Verify the symlink resolves correctly before removing the backup (#1377).
126
- // On Windows, junction creation can silently succeed but resolve to the wrong
127
- // target, or the external dir may not be accessible. If verification fails,
128
- // restore from the backup.
129
- try {
130
- const resolved = realpathSync(localGsd);
131
- const resolvedExternal = realpathSync(externalPath);
132
- if (resolved !== resolvedExternal) {
133
- // Symlink points to wrong target — restore backup
134
- try { rmSync(localGsd, { force: true }); } catch { /* may not exist */ }
135
- renameSync(migratingPath, localGsd);
136
- return { migrated: false, error: `Migration verification failed: symlink resolves to ${resolved}, expected ${resolvedExternal}` };
137
- }
138
- // Verify we can read through the symlink
139
- readdirSync(localGsd);
140
- } catch (verifyErr) {
141
- // Symlink broken or unreadable — restore backup
142
- try { rmSync(localGsd, { force: true }); } catch { /* may not exist */ }
143
- try { renameSync(migratingPath, localGsd); } catch { /* best-effort restore */ }
144
- return { migrated: false, error: `Migration verification failed: ${getErrorMessage(verifyErr)}` };
145
- }
146
-
147
- // Remove .gsd.migrating only after symlink is verified
102
+ // Remove .gsd.migrating
148
103
  rmSync(migratingPath, { recursive: true, force: true });
149
104
 
150
105
  return { migrated: true };
@@ -10,8 +10,7 @@
10
10
  */
11
11
 
12
12
  import { readdirSync, existsSync, realpathSync, Dirent } from "node:fs";
13
- import { join, dirname, normalize } from "node:path";
14
- import { spawnSync } from "node:child_process";
13
+ import { join } from "node:path";
15
14
  import { nativeScanGsdTree, type GsdTreeEntry } from "./native-parser-bridge.js";
16
15
  import { DIR_CACHE_MAX } from "./constants.js";
17
16
 
@@ -278,80 +277,15 @@ const LEGACY_GSD_ROOT_FILES: Record<GSDRootFileKey, string> = {
278
277
  KNOWLEDGE: "knowledge.md",
279
278
  };
280
279
 
281
- // ─── GSD Root Discovery ───────────────────────────────────────────────────────
282
-
283
- const gsdRootCache = new Map<string, string>();
284
-
285
- /** Exported for tests only — do not call in production code. */
286
- export function _clearGsdRootCache(): void {
287
- gsdRootCache.clear();
288
- }
289
-
290
- /**
291
- * Resolve the `.gsd` directory for a given project base path.
292
- *
293
- * Probe order:
294
- * 1. basePath/.gsd — fast path (common case)
295
- * 2. git rev-parse root — handles cwd-is-a-subdirectory
296
- * 3. Walk up from basePath — handles moved .gsd in an ancestor (bounded by git root)
297
- * 4. basePath/.gsd — creation fallback (init scenario)
298
- *
299
- * Result is cached per basePath for the process lifetime.
300
- */
301
280
  export function gsdRoot(basePath: string): string {
302
- const cached = gsdRootCache.get(basePath);
303
- if (cached) return cached;
304
-
305
- const result = probeGsdRoot(basePath);
306
- gsdRootCache.set(basePath, result);
307
- return result;
308
- }
309
-
310
- function probeGsdRoot(rawBasePath: string): string {
311
- // 1. Fast path — check the input path directly
312
- const local = join(rawBasePath, ".gsd");
313
- if (existsSync(local)) return local;
314
-
315
- // Resolve symlinks so path comparisons work correctly across platforms
316
- // (e.g. macOS /var → /private/var). Use rawBasePath as fallback if not resolvable.
317
- let basePath: string;
318
- try { basePath = realpathSync.native(rawBasePath); } catch { basePath = rawBasePath; }
319
-
320
- // 2. Git root anchor — used as both probe target and walk-up boundary
321
- // Only walk if we're inside a git project — prevents escaping into
322
- // unrelated filesystem territory when running outside any repo.
323
- let gitRoot: string | null = null;
281
+ const local = join(basePath, ".gsd");
324
282
  try {
325
- const out = spawnSync("git", ["rev-parse", "--show-toplevel"], {
326
- cwd: basePath,
327
- encoding: "utf-8",
328
- });
329
- if (out.status === 0) {
330
- const r = out.stdout.trim();
331
- if (r) gitRoot = normalize(r);
332
- }
333
- } catch { /* git not available */ }
334
-
335
- if (gitRoot) {
336
- const candidate = join(gitRoot, ".gsd");
337
- if (existsSync(candidate)) return candidate;
338
- }
339
-
340
- // 3. Walk up from basePath to the git root (only if we are in a subdirectory)
341
- if (gitRoot && basePath !== gitRoot) {
342
- let cur = dirname(basePath);
343
- while (cur !== basePath) {
344
- const candidate = join(cur, ".gsd");
345
- if (existsSync(candidate)) return candidate;
346
- if (cur === gitRoot) break;
347
- basePath = cur;
348
- cur = dirname(cur);
349
- }
350
- }
351
-
352
- // 4. Fallback for init/creation
353
- return local;
283
+ const resolved = realpathSync(local);
284
+ if (resolved !== local) return resolved; // symlink resolved
285
+ } catch { /* doesn't exist yet — fall through */ }
286
+ return local; // backwards compat: unmigrated projects
354
287
  }
288
+
355
289
  export function milestonesDir(basePath: string): string {
356
290
  return join(gsdRoot(basePath), "milestones");
357
291
  }
@@ -149,15 +149,11 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null {
149
149
 
150
150
  // Build the prompt with variable substitution
151
151
  const [mid, sid, tid] = triggerUnitId.split("/");
152
- let prompt = config.prompt
152
+ const prompt = config.prompt
153
153
  .replace(/\{milestoneId\}/g, mid ?? "")
154
154
  .replace(/\{sliceId\}/g, sid ?? "")
155
155
  .replace(/\{taskId\}/g, tid ?? "");
156
156
 
157
- // Inject browser safety instruction for hooks that may use browser tools (#1345).
158
- // Vite HMR and other persistent connections prevent networkidle from resolving.
159
- prompt += "\n\n**Browser tool safety:** Do NOT use `browser_wait_for` with `condition: \"network_idle\"` — it hangs indefinitely when dev servers keep persistent connections (Vite HMR, WebSocket). Use `selector_visible`, `text_visible`, or `delay` instead.";
160
-
161
157
  return {
162
158
  hookName: config.name,
163
159
  prompt,
@@ -33,24 +33,9 @@ export function validatePreferences(preferences: GSDPreferences): {
33
33
  const validated: GSDPreferences = {};
34
34
 
35
35
  // ─── Unknown Key Detection ──────────────────────────────────────────
36
- // Common key migration hints for pi-level settings that don't map to GSD prefs
37
- const KEY_MIGRATION_HINTS: Record<string, string> = {
38
- taskIsolation: 'use "git.isolation" instead (values: worktree, branch, none)',
39
- task_isolation: 'use "git.isolation" instead (values: worktree, branch, none)',
40
- isolation: 'use "git.isolation" instead (values: worktree, branch, none)',
41
- manage_gitignore: 'use "git.manage_gitignore" instead',
42
- auto_push: 'use "git.auto_push" instead',
43
- main_branch: 'use "git.main_branch" instead',
44
- };
45
-
46
36
  for (const key of Object.keys(preferences)) {
47
37
  if (!KNOWN_PREFERENCE_KEYS.has(key)) {
48
- const hint = KEY_MIGRATION_HINTS[key];
49
- if (hint) {
50
- warnings.push(`unknown preference key "${key}" — ${hint}`);
51
- } else {
52
- warnings.push(`unknown preference key "${key}" — ignored`);
53
- }
38
+ warnings.push(`unknown preference key "${key}" — ignored`);
54
39
  }
55
40
  }
56
41
 
@@ -28,8 +28,6 @@ Then:
28
28
 
29
29
  **Important:** Do NOT skip the success criteria and definition of done verification (steps 3-4). The milestone summary must reflect actual verified outcomes, not assumed success. If any criterion was not met, document it clearly in the summary and do not mark the milestone as passing verification.
30
30
 
31
- **File system safety:** When scanning milestone directories for evidence, use `ls` or `find` to list directory contents first — never pass a directory path (e.g. `tasks/`, `slices/`) directly to the `read` tool. The `read` tool only accepts file paths, not directories.
32
-
33
31
  **You MUST write `{{milestoneSummaryPath}}` AND update PROJECT.md before finishing.**
34
32
 
35
33
  When done, say: "Milestone {{milestoneId}} complete."
@@ -67,6 +67,4 @@ If verdict is `needs-remediation`:
67
67
 
68
68
  **You MUST write `{{validationPath}}` before finishing.**
69
69
 
70
- **File system safety:** When scanning milestone directories for evidence, use `ls` or `find` to list directory contents first — never pass a directory path (e.g. `tasks/`, `slices/`) directly to the `read` tool. The `read` tool only accepts file paths, not directories.
71
-
72
70
  When done, say: "Milestone {{milestoneId}} validation complete — verdict: <verdict>."
@@ -57,19 +57,9 @@ let _lockCompromised: boolean = false;
57
57
  /** Whether we've already registered a process.on('exit') handler. */
58
58
  let _exitHandlerRegistered: boolean = false;
59
59
 
60
- /** Snapshotted lock file path — captured at acquireSessionLock time to avoid
61
- * gsdRoot() resolving differently in worktree vs project root contexts (#1363). */
62
- let _snapshotLockPath: string | null = null;
63
-
64
- /** Timestamp when the session lock was acquired — used to detect false-positive
65
- * onCompromised events from event loop stalls within the stale window (#1362). */
66
- let _lockAcquiredAt: number = 0;
67
-
68
60
  const LOCK_FILE = "auto.lock";
69
61
 
70
62
  function lockPath(basePath: string): string {
71
- // If we have a snapshotted path from acquisition, use it for consistency
72
- if (_snapshotLockPath) return _snapshotLockPath;
73
63
  return join(gsdRoot(basePath), LOCK_FILE);
74
64
  }
75
65
 
@@ -208,19 +198,8 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
208
198
  onCompromised: () => {
209
199
  // proper-lockfile detected mtime drift (system sleep, event loop stall, etc.).
210
200
  // Default handler throws inside setTimeout — an uncaught exception that crashes
211
- // or corrupts process state.
212
- //
213
- // False-positive suppression (#1362): If we're still within the stale window
214
- // (30 min since acquisition), the mtime mismatch is from an event loop stall
215
- // during a long LLM call — not a real takeover. Log and continue.
216
- const elapsed = Date.now() - _lockAcquiredAt;
217
- if (elapsed < 1_800_000) {
218
- process.stderr.write(
219
- `[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — event loop stall, continuing.\n`,
220
- );
221
- return; // Suppress false positive
222
- }
223
- // Past the stale window — this is a real compromise
201
+ // or corrupts process state. Instead, set a flag so validateSessionLock() can
202
+ // detect the compromise gracefully on the next dispatch cycle.
224
203
  _lockCompromised = true;
225
204
  _releaseFunction = null;
226
205
  },
@@ -230,8 +209,6 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
230
209
  _lockedPath = basePath;
231
210
  _lockPid = process.pid;
232
211
  _lockCompromised = false;
233
- _lockAcquiredAt = Date.now();
234
- _snapshotLockPath = lp; // Snapshot the resolved path for consistent access (#1363)
235
212
 
236
213
  // Safety net: clean up lock dir on process exit if _releaseFunction
237
214
  // wasn't called (e.g., normal exit after clean completion) (#1245).
@@ -268,8 +245,6 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
268
245
  _lockedPath = basePath;
269
246
  _lockPid = process.pid;
270
247
  _lockCompromised = false;
271
- _lockAcquiredAt = Date.now();
272
- _snapshotLockPath = lp; // Snapshot for retry path too (#1363)
273
248
 
274
249
  // Safety net — uses centralized handler to avoid double-registration
275
250
  ensureExitHandler(gsdDir);
@@ -419,8 +394,6 @@ export function releaseSessionLock(basePath: string): void {
419
394
  _lockedPath = null;
420
395
  _lockPid = 0;
421
396
  _lockCompromised = false;
422
- _lockAcquiredAt = 0;
423
- _snapshotLockPath = null;
424
397
  }
425
398
 
426
399
  /**
@@ -113,14 +113,6 @@
113
113
  - Tasks execute sequentially in order (T01, T02, T03, ...)
114
114
  - est: is informational (e.g. 30m, 1h, 2h) and optional
115
115
 
116
- Verify field rules:
117
- - MUST be a mechanically executable command: `npm test`, `grep -q "pattern" file`, `test -f path`
118
- - For content/document tasks: verify file existence, section count, YAML validity, or word count
119
- NOT exact phrasing, specific formulas, or "zero TBD" aspirational criteria
120
- - If no command can verify the output, write: "Manual review — file exists and is non-empty"
121
- - BAD: "Sections 3.1 and 3.2 exist with exact formulas. Zero TBD/TODO."
122
- - GOOD: `grep -c "^## " doc.md` returns >= 4 (4+ sections), `! grep -q "TBD\|TODO" doc.md`
123
-
124
116
  Integration closure rule:
125
117
  - At least one slice in any multi-boundary milestone should perform real composition/wiring, not just contract hardening
126
118
  - For the final assembly slice, verification must exercise the real entrypoint or runtime path
@@ -40,18 +40,8 @@ test("git.merge_to_main produces deprecation warning", () => {
40
40
  });
41
41
 
42
42
 
43
- test("getIsolationMode defaults to worktree when preferences have no isolation setting", () => {
44
- // Validate the default via validatePreferences: when no isolation is set,
45
- // preferences.git.isolation is undefined, and getIsolationMode returns "worktree".
46
- // We test the function's logic by verifying its documented default.
47
- const { preferences } = validatePreferences({});
48
- assert.equal(preferences.git?.isolation, undefined, "no isolation in empty prefs");
49
- // The function returns "worktree" when prefs?.git?.isolation is not "none" or "branch"
50
- // This is a compile-time-verifiable truth from the function body — test it directly
51
- // by constructing the same conditions getIsolationMode checks.
52
- const isolation = preferences.git?.isolation;
53
- const expected = isolation === "none" ? "none" : isolation === "branch" ? "branch" : "worktree";
54
- assert.equal(expected, "worktree", "default isolation mode is worktree");
43
+ test("getIsolationMode defaults to worktree when no prefs file", { skip: "requires no global ~/.gsd/preferences.md" }, () => {
44
+ assert.equal(getIsolationMode(), "worktree");
55
45
  });
56
46
 
57
47
  // ── Mode defaults ────────────────────────────────────────────────────────────
@@ -101,21 +101,6 @@ test("validateDirectory: subdirectory of home is NOT blocked", () => {
101
101
  }
102
102
  });
103
103
 
104
- // Regression test for #1317: GSD worktree inside $HOME must not be blocked even
105
- // when the resolved project root equals $HOME (e.g. home dir is a git repo).
106
- test("validateDirectory: GSD worktree path nested under home is NOT blocked (#1317)", () => {
107
- const worktreePath = join(homedir(), ".gsd", "worktrees", "M001");
108
- mkdirSync(worktreePath, { recursive: true });
109
- try {
110
- // The worktree CWD itself is a valid location — it must pass.
111
- const result = validateDirectory(worktreePath);
112
- assert.equal(result.safe, true, "GSD worktree path should be safe to run in");
113
- assert.equal(result.severity, "ok");
114
- } finally {
115
- rmSync(join(homedir(), ".gsd", "worktrees", "M001"), { recursive: true, force: true });
116
- }
117
- });
118
-
119
104
  // ─── Temp directory root ─────────────────────────────────────────────────────────
120
105
 
121
106
  test("validateDirectory: temp directory root is blocked", () => {
@@ -196,7 +196,6 @@ test("dispatch rule matches validating-milestone phase", async () => {
196
196
  try {
197
197
  // Set up minimal milestone structure for the prompt builder
198
198
  writeRoadmap(base, "M001", ALL_DONE_ROADMAP);
199
- writeSliceSummary(base, "M001", "S01", "# S01 Summary\nDone."); // Guard requires slice summaries (#1368)
200
199
 
201
200
  const ctx: DispatchContext = {
202
201
  basePath: base,
@@ -232,7 +231,6 @@ test("dispatch rule skips when skip_milestone_validation preference is set", asy
232
231
  const base = makeTmpBase();
233
232
  try {
234
233
  writeRoadmap(base, "M001", ALL_DONE_ROADMAP);
235
- writeSliceSummary(base, "M001", "S01", "# S01 Summary\nDone."); // Guard requires slice summaries (#1368)
236
234
 
237
235
  const ctx: DispatchContext = {
238
236
  basePath: base,