gsd-lite 0.7.6 → 0.7.7

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.
@@ -13,7 +13,7 @@
13
13
  "name": "gsd",
14
14
  "source": "./",
15
15
  "description": "AI orchestration tool — GSD management shell + Superpowers quality core. 5 commands, 4 agents, 5 workflows, MCP server, context monitoring.",
16
- "version": "0.7.6",
16
+ "version": "0.7.7",
17
17
  "keywords": [
18
18
  "orchestration",
19
19
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "author": {
6
6
  "name": "sdsrss",
@@ -29,6 +29,35 @@ const LOCK_STALE_MS = 10_000;
29
29
  const LOCK_RETRY_MS = 50;
30
30
  const LOCK_MAX_RETRIES = 100;
31
31
 
32
+ // ── Orphan Detection ───────────────────────────────────────
33
+ // Mirrors gsd-session-init.cjs Phase 0. When the plugin was removed via
34
+ // /plugin uninstall but install.js-written state (hook files, runtime dir,
35
+ // settings.json) survives, getInstallMode() falls through to 'manual' and a
36
+ // new GitHub release would re-trigger install.js — resurrecting the plugin.
37
+ // Guard here so checkForUpdate is a no-op until the orphan is cleaned up
38
+ // (session-init Phase 0 handles the cleanup itself).
39
+ function isOrphan() {
40
+ const installModeMarker = path.join(claudeDir, 'gsd', '.install-mode');
41
+ let mode = null;
42
+ try { mode = fs.readFileSync(installModeMarker, 'utf8').trim(); } catch { /* missing → fall through */ }
43
+ if (mode === 'manual') return false;
44
+ if (mode === 'plugin') {
45
+ try {
46
+ const data = JSON.parse(fs.readFileSync(path.join(claudeDir, 'plugins', 'installed_plugins.json'), 'utf8'));
47
+ return !data.plugins?.['gsd@gsd'];
48
+ } catch { return false; }
49
+ }
50
+ // Pre-marker fallback: orphan iff every cached version has .orphaned_at.
51
+ const cacheBase = path.join(claudeDir, 'plugins', 'cache', 'gsd', 'gsd');
52
+ if (!fs.existsSync(cacheBase)) return false;
53
+ try {
54
+ const dirs = fs.readdirSync(cacheBase, { withFileTypes: true })
55
+ .filter(e => e.isDirectory() && /^\d+\.\d+\.\d+/.test(e.name));
56
+ if (dirs.length === 0) return false;
57
+ return dirs.every(d => fs.existsSync(path.join(cacheBase, d.name, '.orphaned_at')));
58
+ } catch { return false; }
59
+ }
60
+
32
61
  // ── Main Entry ─────────────────────────────────────────────
33
62
  async function checkForUpdate(options = {}) {
34
63
  const {
@@ -43,6 +72,10 @@ async function checkForUpdate(options = {}) {
43
72
  const installMode = getInstallMode();
44
73
 
45
74
  try {
75
+ if (isOrphan()) {
76
+ if (verbose) console.log('Skipping update check (plugin uninstalled — orphan state)');
77
+ return null;
78
+ }
46
79
  if (!force && shouldSkipUpdateCheck()) {
47
80
  if (verbose) console.log('Skipping update check (dev mode or auto-update in progress)');
48
81
  return null;
@@ -643,6 +676,7 @@ module.exports = {
643
676
  compareVersions,
644
677
  getInstallMode,
645
678
  isDevMode,
679
+ isOrphan,
646
680
  shouldCheck,
647
681
  shouldSkipUpdateCheck,
648
682
  validateExtractedPackage,
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // GSD-Lite SessionStart hook
3
+ // 0. Orphan self-cleanup: if /plugin uninstall removed the plugin but left
4
+ // install.js-written state behind, run inline cleanup and exit.
3
5
  // 1. Cleans up stale temp files (throttled to once/day).
4
6
  // 2. Auto-registers statusLine in settings.json if not already configured.
5
7
  // 3. Self-heals .mcp.json if missing.
@@ -16,6 +18,143 @@ const os = require('node:os');
16
18
  const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
17
19
  const settingsPath = path.join(claudeDir, 'settings.json');
18
20
 
21
+ // ── Phase 0: Orphan self-cleanup ──
22
+ // /plugin uninstall only touches installed_plugins.json + enabledPlugins; it
23
+ // leaves hook scripts, settings.json registrations, the runtime dir, and the
24
+ // composite statusline registry in place — so hooks keep firing and Phases
25
+ // 2/5 below will self-heal the stale state on every session. Detect that case
26
+ // here and remove our footprint before any other phase runs.
27
+ function isOrphan() {
28
+ // .install-mode marker (written by install.js ≥ 0.7.7) is authoritative.
29
+ const installModeMarker = path.join(claudeDir, 'gsd', '.install-mode');
30
+ let mode = null;
31
+ try { mode = fs.readFileSync(installModeMarker, 'utf8').trim(); } catch { /* missing → fall through */ }
32
+ if (mode === 'manual') return false; // npx install never enters /plugin uninstall path
33
+ if (mode === 'plugin') {
34
+ try {
35
+ const data = JSON.parse(fs.readFileSync(path.join(claudeDir, 'plugins', 'installed_plugins.json'), 'utf8'));
36
+ return !data.plugins?.['gsd@gsd'];
37
+ } catch { return false; } // registry unreadable → don't assume orphan
38
+ }
39
+ // Pre-marker installs: fallback heuristic. Claude Code stamps
40
+ // .orphaned_at inside cache version dirs when the entry is removed from
41
+ // installed_plugins.json. Orphan iff every cached version has the marker.
42
+ const cacheBase = path.join(claudeDir, 'plugins', 'cache', 'gsd', 'gsd');
43
+ if (!fs.existsSync(cacheBase)) return false;
44
+ try {
45
+ const dirs = fs.readdirSync(cacheBase, { withFileTypes: true })
46
+ .filter(e => e.isDirectory() && /^\d+\.\d+\.\d+/.test(e.name));
47
+ if (dirs.length === 0) return false;
48
+ return dirs.every(d => fs.existsSync(path.join(cacheBase, d.name, '.orphaned_at')));
49
+ } catch { return false; }
50
+ }
51
+
52
+ function atomicWriteJson(filePath, value) {
53
+ const tmp = filePath + `.gsd-orphan-${process.pid}-${Date.now()}.tmp`;
54
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
55
+ fs.renameSync(tmp, filePath);
56
+ }
57
+
58
+ function cleanupOrphan() {
59
+ // 1. Composite statusLine registry — call removeProvider BEFORE deleting
60
+ // the lib file it lives in.
61
+ try {
62
+ const compositeLib = path.join(claudeDir, 'hooks', 'lib', 'statusline-composite.cjs');
63
+ if (fs.existsSync(compositeLib)) {
64
+ const { removeProvider } = require(compositeLib);
65
+ removeProvider();
66
+ }
67
+ } catch { /* best effort */ }
68
+
69
+ // 2. settings.json — mirror uninstall.js logic.
70
+ try {
71
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
72
+ let changed = false;
73
+ for (const name of ['gsd', 'gsd-lite']) {
74
+ if (settings.mcpServers?.[name]) { delete settings.mcpServers[name]; changed = true; }
75
+ const pluginKey = `${name}@${name}`;
76
+ if (settings.enabledPlugins?.[pluginKey]) { delete settings.enabledPlugins[pluginKey]; changed = true; }
77
+ if (settings.extraKnownMarketplaces?.[name]) { delete settings.extraKnownMarketplaces[name]; changed = true; }
78
+ }
79
+ if (settings.extraKnownMarketplaces && Object.keys(settings.extraKnownMarketplaces).length === 0) {
80
+ delete settings.extraKnownMarketplaces;
81
+ }
82
+ if (settings.statusLine?.command?.includes('gsd-statusline')
83
+ || settings.statusLine?.command?.includes('context-monitor.js')) {
84
+ delete settings.statusLine;
85
+ changed = true;
86
+ }
87
+ if (settings.hooks) {
88
+ if (typeof settings.hooks.StatusLine === 'string'
89
+ && (settings.hooks.StatusLine.includes('gsd-statusline')
90
+ || settings.hooks.StatusLine.includes('context-monitor.js'))) {
91
+ delete settings.hooks.StatusLine;
92
+ changed = true;
93
+ }
94
+ for (const [hookType, identifier] of [
95
+ ['PostToolUse', 'gsd-context-monitor'],
96
+ ['PostToolUse', 'context-monitor.js'],
97
+ ['SessionStart', 'gsd-session-init'],
98
+ ['Stop', 'gsd-session-stop'],
99
+ ]) {
100
+ if (Array.isArray(settings.hooks[hookType])) {
101
+ const before = settings.hooks[hookType].length;
102
+ settings.hooks[hookType] = settings.hooks[hookType].filter(e =>
103
+ !e.hooks?.some(h => h.command?.includes(identifier)));
104
+ if (settings.hooks[hookType].length < before) changed = true;
105
+ if (settings.hooks[hookType].length === 0) delete settings.hooks[hookType];
106
+ } else if (typeof settings.hooks[hookType] === 'string'
107
+ && settings.hooks[hookType].includes(identifier)) {
108
+ delete settings.hooks[hookType];
109
+ changed = true;
110
+ }
111
+ }
112
+ }
113
+ if (changed) atomicWriteJson(settingsPath, settings);
114
+ } catch { /* best effort */ }
115
+
116
+ // 3. plugins/known_marketplaces.json
117
+ try {
118
+ const known = path.join(claudeDir, 'plugins', 'known_marketplaces.json');
119
+ const data = JSON.parse(fs.readFileSync(known, 'utf8'));
120
+ let dirty = false;
121
+ for (const n of ['gsd', 'gsd-lite']) {
122
+ if (n in data) { delete data[n]; dirty = true; }
123
+ }
124
+ if (dirty) atomicWriteJson(known, data);
125
+ } catch { /* best effort */ }
126
+
127
+ // 4. Hook script files
128
+ for (const name of ['context-monitor.js', 'gsd-statusline.cjs', 'gsd-context-monitor.cjs', 'gsd-auto-update.cjs', 'gsd-session-stop.cjs']) {
129
+ try { fs.rmSync(path.join(claudeDir, 'hooks', name), { force: true }); } catch { /* best effort */ }
130
+ }
131
+ // 5. Hook lib files (GSD-owned only — don't touch other plugins' libs)
132
+ for (const lib of ['gsd-finder.cjs', 'statusline-composite.cjs', 'semver-sort.cjs']) {
133
+ try { fs.rmSync(path.join(claudeDir, 'hooks', 'lib', lib), { force: true }); } catch { /* best effort */ }
134
+ }
135
+ // 6. Runtime dir + plugin marketplace + cache dirs (current + legacy names)
136
+ for (const dir of [
137
+ path.join(claudeDir, 'gsd'),
138
+ path.join(claudeDir, 'gsd-lite'),
139
+ path.join(claudeDir, 'plugins', 'marketplaces', 'gsd'),
140
+ path.join(claudeDir, 'plugins', 'marketplaces', 'gsd-lite'),
141
+ path.join(claudeDir, 'plugins', 'cache', 'gsd'),
142
+ path.join(claudeDir, 'plugins', 'cache', 'gsd-lite'),
143
+ ]) {
144
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
145
+ }
146
+ // 7. Self-removal — delete this script last. The running process keeps the
147
+ // file handle until exit (POSIX); on Windows this may fail silently and
148
+ // Phase 0's next-session check will retry.
149
+ try { fs.rmSync(path.join(claudeDir, 'hooks', 'gsd-session-init.cjs'), { force: true }); } catch { /* best effort */ }
150
+ }
151
+
152
+ if (isOrphan()) {
153
+ console.log('⚠ GSD-Lite plugin uninstalled — cleaning up orphaned hooks and runtime.');
154
+ try { cleanupOrphan(); } catch { /* best effort — never block session start */ }
155
+ process.exit(0);
156
+ }
157
+
19
158
  // Safety: exit after 4s regardless (hook timeout is 5s)
20
159
  setTimeout(() => process.exit(0), 4000).unref();
21
160
 
package/install.js CHANGED
@@ -211,6 +211,11 @@ export function main() {
211
211
  // 6. Stable runtime for MCP server
212
212
  copyDir(join(__dirname, 'src'), join(RUNTIME_DIR, 'src'), 'runtime/src → ~/.claude/gsd/src/');
213
213
  copyFile(join(__dirname, 'package.json'), join(RUNTIME_DIR, 'package.json'), 'runtime/package.json → ~/.claude/gsd/package.json');
214
+ // Copy uninstall.js so the SessionStart hook's Phase 0 orphan-cleanup can
215
+ // spawn it when /plugin uninstall has removed the plugin without running our
216
+ // uninstaller. Without this, hooks/runtime/settings.json entries written by
217
+ // install.js outlive the plugin and keep firing.
218
+ copyFile(join(__dirname, 'uninstall.js'), join(RUNTIME_DIR, 'uninstall.js'), 'runtime/uninstall.js → ~/.claude/gsd/uninstall.js');
214
219
  // Copy lock file so `npm ci` works when node_modules are not present (npx scenario)
215
220
  const lockFile = join(__dirname, 'package-lock.json');
216
221
  if (existsSync(lockFile)) {
@@ -289,6 +294,27 @@ export function main() {
289
294
  if (statusLineRegistered || hooksRegistered) {
290
295
  log(' ✓ GSD-Lite hooks registered in settings.json');
291
296
  }
297
+
298
+ // Record install mode so SessionStart's Phase 0 orphan-cleanup can
299
+ // distinguish "plugin uninstalled" from "npx install". Without this marker
300
+ // the hook falls back to the .orphaned_at cache heuristic, which is fine
301
+ // for pre-marker users but more guess-y.
302
+ try {
303
+ const installModeMarker = join(RUNTIME_DIR, '.install-mode');
304
+ writeFileSync(installModeMarker, (isPluginInstall ? 'plugin' : 'manual') + '\n');
305
+ } catch { /* best effort — marker missing falls back to heuristic */ }
306
+
307
+ // Clear stale .orphaned_at from the current plugin cache version, in case
308
+ // the user previously uninstalled (Claude Code stamps it) and is now
309
+ // reinstalling via /plugin. Leaving it would make orphan-cleanup mis-fire
310
+ // on the next session.
311
+ if (isPluginInstall) {
312
+ try {
313
+ const currentVersion = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8')).version;
314
+ const orphanMarker = join(CLAUDE_DIR, 'plugins', 'cache', 'gsd', 'gsd', currentVersion, '.orphaned_at');
315
+ if (existsSync(orphanMarker)) rmSync(orphanMarker, { force: true });
316
+ } catch { /* best effort */ }
317
+ }
292
318
  } else {
293
319
  log(' [dry-run] Would register MCP server in settings.json');
294
320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-lite",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "type": "module",
6
6
  "bin": {