gsd-lite 0.3.17 → 0.4.1

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.3.17",
16
+ "version": "0.4.1",
17
17
  "keywords": [
18
18
  "orchestration",
19
19
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd",
3
- "version": "0.3.17",
3
+ "version": "0.4.1",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "author": {
6
6
  "name": "sdsrss",
@@ -0,0 +1,98 @@
1
+ ---
2
+ description: Run diagnostic checks on GSD-Lite installation and project health
3
+ ---
4
+
5
+ <role>
6
+ You are GSD-Lite diagnostician. Run system health checks and report results clearly.
7
+ Use the user's input language for all output.
8
+ </role>
9
+
10
+ <process>
11
+
12
+ ## STEP 1: State File Check
13
+
14
+ Check if `.gsd/state.json` exists:
15
+ - If exists: parse it as JSON
16
+ - Valid JSON: record PASS with project name and workflow_mode
17
+ - Invalid JSON (parse error): record FAIL with error details
18
+ - If not exists: record INFO "No active project (state.json not found)"
19
+
20
+ ## STEP 2: MCP Server Health
21
+
22
+ Call the `gsd health` MCP tool:
23
+ - If returns `status: "ok"`: record PASS with server version
24
+ - If returns error or unreachable: record FAIL with error message
25
+ - Note: if MCP server is not available at all (tool not found), record FAIL "MCP server not registered"
26
+
27
+ ## STEP 3: Hooks Registration
28
+
29
+ Check if GSD hooks are registered in Claude settings:
30
+ - Read `~/.claude/settings.json` (or `~/.claude/settings.local.json`)
31
+ - Check for `statusLine` entry containing `gsd-statusline`
32
+ - Check for `PostToolUse` hook entry containing `gsd-context-monitor`
33
+ - Both present: record PASS
34
+ - Partial: record WARN with which hook is missing
35
+ - Neither: record FAIL "No GSD hooks registered"
36
+
37
+ Also verify the hook files exist on disk:
38
+ - `~/.claude/hooks/gsd-statusline.cjs`
39
+ - `~/.claude/hooks/gsd-context-monitor.cjs`
40
+ - Files missing but settings present: record WARN "Hook registered but file missing"
41
+
42
+ ## STEP 4: Lock File Check
43
+
44
+ Check if `.gsd/.state-lock` exists:
45
+ - If not exists: record PASS "No stale lock"
46
+ - If exists: check file age
47
+ - Older than 5 minutes: record WARN "Stale lock file detected (age: {age}). May indicate a crashed process. Consider removing it."
48
+ - Recent (< 5 min): record INFO "Lock file present (age: {age}), likely active operation"
49
+
50
+ ## STEP 5: Auto-Update Status
51
+
52
+ Check for update-related information:
53
+ - Read `~/.claude/gsd/package.json` for installed version
54
+ - Compare with the version from `gsd health` tool response
55
+ - If versions match: record PASS with version number
56
+ - If mismatch: record WARN "Version mismatch: installed={x}, server={y}"
57
+ - If `~/.claude/gsd/.update-pending` exists: record INFO "Update pending, will apply on next session"
58
+ - If cannot determine: record INFO "Update status unavailable"
59
+
60
+ ## STEP 6: Output Summary
61
+
62
+ Output a diagnostic summary with status indicators:
63
+
64
+ ```
65
+ GSD Doctor - Diagnostic Report
66
+ ===============================
67
+
68
+ [PASS] State file — {details}
69
+ [PASS] MCP server — {details}
70
+ [PASS] Hooks registered — {details}
71
+ [PASS] Lock file — {details}
72
+ [PASS] Update status — {details}
73
+
74
+ Result: All checks passed (or N issues found)
75
+ ```
76
+
77
+ Status indicators:
78
+ - `[PASS]` — check passed, no issues
79
+ - `[WARN]` — potential issue, not blocking
80
+ - `[FAIL]` — problem detected, needs attention
81
+ - `[INFO]` — informational, no action needed
82
+
83
+ If any FAIL or WARN items exist, add a "Suggested Actions" section:
84
+ ```
85
+ Suggested Actions:
86
+ - {action for each FAIL/WARN item}
87
+ ```
88
+
89
+ </process>
90
+
91
+ <rules>
92
+ - Read-only operation: do not modify any files
93
+ - Do not modify state.json or any configuration
94
+ - Report raw facts: do not guess or infer causes beyond what is directly observable
95
+ - If a check cannot be performed (e.g., tool unavailable), report INFO rather than FAIL
96
+ - Always show all 5 checks in the summary, even if some are INFO/skipped
97
+ </rules>
98
+ </output>
@@ -313,9 +313,24 @@ function getCurrentVersion(mode = getInstallMode()) {
313
313
  return '0.0.0';
314
314
  }
315
315
 
316
+ // ── Package Validation ──────────────────────────────────────
317
+ function validateExtractedPackage(extractDir) {
318
+ try {
319
+ const pkgPath = path.join(extractDir, 'package.json');
320
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
321
+ if (pkg.name !== 'gsd-lite') return false;
322
+ if (!pkg.version || !/^\d+\.\d+\.\d+/.test(pkg.version)) return false;
323
+ return true;
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
316
329
  // ── Download & Install ─────────────────────────────────────
317
330
  async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
318
331
  const tmpDir = path.join(os.tmpdir(), `gsd-update-${Date.now()}`);
332
+ const backupPath = path.join(pluginRoot, 'package.json.bak');
333
+ let backedUp = false;
319
334
  try {
320
335
  fs.mkdirSync(tmpDir, { recursive: true });
321
336
 
@@ -341,6 +356,23 @@ async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
341
356
  const tar = spawnSync('tar', ['xzf', tarPath, '-C', tmpDir, '--strip-components=1'], { timeout: 30000 });
342
357
  if (tar.status !== 0) throw new Error(`tar extract failed: ${(tar.stderr || '').toString().slice(0, 200)}`);
343
358
 
359
+ // Validate extracted package before installing
360
+ if (!validateExtractedPackage(tmpDir)) {
361
+ if (verbose) console.error(' Package validation failed — aborting install');
362
+ return false;
363
+ }
364
+
365
+ // Backup current package.json before install
366
+ const currentPkgPath = path.join(pluginRoot, 'package.json');
367
+ try {
368
+ if (fs.existsSync(currentPkgPath)) {
369
+ fs.copyFileSync(currentPkgPath, backupPath);
370
+ backedUp = true;
371
+ }
372
+ } catch {
373
+ /* best effort — proceed without backup */
374
+ }
375
+
344
376
  // Run installer with spawnSync (no shell)
345
377
  if (verbose) console.log(' Running installer...');
346
378
  const install = spawnSync(process.execPath, [path.join(tmpDir, 'install.js')], {
@@ -348,7 +380,18 @@ async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
348
380
  stdio: verbose ? 'inherit' : 'pipe',
349
381
  env: { ...process.env, PLUGIN_AUTO_UPDATE: '1' },
350
382
  });
351
- if (install.status !== 0) throw new Error(`Installer failed: ${(install.stderr || '').toString().slice(0, 200)}`);
383
+ if (install.status !== 0) {
384
+ // Restore backup on install failure
385
+ if (backedUp) {
386
+ try { fs.copyFileSync(backupPath, currentPkgPath); } catch { /* best effort */ }
387
+ }
388
+ throw new Error(`Installer failed: ${(install.stderr || '').toString().slice(0, 200)}`);
389
+ }
390
+
391
+ // Success — remove backup
392
+ if (backedUp) {
393
+ try { fs.rmSync(backupPath, { force: true }); } catch { /* ignore */ }
394
+ }
352
395
 
353
396
  return true;
354
397
  } catch (err) {
@@ -402,6 +445,7 @@ module.exports = {
402
445
  isDevMode,
403
446
  shouldCheck,
404
447
  shouldSkipUpdateCheck,
448
+ validateExtractedPackage,
405
449
  };
406
450
 
407
451
  // ── CLI Entry Point (for background auto-install) ─────────
package/install.js CHANGED
@@ -136,7 +136,7 @@ export function main() {
136
136
  copyDir(localNM, join(RUNTIME_DIR, 'node_modules'), 'runtime/node_modules (copied)');
137
137
  } else if (!DRY_RUN) {
138
138
  log(' ⧗ Installing runtime dependencies...');
139
- execSync('npm install --omit=dev', { cwd: RUNTIME_DIR, stdio: 'pipe' });
139
+ execSync('npm ci --omit=dev', { cwd: RUNTIME_DIR, stdio: 'pipe' });
140
140
  log(' ✓ runtime dependencies installed');
141
141
  } else {
142
142
  log(' [dry-run] Would install runtime dependencies');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-lite",
3
- "version": "0.3.17",
3
+ "version": "0.4.1",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,8 @@
39
39
  "cli.js",
40
40
  "launcher.js",
41
41
  "install.js",
42
- "uninstall.js"
42
+ "uninstall.js",
43
+ "package-lock.json"
43
44
  ],
44
45
  "engines": {
45
46
  "node": ">=20.0.0"
package/src/schema.js CHANGED
@@ -256,6 +256,17 @@ export function validateStateUpdate(state, updates) {
256
256
  errors.push(`current_phase (${effectivePhase}) must not exceed total_phases (${effectiveTotal})`);
257
257
  }
258
258
 
259
+ // P2-9: Cross-field — current_task must belong to current_phase
260
+ const effectiveTask = 'current_task' in updates ? updates.current_task : state.current_task;
261
+ if (effectiveTask && Array.isArray(state.phases)) {
262
+ const curPhase = state.phases.find(p => p.id === effectivePhase);
263
+ if (curPhase && Array.isArray(curPhase.todo)) {
264
+ if (!curPhase.todo.some(t => t.id === effectiveTask)) {
265
+ errors.push(`current_task "${effectiveTask}" not found in current_phase ${effectivePhase}`);
266
+ }
267
+ }
268
+ }
269
+
259
270
  return { valid: errors.length === 0, errors };
260
271
  }
261
272
 
@@ -356,6 +367,57 @@ export function validateState(state) {
356
367
  && state.total_phases > 0 && state.current_phase > state.total_phases) {
357
368
  errors.push(`current_phase (${state.current_phase}) must not exceed total_phases (${state.total_phases})`);
358
369
  }
370
+ // P2-9: Cross-field consistency — current_task must belong to current_phase
371
+ if (state.current_task && Array.isArray(state.phases)) {
372
+ const curPhase = state.phases.find(p => p.id === state.current_phase);
373
+ if (curPhase && Array.isArray(curPhase.todo)) {
374
+ const taskExists = curPhase.todo.some(t => t.id === state.current_task);
375
+ if (!taskExists) {
376
+ errors.push(`current_task "${state.current_task}" not found in current_phase ${state.current_phase}`);
377
+ }
378
+ }
379
+ }
380
+ // P2-9: workflow_mode consistency — completed project must not have active/running tasks
381
+ if (state.workflow_mode === 'completed' && Array.isArray(state.phases)) {
382
+ for (const phase of state.phases) {
383
+ for (const task of (phase.todo || [])) {
384
+ if (task.lifecycle === 'running') {
385
+ errors.push(`Completed project has running task ${task.id} in phase ${phase.id}`);
386
+ }
387
+ }
388
+ }
389
+ }
390
+ // P2-9: workflow_mode consistency — reviewing modes require matching current_review
391
+ if (state.workflow_mode === 'reviewing_phase' || state.workflow_mode === 'reviewing_task') {
392
+ const expectedScope = state.workflow_mode === 'reviewing_phase' ? 'phase' : 'task';
393
+ if (!state.current_review || state.current_review.scope !== expectedScope) {
394
+ errors.push(`workflow_mode "${state.workflow_mode}" requires current_review with scope="${expectedScope}"`);
395
+ }
396
+ }
397
+ // P2-9: current_review.scope_id must reference an existing phase or task
398
+ if (state.current_review && state.current_review.scope_id != null && Array.isArray(state.phases)) {
399
+ if (state.current_review.scope === 'phase') {
400
+ if (!state.phases.some(p => p.id === state.current_review.scope_id)) {
401
+ errors.push(`current_review.scope_id ${state.current_review.scope_id} references non-existent phase`);
402
+ }
403
+ } else if (state.current_review.scope === 'task') {
404
+ const curPhase = state.phases.find(p => p.id === state.current_phase);
405
+ if (curPhase && Array.isArray(curPhase.todo) && !curPhase.todo.some(t => t.id === state.current_review.scope_id)) {
406
+ errors.push(`current_review.scope_id "${state.current_review.scope_id}" references non-existent task in phase ${state.current_phase}`);
407
+ }
408
+ }
409
+ }
410
+ // P2-9: accepted phase must not contain non-accepted tasks
411
+ if (Array.isArray(state.phases)) {
412
+ for (const phase of state.phases) {
413
+ if (phase.lifecycle === 'accepted' && Array.isArray(phase.todo)) {
414
+ const nonAccepted = phase.todo.filter(t => t.lifecycle !== 'accepted');
415
+ if (nonAccepted.length > 0) {
416
+ errors.push(`Accepted phase ${phase.id} contains non-accepted tasks: ${nonAccepted.map(t => `${t.id}:${t.lifecycle}`).join(', ')}`);
417
+ }
418
+ }
419
+ }
420
+ }
359
421
  if (Array.isArray(state.phases)) {
360
422
  if (typeof state.total_phases === 'number' && state.total_phases !== state.phases.length) {
361
423
  errors.push(`total_phases (${state.total_phases}) does not match phases.length (${state.phases.length})`);
@@ -168,6 +168,32 @@ async function evaluatePreflight(state, basePath) {
168
168
  });
169
169
  }
170
170
 
171
+ // P0-2: Dirty-phase detection — rollback current_phase to earliest phase
172
+ // that has needs_revalidation tasks, ensuring earlier invalidated work
173
+ // is re-executed before proceeding with later phases.
174
+ // Use filter+reduce (not .find) to guarantee lowest-ID match regardless of array order.
175
+ const dirtyPhases = (state.phases || []).filter(p =>
176
+ p.id < state.current_phase
177
+ && (p.todo || []).some(t => t.lifecycle === 'needs_revalidation'),
178
+ );
179
+ const earliestDirtyPhase = dirtyPhases.length > 0
180
+ ? dirtyPhases.reduce((min, p) => (p.id < min.id ? p : min))
181
+ : null;
182
+ if (earliestDirtyPhase) {
183
+ hints.push({
184
+ workflow_mode: 'executing_task',
185
+ action: 'rollback_to_dirty_phase',
186
+ updates: {
187
+ workflow_mode: 'executing_task',
188
+ current_phase: earliestDirtyPhase.id,
189
+ current_task: null,
190
+ current_review: null,
191
+ },
192
+ dirty_phase: { id: earliestDirtyPhase.id, name: earliestDirtyPhase.name },
193
+ message: `Phase ${earliestDirtyPhase.id} has invalidated tasks; rolling back from phase ${state.current_phase}`,
194
+ });
195
+ }
196
+
171
197
  if (hints.length === 0) return { override: null };
172
198
 
173
199
  return {
@@ -469,6 +495,21 @@ async function resumeExecutingTask(state, basePath) {
469
495
  };
470
496
  }
471
497
 
498
+ // P0-1: Auto phase completion — when all tasks accepted and review passed,
499
+ // signal complete_phase instead of going idle
500
+ const allAccepted = phase.todo.length > 0 && phase.todo.every(t => t.lifecycle === 'accepted');
501
+ const reviewPassed = phase.phase_review?.status === 'accepted'
502
+ || phase.phase_handoff?.required_reviews_passed === true;
503
+ if (allAccepted && reviewPassed) {
504
+ return {
505
+ success: true,
506
+ action: 'complete_phase',
507
+ workflow_mode: 'executing_task',
508
+ phase_id: phase.id,
509
+ message: 'All tasks accepted and review passed; phase ready for completion',
510
+ };
511
+ }
512
+
472
513
  const persistError = await persist(basePath, {
473
514
  current_task: null,
474
515
  current_review: null,
@@ -509,6 +550,7 @@ export async function resumeWorkflow({ basePath = process.cwd(), _depth = 0 } =
509
550
  ...(preflight.override.current_git_head ? { current_git_head: preflight.override.current_git_head } : {}),
510
551
  ...(preflight.override.changed_files ? { changed_files: preflight.override.changed_files } : {}),
511
552
  ...(preflight.override.expired_research ? { expired_research: preflight.override.expired_research } : {}),
553
+ ...(preflight.override.dirty_phase ? { dirty_phase: preflight.override.dirty_phase } : {}),
512
554
  ...(preflight.hints && preflight.hints.length > 1 ? { pending_issues: preflight.hints.slice(1) } : {}),
513
555
  };
514
556
  }
@@ -75,7 +75,14 @@ export async function runTypeCheck(pm, cwd) {
75
75
  if (pm === 'pnpm') return runCommand('pnpm', ['exec', 'tsc', '--noEmit'], cwd);
76
76
  if (pm === 'yarn') return runCommand('yarn', ['tsc', '--noEmit'], cwd);
77
77
  if (pm === 'bun') return runCommand('bun', ['run', 'tsc', '--noEmit'], cwd);
78
- return runCommand('npx', ['tsc', '--noEmit'], cwd);
78
+ // Local-first: use node_modules/.bin/tsc if available, skip otherwise
79
+ const localTsc = join(cwd, 'node_modules', '.bin', 'tsc');
80
+ try {
81
+ await stat(localTsc);
82
+ } catch {
83
+ return { exit_code: 0, summary: 'skipped: no local typescript found' };
84
+ }
85
+ return runCommand(localTsc, ['--noEmit'], cwd);
79
86
  }
80
87
 
81
88
  export async function runAll(cwd = process.cwd()) {