gsd-pi 2.33.0-dev.69bff0f → 2.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/README.md +18 -13
  2. package/dist/resources/extensions/gsd/auto-supervisor.ts +5 -10
  3. package/dist/resources/extensions/gsd/auto-worktree.ts +1 -135
  4. package/dist/resources/extensions/gsd/commands.ts +2 -14
  5. package/dist/resources/extensions/gsd/session-lock.ts +16 -80
  6. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +1 -39
  7. package/dist/resources/extensions/gsd/tests/session-lock.test.ts +0 -119
  8. package/dist/resources/extensions/mcporter/extension-manifest.json +12 -0
  9. package/package.json +1 -1
  10. package/src/resources/extensions/gsd/auto-supervisor.ts +5 -10
  11. package/src/resources/extensions/gsd/auto-worktree.ts +1 -135
  12. package/src/resources/extensions/gsd/commands.ts +2 -14
  13. package/src/resources/extensions/gsd/session-lock.ts +16 -80
  14. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +1 -39
  15. package/src/resources/extensions/gsd/tests/session-lock.test.ts +0 -119
  16. package/src/resources/extensions/mcporter/extension-manifest.json +12 -0
  17. package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +0 -691
  18. package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +0 -317
  19. package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +0 -358
  20. package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +0 -216
  21. package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +0 -206
  22. package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +0 -691
  23. package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +0 -317
  24. package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +0 -358
  25. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +0 -216
  26. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +0 -206
package/README.md CHANGED
@@ -24,19 +24,24 @@ One command. Walk away. Come back to a built project with clean git history.
24
24
 
25
25
  ---
26
26
 
27
- ## What's New in v2.33
28
-
29
- - **Dispatch loop hardening** — defensive guards, reentrancy protection, and 125 new regression tests covering the full `deriveState → resolveDispatch` chain without an LLM
30
- - **Live regression test harness** — post-build pipeline validation that catches dispatch, parser, and lock lifecycle regressions before promotion
31
- - **Unified error handling** — `getErrorMessage()` helper replaces 65 inline duplicates across the codebase
32
- - **Centralized unit ID parsing** — `parseUnitId()` eliminates fragile regex patterns scattered across dispatch, recovery, and metrics code
33
- - **Milestone merge consolidation** — `tryMergeMilestone()` replaces 4 duplicate merge paths in the auto-mode loop
34
- - **Lock alignment fix** — retry lock path now matches primary lock settings, preventing `ECOMPROMISED` errors on resume
35
- - **NixOS/nix-darwin support** — symlinks in `.gsd/` are skipped during `makeTreeWritable` to prevent `EPERM` failures
36
- - **Windows EPERM fallback** — `.gsd/` migration uses copy+delete when NTFS blocks direct rename
37
- - **Worktree identity fix** — stable project hash resolved from main repo root, not worktree path
38
- - **Quick-task branch cleanup** — `/gsd quick` branches auto-merge back to the original branch after completion
39
- - **Crash recovery guidance** — actionable next-step messages based on what was interrupted and what state survived
27
+ ## What's New in v2.32
28
+
29
+ - **Simplified pipeline** — research merged into planning, mechanical completion (ADR-003)
30
+ - **Always-on health widget** — 🟢🟡🔴 traffic-light indicator in the progress widget and visualizer health tab
31
+ - **Environment health checks** — progress scoring and status integration for auto-mode
32
+ - **Extension registry** — user-managed enable/disable for bundled and custom extensions
33
+ - **Built-in skill authoring** — create and distribute custom skills from within GSD
34
+ - **Workflow templates** — right-sized workflows for every task type (research, plan, execute, complete)
35
+ - **AWS Bedrock auth** — automatic credential refresh via the new `aws-auth` extension
36
+ - **`-w` / `--worktree` CLI flag** — launch isolated worktree sessions from the command line
37
+ - **Native MCP client** — replaced MCPorter with a built-in MCP client for better reliability
38
+ - **External state directory** — `.gsd/` now lives in `~/.gsd/projects/` with a symlink (ADR-002)
39
+ - **Model health indicator** — live health status based on error trends and consecutive failures
40
+ - **Quick-task branch cleanup** — `/gsd quick` branches auto-merge back after completion
41
+ - **Windows EPERM fallback** — migration rename uses copy+delete when NTFS blocks rename
42
+ - **Worktree identity fix** — stable project hash across worktrees and main repo
43
+ - **Crash recovery guidance** — actionable next-step messages based on what was interrupted
44
+ - **UAT verdict gating** — non-PASS verdicts now block slice progression instead of being ignored
40
45
 
41
46
  See the full [Changelog](./CHANGELOG.md) for details.
42
47
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Auto-mode Supervisor — signal handling and working-tree activity detection.
2
+ * Auto-mode Supervisor — SIGTERM handling and working-tree activity detection.
3
3
  *
4
4
  * Pure functions — no module-level globals or AutoContext dependency.
5
5
  */
@@ -8,10 +8,10 @@ import { clearLock } from "./crash-recovery.js";
8
8
  import { releaseSessionLock } from "./session-lock.js";
9
9
  import { nativeHasChanges } from "./native-git-bridge.js";
10
10
 
11
- // ─── Signal Handling ──────────────────────────────────────────────────────────
11
+ // ─── SIGTERM Handling ─────────────────────────────────────────────────────────
12
12
 
13
13
  /**
14
- * Register SIGTERM and SIGINT handlers that clear lock files and exit cleanly.
14
+ * Register a SIGTERM handler that clears the lock file and exits cleanly.
15
15
  * Captures the active base path at registration time so the handler
16
16
  * always references the correct path even if the module variable changes.
17
17
  * Removes any previously registered handler before installing the new one.
@@ -22,25 +22,20 @@ export function registerSigtermHandler(
22
22
  currentBasePath: string,
23
23
  previousHandler: (() => void) | null,
24
24
  ): () => void {
25
- if (previousHandler) {
26
- process.off("SIGTERM", previousHandler);
27
- process.off("SIGINT", previousHandler);
28
- }
25
+ if (previousHandler) process.off("SIGTERM", previousHandler);
29
26
  const handler = () => {
30
27
  releaseSessionLock(currentBasePath);
31
28
  clearLock(currentBasePath);
32
29
  process.exit(0);
33
30
  };
34
31
  process.on("SIGTERM", handler);
35
- process.on("SIGINT", handler);
36
32
  return handler;
37
33
  }
38
34
 
39
- /** Deregister signal handlers (called on stop/pause). */
35
+ /** Deregister the SIGTERM handler (called on stop/pause). */
40
36
  export function deregisterSigtermHandler(handler: (() => void) | null): void {
41
37
  if (handler) {
42
38
  process.off("SIGTERM", handler);
43
- process.off("SIGINT", handler);
44
39
  }
45
40
  }
46
41
 
@@ -6,7 +6,7 @@
6
6
  * manages create, enter, detect, and teardown for auto-mode worktrees.
7
7
  */
8
8
 
9
- import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync, readdirSync, cpSync, lstatSync as lstatSyncFn } from "node:fs";
9
+ import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync } from "node:fs";
10
10
  import { isAbsolute, join, sep } from "node:path";
11
11
  import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
12
12
  import { execSync, execFileSync } from "node:child_process";
@@ -45,122 +45,6 @@ import { getErrorMessage } from "./error-utils.js";
45
45
  /** Original project root before chdir into auto-worktree. */
46
46
  let originalBase: string | null = null;
47
47
 
48
- // ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
49
-
50
- /**
51
- * Sync .gsd/ state from the main repo into the worktree.
52
- *
53
- * When .gsd/ is a symlink to the external state directory, both the main
54
- * repo and worktree share the same directory — no sync needed.
55
- *
56
- * When .gsd/ is a real directory (e.g., git-tracked or manage_gitignore:false),
57
- * the worktree has its own copy that may be stale. This function copies
58
- * missing milestones, CONTEXT, ROADMAP, DECISIONS, REQUIREMENTS, and
59
- * PROJECT files from the main repo's .gsd/ into the worktree's .gsd/.
60
- *
61
- * Only adds missing content — never overwrites existing files in the worktree
62
- * (the worktree's execution state is authoritative for in-progress work).
63
- */
64
- export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: string): { synced: string[] } {
65
- const mainGsd = gsdRoot(mainBasePath);
66
- const wtGsd = gsdRoot(worktreePath_);
67
- const synced: string[] = [];
68
-
69
- // If both resolve to the same directory (symlink), no sync needed
70
- try {
71
- const mainResolved = realpathSync(mainGsd);
72
- const wtResolved = realpathSync(wtGsd);
73
- if (mainResolved === wtResolved) return { synced };
74
- } catch {
75
- // Can't resolve — proceed with sync as a safety measure
76
- }
77
-
78
- if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
79
-
80
- // Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE)
81
- const rootFiles = ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md", "OVERRIDES.md"];
82
- for (const f of rootFiles) {
83
- const src = join(mainGsd, f);
84
- const dst = join(wtGsd, f);
85
- if (existsSync(src) && !existsSync(dst)) {
86
- try {
87
- cpSync(src, dst);
88
- synced.push(f);
89
- } catch { /* non-fatal */ }
90
- }
91
- }
92
-
93
- // Sync milestones: copy entire milestone directories that are missing
94
- const mainMilestonesDir = join(mainGsd, "milestones");
95
- const wtMilestonesDir = join(wtGsd, "milestones");
96
- if (existsSync(mainMilestonesDir) && existsSync(wtMilestonesDir)) {
97
- try {
98
- const mainMilestones = readdirSync(mainMilestonesDir, { withFileTypes: true })
99
- .filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
100
- .map(d => d.name);
101
-
102
- for (const mid of mainMilestones) {
103
- const srcDir = join(mainMilestonesDir, mid);
104
- const dstDir = join(wtMilestonesDir, mid);
105
-
106
- if (!existsSync(dstDir)) {
107
- // Entire milestone missing from worktree — copy it
108
- try {
109
- cpSync(srcDir, dstDir, { recursive: true });
110
- synced.push(`milestones/${mid}/`);
111
- } catch { /* non-fatal */ }
112
- } else {
113
- // Milestone directory exists but may be missing files (stale snapshot).
114
- // Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.)
115
- try {
116
- const srcFiles = readdirSync(srcDir).filter(f => f.endsWith(".md") || f.endsWith(".json"));
117
- for (const f of srcFiles) {
118
- const srcFile = join(srcDir, f);
119
- const dstFile = join(dstDir, f);
120
- if (!existsSync(dstFile)) {
121
- try {
122
- const srcStat = lstatSyncFn(srcFile);
123
- if (srcStat.isFile()) {
124
- cpSync(srcFile, dstFile);
125
- synced.push(`milestones/${mid}/${f}`);
126
- }
127
- } catch { /* non-fatal */ }
128
- }
129
- }
130
-
131
- // Sync slices directory if it exists in main but not in worktree
132
- const srcSlicesDir = join(srcDir, "slices");
133
- const dstSlicesDir = join(dstDir, "slices");
134
- if (existsSync(srcSlicesDir) && !existsSync(dstSlicesDir)) {
135
- try {
136
- cpSync(srcSlicesDir, dstSlicesDir, { recursive: true });
137
- synced.push(`milestones/${mid}/slices/`);
138
- } catch { /* non-fatal */ }
139
- } else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) {
140
- // Both exist — sync missing slice directories
141
- const srcSlices = readdirSync(srcSlicesDir, { withFileTypes: true })
142
- .filter(d => d.isDirectory())
143
- .map(d => d.name);
144
- for (const sid of srcSlices) {
145
- const srcSlice = join(srcSlicesDir, sid);
146
- const dstSlice = join(dstSlicesDir, sid);
147
- if (!existsSync(dstSlice)) {
148
- try {
149
- cpSync(srcSlice, dstSlice, { recursive: true });
150
- synced.push(`milestones/${mid}/slices/${sid}/`);
151
- } catch { /* non-fatal */ }
152
- }
153
- }
154
- }
155
- } catch { /* non-fatal */ }
156
- }
157
- }
158
- } catch { /* non-fatal */ }
159
- }
160
-
161
- return { synced };
162
- }
163
-
164
48
  // ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
165
49
 
166
50
  /**
@@ -241,12 +125,6 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
241
125
  // Ensure worktree shares external state via symlink
242
126
  ensureGsdSymlink(info.path);
243
127
 
244
- // Sync .gsd/ state from main repo into the worktree (#1311).
245
- // Even with the symlink, the worktree may have stale git-tracked files
246
- // if .gsd/ is not gitignored. And on fresh create, the milestone files
247
- // created on main since the branch point won't be in the worktree.
248
- syncGsdStateToWorktree(basePath, info.path);
249
-
250
128
  // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
251
129
  const hookError = runWorktreePostCreateHook(basePath, info.path);
252
130
  if (hookError) {
@@ -389,18 +267,6 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string
389
267
  throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`);
390
268
  }
391
269
 
392
- // Ensure worktree shares external state via symlink (#1311).
393
- // On resume (enterAutoWorktree), the symlink may be missing if it was
394
- // created before ensureGsdSymlink existed, or the .gsd/ directory may be
395
- // a stale git-tracked copy instead of a symlink. Refreshing here ensures
396
- // the worktree sees the same milestone state as the main repo.
397
- ensureGsdSymlink(p);
398
-
399
- // Sync .gsd/ state from main repo into worktree (#1311).
400
- // Covers the case where .gsd/ is a real directory (not symlinked) and
401
- // milestones were created on main after the worktree was last used.
402
- syncGsdStateToWorktree(basePath, p);
403
-
404
270
  const previousCwd = process.cwd();
405
271
 
406
272
  try {
@@ -52,20 +52,8 @@ import { handleStart, handleTemplates, getTemplateCompletions } from "./commands
52
52
 
53
53
  /** Resolve the effective project root, accounting for worktree paths. */
54
54
  export function projectRoot(): string {
55
- const cwd = process.cwd();
56
- const root = resolveProjectRoot(cwd);
57
-
58
- // When running inside a GSD worktree, the resolved root may be a "dangerous"
59
- // directory (e.g., $HOME used as a git repo root — #1317). The safety check
60
- // should validate the actual working directory, not the upstream root,
61
- // because the worktree itself is a safe project subdirectory.
62
- // Only skip the root check when we can confirm we're in a valid worktree.
63
- if (root !== cwd) {
64
- // We're in a worktree — validate the worktree path instead of the root
65
- assertSafeDirectory(cwd);
66
- } else {
67
- assertSafeDirectory(root);
68
- }
55
+ const root = resolveProjectRoot(process.cwd());
56
+ assertSafeDirectory(root);
69
57
  return root;
70
58
  }
71
59
 
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { createRequire } from "node:module";
20
- import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
20
+ import { existsSync, readFileSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
21
21
  import { join, dirname } from "node:path";
22
22
  import { gsdRoot } from "./paths.js";
23
23
  import { atomicWriteSync } from "./atomic-write.js";
@@ -54,81 +54,12 @@ let _lockPid: number = 0;
54
54
  /** Set to true when proper-lockfile fires onCompromised (mtime drift, sleep, etc.). */
55
55
  let _lockCompromised: boolean = false;
56
56
 
57
- /** Whether we've already registered a process.on('exit') handler. */
58
- let _exitHandlerRegistered: boolean = false;
59
-
60
57
  const LOCK_FILE = "auto.lock";
61
58
 
62
59
  function lockPath(basePath: string): string {
63
60
  return join(gsdRoot(basePath), LOCK_FILE);
64
61
  }
65
62
 
66
- // ─── Stray Lock Cleanup ─────────────────────────────────────────────────────
67
-
68
- /**
69
- * Remove numbered lock file variants (e.g. "auto 2.lock", "auto 3.lock")
70
- * that accumulate from macOS file conflict resolution (iCloud/Dropbox/OneDrive)
71
- * or other filesystem-level copy-on-conflict behavior (#1315).
72
- *
73
- * Also removes stray proper-lockfile directories beyond the canonical `.gsd.lock/`.
74
- */
75
- export function cleanupStrayLockFiles(basePath: string): void {
76
- const gsdDir = gsdRoot(basePath);
77
-
78
- // Clean numbered auto lock files inside .gsd/
79
- try {
80
- if (existsSync(gsdDir)) {
81
- for (const entry of readdirSync(gsdDir)) {
82
- // Match "auto <N>.lock" or "auto (<N>).lock" variants but NOT the canonical "auto.lock"
83
- if (entry !== LOCK_FILE && /^auto\s.+\.lock$/i.test(entry)) {
84
- try { unlinkSync(join(gsdDir, entry)); } catch { /* best-effort */ }
85
- }
86
- }
87
- }
88
- } catch { /* non-fatal: directory read failure */ }
89
-
90
- // Clean stray proper-lockfile directories (e.g. ".gsd 2.lock/")
91
- // The canonical one is ".gsd.lock/" — anything else is stray.
92
- try {
93
- const parentDir = dirname(gsdDir);
94
- const gsdDirName = gsdDir.split("/").pop() || ".gsd";
95
- if (existsSync(parentDir)) {
96
- for (const entry of readdirSync(parentDir)) {
97
- // Match ".gsd <N>.lock" or ".gsd (<N>).lock" directories but NOT ".gsd.lock"
98
- if (entry !== `${gsdDirName}.lock` && entry.startsWith(gsdDirName) && entry.endsWith(".lock")) {
99
- const fullPath = join(parentDir, entry);
100
- try {
101
- const stat = statSync(fullPath);
102
- if (stat.isDirectory()) {
103
- rmSync(fullPath, { recursive: true, force: true });
104
- }
105
- } catch { /* best-effort */ }
106
- }
107
- }
108
- }
109
- } catch { /* non-fatal */ }
110
- }
111
-
112
- /**
113
- * Register a single process exit handler that cleans up lock state.
114
- * Uses module-level references so it always operates on current state.
115
- * Only registers once — subsequent calls are no-ops.
116
- */
117
- function ensureExitHandler(gsdDir: string): void {
118
- if (_exitHandlerRegistered) return;
119
- _exitHandlerRegistered = true;
120
-
121
- process.once("exit", () => {
122
- try {
123
- if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
124
- } catch { /* best-effort */ }
125
- try {
126
- const lockDir = join(gsdDir + ".lock");
127
- if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true });
128
- } catch { /* best-effort */ }
129
- });
130
- }
131
-
132
63
  // ─── Public API ─────────────────────────────────────────────────────────────
133
64
 
134
65
  /**
@@ -146,9 +77,6 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
146
77
  // Ensure the directory exists
147
78
  mkdirSync(dirname(lp), { recursive: true });
148
79
 
149
- // Clean up numbered lock file variants from cloud sync conflicts (#1315)
150
- cleanupStrayLockFiles(basePath);
151
-
152
80
  // Write our lock data first (the content is informational; the OS lock is the real guard)
153
81
  const lockData: SessionLockData = {
154
82
  pid: process.pid,
@@ -196,7 +124,15 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
196
124
 
197
125
  // Safety net: clean up lock dir on process exit if _releaseFunction
198
126
  // wasn't called (e.g., normal exit after clean completion) (#1245).
199
- ensureExitHandler(gsdDir);
127
+ const lockDirForCleanup = join(gsdDir + ".lock");
128
+ process.once("exit", () => {
129
+ try {
130
+ if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
131
+ } catch { /* best-effort */ }
132
+ try {
133
+ if (existsSync(lockDirForCleanup)) rmSync(lockDirForCleanup, { recursive: true, force: true });
134
+ } catch { /* best-effort */ }
135
+ });
200
136
 
201
137
  // Write the informational lock data
202
138
  atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
@@ -222,15 +158,18 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
222
158
  update: 10_000,
223
159
  onCompromised: () => {
224
160
  _lockCompromised = true;
225
- _releaseFunction = null;
226
161
  },
227
162
  });
228
163
  _releaseFunction = release;
229
164
  _lockedPath = basePath;
230
165
  _lockPid = process.pid;
231
166
 
232
- // Safety net uses centralized handler to avoid double-registration
233
- ensureExitHandler(gsdDir);
167
+ // Safety net for retry path too
168
+ const retryLockDir = join(gsdDir + ".lock");
169
+ process.once("exit", () => {
170
+ try { if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; } } catch {}
171
+ try { if (existsSync(retryLockDir)) rmSync(retryLockDir, { recursive: true, force: true }); } catch {}
172
+ });
234
173
 
235
174
  atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
236
175
  return { acquired: true };
@@ -371,9 +310,6 @@ export function releaseSessionLock(basePath: string): void {
371
310
  // Non-fatal
372
311
  }
373
312
 
374
- // Clean up numbered lock file variants from cloud sync conflicts (#1315)
375
- cleanupStrayLockFiles(basePath);
376
-
377
313
  _lockedPath = null;
378
314
  _lockPid = 0;
379
315
  _lockCompromised = false;
@@ -609,48 +609,10 @@ test("session lock: onCompromised handler exists in both primary and retry paths
609
609
  const compromisedMatches = [...lockSource.matchAll(/onCompromised/g)];
610
610
  // Should have at least 2 onCompromised handlers (primary + retry)
611
611
  // plus the flag declaration and the check in validateSessionLock
612
- assert.ok(compromisedMatches.length >= 3,
612
+ assert.ok(compromisedMatches.length >= 3,
613
613
  `expected ≥3 onCompromised references (primary + retry + flag), got ${compromisedMatches.length}`);
614
614
  });
615
615
 
616
- test("session lock: both onCompromised handlers null _releaseFunction (#1315)", async () => {
617
- const lockSource = readFileSync(
618
- "src/resources/extensions/gsd/session-lock.ts", "utf-8"
619
- );
620
- // Extract onCompromised handler blocks — both should set _releaseFunction = null
621
- const handlers = lockSource.match(/onCompromised:\s*\(\)\s*=>\s*\{[^}]+\}/g) || [];
622
- assert.ok(handlers.length >= 2, `expected ≥2 onCompromised handlers, got ${handlers.length}`);
623
- for (const h of handlers) {
624
- assert.ok(h.includes("_releaseFunction = null"),
625
- `onCompromised handler should null _releaseFunction: ${h}`);
626
- }
627
- });
628
-
629
- test("session lock: exit handler uses ensureExitHandler to prevent double-registration (#1315)", async () => {
630
- const lockSource = readFileSync(
631
- "src/resources/extensions/gsd/session-lock.ts", "utf-8"
632
- );
633
- // Should use ensureExitHandler instead of direct process.once("exit") in acquire paths
634
- const directExitHandlers = (lockSource.match(/process\.once\("exit"/g) || []).length;
635
- const ensureExitCalls = (lockSource.match(/ensureExitHandler\(/g) || []).length;
636
- // Only 1 direct process.once("exit") allowed — inside ensureExitHandler itself
637
- assert.ok(directExitHandlers <= 1,
638
- `expected ≤1 direct process.once("exit") (inside ensureExitHandler), got ${directExitHandlers}`);
639
- assert.ok(ensureExitCalls >= 2,
640
- `expected ≥2 ensureExitHandler calls (primary + retry path), got ${ensureExitCalls}`);
641
- });
642
-
643
- test("signal handler: SIGINT handler registered alongside SIGTERM (#1315)", async () => {
644
- const supervisorSource = readFileSync(
645
- "src/resources/extensions/gsd/auto-supervisor.ts", "utf-8"
646
- );
647
- // registerSigtermHandler should register on both SIGTERM and SIGINT
648
- assert.ok(supervisorSource.includes('process.on("SIGINT"') || supervisorSource.includes("process.on('SIGINT'"),
649
- "registerSigtermHandler should register SIGINT handler");
650
- assert.ok(supervisorSource.includes('process.off("SIGINT"') || supervisorSource.includes("process.off('SIGINT'"),
651
- "deregisterSigtermHandler should deregister SIGINT handler");
652
- });
653
-
654
616
  // ─── Scope 5: Crash Recovery — Message Guidance per Unit Type ────────────
655
617
 
656
618
  test("crash recovery: formatCrashInfo includes guidance for bootstrap crash", async () => {
@@ -12,7 +12,6 @@ import {
12
12
  readSessionLockData,
13
13
  isSessionLockHeld,
14
14
  isSessionLockProcessAlive,
15
- cleanupStrayLockFiles,
16
15
  } from "../session-lock.ts";
17
16
 
18
17
  // ─── acquireSessionLock ──────────────────────────────────────────────────
@@ -314,121 +313,3 @@ test("acquireSessionLock creates .gsd/ if it does not exist", () => {
314
313
  releaseSessionLock(dir);
315
314
  rmSync(dir, { recursive: true, force: true });
316
315
  });
317
-
318
- // ─── cleanupStrayLockFiles (#1315) ──────────────────────────────────────
319
-
320
- test("cleanupStrayLockFiles removes numbered lock variants but preserves auto.lock", () => {
321
- const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
322
- const gsdDir = join(dir, ".gsd");
323
- mkdirSync(gsdDir, { recursive: true });
324
-
325
- // Create canonical lock file + numbered variants
326
- writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
327
- writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":2}');
328
- writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":3}');
329
- writeFileSync(join(gsdDir, "auto 4.lock"), '{"pid":4}');
330
-
331
- cleanupStrayLockFiles(dir);
332
-
333
- assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
334
- assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed");
335
- assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed");
336
- assert.ok(!existsSync(join(gsdDir, "auto 4.lock")), "auto 4.lock should be removed");
337
-
338
- rmSync(dir, { recursive: true, force: true });
339
- });
340
-
341
- test("cleanupStrayLockFiles handles parenthesized variants", () => {
342
- const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
343
- const gsdDir = join(dir, ".gsd");
344
- mkdirSync(gsdDir, { recursive: true });
345
-
346
- // macOS sometimes uses parenthesized format: "auto (2).lock"
347
- writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
348
- writeFileSync(join(gsdDir, "auto (2).lock"), '{"pid":2}');
349
-
350
- cleanupStrayLockFiles(dir);
351
-
352
- assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
353
- assert.ok(!existsSync(join(gsdDir, "auto (2).lock")), "auto (2).lock should be removed");
354
-
355
- rmSync(dir, { recursive: true, force: true });
356
- });
357
-
358
- test("cleanupStrayLockFiles does not remove unrelated files", () => {
359
- const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
360
- const gsdDir = join(dir, ".gsd");
361
- mkdirSync(gsdDir, { recursive: true });
362
-
363
- // Create unrelated files that should NOT be removed
364
- writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
365
- writeFileSync(join(gsdDir, "config.json"), '{}');
366
- writeFileSync(join(gsdDir, "other.lock"), '{}');
367
-
368
- cleanupStrayLockFiles(dir);
369
-
370
- assert.ok(existsSync(join(gsdDir, "auto.lock")), "auto.lock should be preserved");
371
- assert.ok(existsSync(join(gsdDir, "config.json")), "config.json should be preserved");
372
- assert.ok(existsSync(join(gsdDir, "other.lock")), "other.lock should be preserved");
373
-
374
- rmSync(dir, { recursive: true, force: true });
375
- });
376
-
377
- test("cleanupStrayLockFiles is safe on empty directory", () => {
378
- const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
379
- const gsdDir = join(dir, ".gsd");
380
- mkdirSync(gsdDir, { recursive: true });
381
-
382
- // Should not throw
383
- cleanupStrayLockFiles(dir);
384
-
385
- rmSync(dir, { recursive: true, force: true });
386
- });
387
-
388
- test("cleanupStrayLockFiles is safe when .gsd/ does not exist", () => {
389
- const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
390
-
391
- // Should not throw even without .gsd/
392
- cleanupStrayLockFiles(dir);
393
-
394
- rmSync(dir, { recursive: true, force: true });
395
- });
396
-
397
- test("acquireSessionLock cleans stray lock files before acquiring", () => {
398
- const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
399
- const gsdDir = join(dir, ".gsd");
400
- mkdirSync(gsdDir, { recursive: true });
401
-
402
- // Plant stray lock files before acquire
403
- writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
404
- writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":9999998}');
405
-
406
- const result = acquireSessionLock(dir);
407
- assert.equal(result.acquired, true, "should acquire lock");
408
-
409
- // Stray files should be cleaned up
410
- assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during acquire");
411
- assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed during acquire");
412
-
413
- releaseSessionLock(dir);
414
- rmSync(dir, { recursive: true, force: true });
415
- });
416
-
417
- test("releaseSessionLock cleans stray lock files after releasing", () => {
418
- const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
419
- const gsdDir = join(dir, ".gsd");
420
- mkdirSync(gsdDir, { recursive: true });
421
-
422
- const result = acquireSessionLock(dir);
423
- assert.equal(result.acquired, true);
424
-
425
- // Plant stray lock files (simulating cloud sync creating them during session)
426
- writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
427
-
428
- releaseSessionLock(dir);
429
-
430
- assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during release");
431
- assert.ok(!existsSync(join(gsdDir, "auto.lock")), "auto.lock should also be removed");
432
-
433
- rmSync(dir, { recursive: true, force: true });
434
- });
@@ -0,0 +1,12 @@
1
+ {
2
+ "id": "mcporter",
3
+ "name": "MCPorter",
4
+ "version": "1.0.0",
5
+ "description": "Discover and call tools from MCP servers configured in Claude Desktop, Cursor, and VS Code",
6
+ "tier": "bundled",
7
+ "requires": { "platform": ">=2.29.0" },
8
+ "provides": {
9
+ "tools": ["mcp_servers", "mcp_discover", "mcp_call"],
10
+ "hooks": ["session_start"]
11
+ }
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.33.0-dev.69bff0f",
3
+ "version": "2.33.0",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {