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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/commands/doctor.md +98 -0
- package/hooks/gsd-auto-update.cjs +45 -1
- package/install.js +1 -1
- package/package.json +3 -2
- package/src/schema.js +62 -0
- package/src/tools/orchestrator.js +42 -0
- package/src/tools/verify.js +8 -1
|
@@ -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.
|
|
16
|
+
"version": "0.4.1",
|
|
17
17
|
"keywords": [
|
|
18
18
|
"orchestration",
|
|
19
19
|
"mcp",
|
|
@@ -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)
|
|
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
|
|
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
|
+
"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
|
}
|
package/src/tools/verify.js
CHANGED
|
@@ -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
|
-
|
|
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()) {
|