gsd-lite 0.7.5 → 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.5",
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.5",
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",
package/README.md CHANGED
@@ -291,7 +291,7 @@ gsd-lite/
291
291
  │ ├── gsd-session-stop.cjs # Graceful shutdown with crash markers
292
292
  │ ├── gsd-statusline.cjs # StatusLine display (composite-aware)
293
293
  │ └── lib/ # Shared hook utilities (gsd-finder, composite statusline, semver)
294
- ├── tests/ # 966 tests (unit + simulation + E2E integration)
294
+ ├── tests/ # 972 tests (unit + simulation + E2E integration)
295
295
  ├── cli.js # Install/uninstall CLI entry
296
296
  ├── install.js # Installation script (plugin-aware, idempotent)
297
297
  └── uninstall.js # Uninstall script
@@ -300,7 +300,7 @@ gsd-lite/
300
300
  ## Testing
301
301
 
302
302
  ```bash
303
- npm test # Run all 966 tests
303
+ npm test # Run all 972 tests
304
304
  npm run test:coverage # Tests + coverage report (94%+ lines, 83%+ branches)
305
305
  npm run lint # Biome lint
306
306
  node --test tests/file.js # Run a single test file
@@ -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.5",
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": {
package/src/server.js CHANGED
@@ -144,7 +144,7 @@ const TOOLS = [
144
144
  },
145
145
  run_verify: {
146
146
  type: 'boolean',
147
- description: 'When true, run lint/typecheck/test during handoff evaluation',
147
+ description: 'Assert that verification was run externally; if true without a verification object, returns INVALID_INPUT. The state layer does not execute external tools.',
148
148
  },
149
149
  direction_ok: {
150
150
  type: 'boolean',
@@ -1,4 +1,4 @@
1
- import { read, reclassifyReviewLevel } from '../state/index.js';
1
+ import { read, reclassifyReviewLevel, selectRunnableTask } from '../state/index.js';
2
2
  import { validateExecutorResult } from '../../schema.js';
3
3
  import {
4
4
  MAX_DEBUG_RETRY,
@@ -101,8 +101,20 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
101
101
 
102
102
  if (result.outcome === 'blocked') {
103
103
  const { blocked_reason, unblock_condition } = getBlockedReasonFromResult(result);
104
+ // Probe whether other tasks remain runnable after this one is blocked.
105
+ // Design (docs/gsd-lite-design.md §1399): awaiting_user fires only when
106
+ // 0 runnable tasks remain — blocked-with-others-runnable continues execution.
107
+ const probePhase = {
108
+ ...phase,
109
+ todo: phase.todo.map((t) => (t.id === task.id
110
+ ? { ...t, lifecycle: 'blocked', blocked_reason, unblock_condition }
111
+ : t)),
112
+ };
113
+ const probe = selectRunnableTask(probePhase, state);
114
+ const hasOtherRunnable = !!probe?.task;
115
+
104
116
  const persistError = await persist(basePath, {
105
- workflow_mode: 'awaiting_user',
117
+ workflow_mode: hasOtherRunnable ? 'executing_task' : 'awaiting_user',
106
118
  current_task: null,
107
119
  current_review: null,
108
120
  phases: [{
@@ -120,8 +132,8 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
120
132
 
121
133
  return {
122
134
  success: true,
123
- action: 'awaiting_user',
124
- workflow_mode: 'awaiting_user',
135
+ action: hasOtherRunnable ? 'continue_execution' : 'awaiting_user',
136
+ workflow_mode: hasOtherRunnable ? 'executing_task' : 'awaiting_user',
125
137
  task_id: task.id,
126
138
  blockers: getBlockedTasks({ todo: [{ id: task.id, lifecycle: 'blocked', blocked_reason, unblock_condition }] }),
127
139
  };
@@ -511,6 +511,13 @@ export async function phaseComplete({
511
511
  ? verificationPassed(verificationResult)
512
512
  : phase.phase_handoff.tests_passed === true;
513
513
  if (!testsPassed) {
514
+ if (!verificationResult) {
515
+ return {
516
+ error: true,
517
+ code: ERROR_CODES.HANDOFF_GATE,
518
+ message: 'Handoff gate not met: verification required. Run lint/typecheck/test externally, then call phase-complete with verification: { lint: { exit_code }, typecheck: { exit_code }, test: { exit_code } }',
519
+ };
520
+ }
514
521
  return {
515
522
  error: true,
516
523
  code: ERROR_CODES.HANDOFF_GATE,
@@ -163,21 +163,16 @@
163
163
  | `await_recovery_decision` | 工作流处于 failed 状态。向用户展示失败信息和恢复选项 (retry/skip/replan) |
164
164
 
165
165
  **`phase-complete` 参数:**
166
+
167
+ 编排器必须先用 Bash 外部执行 lint/typecheck/test (state 层不自动跑外部工具),然后把结果作为 `verification` 传入:
166
168
  ```
167
169
  phase-complete({
168
170
  phase_id: <当前 phase 编号>,
169
- run_verify: true, // 自动运行 lint/typecheck/test
170
- direction_ok: true // 方向校验通过 (如有偏差设为 false)
171
- })
172
- ```
173
- 如果没有 lint/typecheck/test 工具,可改用 `verification` 参数传入预计算结果:
174
- ```
175
- phase-complete({
176
- phase_id: <phase>,
177
171
  verification: { lint: {exit_code: 0}, typecheck: {exit_code: 0}, test: {exit_code: 0} },
178
- direction_ok: true
172
+ direction_ok: true // 方向校验通过 (如有偏差设为 false)
179
173
  })
180
174
  ```
175
+ `run_verify: true` 只是声明"已外部跑过",不提供 `verification` 时会返回 INVALID_INPUT,用于防止调用方忘记传结果。如果项目缺少某项脚本 (例如无 typecheck),跑对应工具时返回 0 视为通过即可 (约定: "命令不存在" = 不适用 = 通过)。
181
176
  </execution_loop>
182
177
 
183
178
  ## STEP 12 — 最终报告