gsd-lite 0.2.1 → 0.3.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 +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.mcp.json +3 -3
- package/README.md +7 -6
- package/agents/{gsd-debugger.md → debugger.md} +2 -2
- package/agents/{gsd-executor.md → executor.md} +2 -2
- package/agents/{gsd-researcher.md → researcher.md} +2 -2
- package/agents/{gsd-reviewer.md → reviewer.md} +2 -2
- package/cli.js +5 -5
- package/commands/prd.md +291 -0
- package/commands/{gsd-resume.md → resume.md} +7 -8
- package/commands/{gsd-start.md → start.md} +9 -10
- package/commands/{gsd-status.md → status.md} +0 -1
- package/commands/{gsd-stop.md → stop.md} +0 -1
- package/hooks/context-monitor.js +8 -28
- package/hooks/gsd-context-monitor.cjs +124 -0
- package/hooks/gsd-session-init.cjs +61 -0
- package/hooks/gsd-statusline.cjs +114 -0
- package/hooks/hooks.json +15 -2
- package/install.js +35 -22
- package/launcher.js +25 -0
- package/package.json +4 -3
- package/references/questioning.md +1 -1
- package/src/schema.js +11 -5
- package/src/server.js +45 -25
- package/src/tools/orchestrator.js +19 -5
- package/src/tools/state.js +10 -7
- package/src/tools/verify.js +6 -5
- package/src/utils.js +30 -13
- package/uninstall.js +84 -22
- package/workflows/debugging.md +1 -1
- package/workflows/deviation-rules.md +1 -1
- package/workflows/research.md +1 -1
- package/workflows/review-cycle.md +1 -1
- package/workflows/tdd-cycle.md +1 -1
- package/commands/gsd-prd.md +0 -154
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GSD-Lite Context Monitor — PostToolUse hook
|
|
3
|
+
// Reads context metrics from the statusline bridge file and injects
|
|
4
|
+
// warnings when context usage is high.
|
|
5
|
+
//
|
|
6
|
+
// Architecture:
|
|
7
|
+
// 1. StatusLine hook writes metrics to /tmp/gsd-ctx-{session_id}.json
|
|
8
|
+
// 2. This hook reads those metrics after each tool use
|
|
9
|
+
// 3. When remaining context drops below thresholds, injects a warning
|
|
10
|
+
// via hookSpecificOutput.additionalContext
|
|
11
|
+
//
|
|
12
|
+
// Thresholds:
|
|
13
|
+
// WARNING (remaining <= 35%): Agent should wrap up current task
|
|
14
|
+
// CRITICAL (remaining <= 25%): Agent must stop and save state
|
|
15
|
+
//
|
|
16
|
+
// Debounce: 5 tool uses between warnings to avoid spam
|
|
17
|
+
// Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately)
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const WARNING_THRESHOLD = 35;
|
|
24
|
+
const CRITICAL_THRESHOLD = 25;
|
|
25
|
+
const STALE_SECONDS = 60;
|
|
26
|
+
const DEBOUNCE_CALLS = 5;
|
|
27
|
+
|
|
28
|
+
let input = '';
|
|
29
|
+
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
|
30
|
+
process.stdin.setEncoding('utf8');
|
|
31
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
32
|
+
process.stdin.on('end', () => {
|
|
33
|
+
clearTimeout(stdinTimeout);
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(input);
|
|
36
|
+
const sessionId = data.session_id;
|
|
37
|
+
|
|
38
|
+
if (!sessionId) {
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tmpDir = os.tmpdir();
|
|
43
|
+
const metricsPath = path.join(tmpDir, `gsd-ctx-${sessionId}.json`);
|
|
44
|
+
|
|
45
|
+
let metrics;
|
|
46
|
+
try {
|
|
47
|
+
metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
|
|
48
|
+
} catch {
|
|
49
|
+
process.exit(0); // No bridge file — fresh session or subagent
|
|
50
|
+
}
|
|
51
|
+
const remaining = metrics.remaining_percentage;
|
|
52
|
+
const usedPct = metrics.used_pct;
|
|
53
|
+
|
|
54
|
+
// Cheapest check first — most calls exit here
|
|
55
|
+
if (remaining > WARNING_THRESHOLD) {
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Ignore stale metrics
|
|
60
|
+
const now = Math.floor(Date.now() / 1000);
|
|
61
|
+
if (metrics.timestamp && (now - metrics.timestamp) > STALE_SECONDS) {
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Debounce logic
|
|
66
|
+
const warnPath = path.join(tmpDir, `gsd-ctx-${sessionId}-warned.json`);
|
|
67
|
+
let warnData = { callsSinceWarn: 0, lastLevel: null };
|
|
68
|
+
let firstWarn = true;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
|
|
72
|
+
firstWarn = false;
|
|
73
|
+
} catch {
|
|
74
|
+
// No prior warning state — first warning this session
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1;
|
|
78
|
+
|
|
79
|
+
const isCritical = remaining <= CRITICAL_THRESHOLD;
|
|
80
|
+
const currentLevel = isCritical ? 'critical' : 'warning';
|
|
81
|
+
|
|
82
|
+
// Severity escalation bypasses debounce
|
|
83
|
+
const severityEscalated = currentLevel === 'critical' && warnData.lastLevel === 'warning';
|
|
84
|
+
if (!firstWarn && warnData.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
|
|
85
|
+
fs.writeFileSync(warnPath, JSON.stringify(warnData));
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Reset debounce
|
|
90
|
+
warnData.callsSinceWarn = 0;
|
|
91
|
+
warnData.lastLevel = currentLevel;
|
|
92
|
+
fs.writeFileSync(warnPath, JSON.stringify(warnData));
|
|
93
|
+
|
|
94
|
+
// Use bridge data to avoid extra filesystem check
|
|
95
|
+
const isGsdActive = metrics.has_gsd === true;
|
|
96
|
+
|
|
97
|
+
let message;
|
|
98
|
+
if (isCritical) {
|
|
99
|
+
message = isGsdActive
|
|
100
|
+
? `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. `
|
|
101
|
+
+ 'Context is nearly exhausted. Complete current task checkpoint immediately, '
|
|
102
|
+
+ 'set workflow_mode = awaiting_clear via gsd-state-update, and tell user to /clear then /gsd:resume.'
|
|
103
|
+
: `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. `
|
|
104
|
+
+ 'Context is nearly exhausted. Inform the user that context is low and ask how they want to proceed.';
|
|
105
|
+
} else {
|
|
106
|
+
message = isGsdActive
|
|
107
|
+
? `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. `
|
|
108
|
+
+ 'Context is getting limited. Avoid starting new complex work. Complete current task then save state.'
|
|
109
|
+
: `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. `
|
|
110
|
+
+ 'Be aware that context is getting limited. Avoid unnecessary exploration or starting new complex work.';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const output = {
|
|
114
|
+
hookSpecificOutput: {
|
|
115
|
+
hookEventName: 'PostToolUse',
|
|
116
|
+
additionalContext: message,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
process.stdout.write(JSON.stringify(output));
|
|
121
|
+
} catch {
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GSD-Lite SessionStart hook
|
|
3
|
+
// Auto-registers statusLine in settings.json if not already configured.
|
|
4
|
+
// This bridges the gap for plugin marketplace installs (which don't run install.js).
|
|
5
|
+
// Idempotent: skips if statusLine already points to gsd-statusline, preserves
|
|
6
|
+
// third-party statuslines.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
|
|
12
|
+
const pluginRoot = path.resolve(__dirname, '..');
|
|
13
|
+
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
14
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
15
|
+
const statuslineScript = path.join(pluginRoot, 'hooks', 'gsd-statusline.cjs');
|
|
16
|
+
|
|
17
|
+
// Clean up stale bridge/debounce files from previous sessions (older than 24h)
|
|
18
|
+
try {
|
|
19
|
+
const tmpDir = os.tmpdir();
|
|
20
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
for (const entry of fs.readdirSync(tmpDir)) {
|
|
23
|
+
if (!entry.startsWith('gsd-ctx-')) continue;
|
|
24
|
+
try {
|
|
25
|
+
const fullPath = path.join(tmpDir, entry);
|
|
26
|
+
if (now - fs.statSync(fullPath).mtimeMs > DAY_MS) fs.unlinkSync(fullPath);
|
|
27
|
+
} catch { /* skip */ }
|
|
28
|
+
}
|
|
29
|
+
} catch { /* silent */ }
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Verify the statusline script exists (sanity check)
|
|
33
|
+
if (!fs.existsSync(statuslineScript)) {
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let settings = {};
|
|
38
|
+
try {
|
|
39
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
40
|
+
} catch {
|
|
41
|
+
process.exit(0); // Can't read settings — don't risk writing a broken file
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Already has a statusLine configured (ours or third-party) — don't overwrite
|
|
45
|
+
if (settings.statusLine?.command) {
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Register our statusLine
|
|
50
|
+
settings.statusLine = {
|
|
51
|
+
type: 'command',
|
|
52
|
+
command: `node ${JSON.stringify(statuslineScript)}`
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Atomic write to avoid corruption
|
|
56
|
+
const tmpPath = settingsPath + '.gsd-tmp';
|
|
57
|
+
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n');
|
|
58
|
+
fs.renameSync(tmpPath, settingsPath);
|
|
59
|
+
} catch {
|
|
60
|
+
// Silent fail — never block session start
|
|
61
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GSD-Lite StatusLine hook
|
|
3
|
+
// Shows: model | current task | directory | context usage progress bar
|
|
4
|
+
// Reads JSON from stdin, writes bridge file for context-monitor PostToolUse hook.
|
|
5
|
+
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
|
|
10
|
+
let input = '';
|
|
11
|
+
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
|
12
|
+
process.stdin.setEncoding('utf8');
|
|
13
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
14
|
+
process.stdin.on('end', () => {
|
|
15
|
+
clearTimeout(stdinTimeout);
|
|
16
|
+
try {
|
|
17
|
+
const data = JSON.parse(input);
|
|
18
|
+
const model = data.model?.display_name || 'Claude';
|
|
19
|
+
const cwd = data.workspace?.current_dir || process.cwd();
|
|
20
|
+
const session = data.session_id || '';
|
|
21
|
+
const remaining = data.context_window?.remaining_percentage;
|
|
22
|
+
|
|
23
|
+
// Current GSD task from state.json
|
|
24
|
+
let task = '';
|
|
25
|
+
let hasGsd = false;
|
|
26
|
+
const gsdDir = path.join(cwd, '.gsd');
|
|
27
|
+
try {
|
|
28
|
+
const state = JSON.parse(fs.readFileSync(path.join(gsdDir, 'state.json'), 'utf8'));
|
|
29
|
+
hasGsd = true;
|
|
30
|
+
if (state.current_task && state.current_phase) {
|
|
31
|
+
const phase = (state.phases || []).find(p => p.id === state.current_phase);
|
|
32
|
+
const t = phase?.todo?.find(t => t.id === state.current_task);
|
|
33
|
+
if (t) task = `${t.id} ${t.name}`;
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// No state.json or parse error — skip task display
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Context window display (USED percentage scaled to usable context)
|
|
40
|
+
// Claude Code reserves ~16.5% for autocompact buffer
|
|
41
|
+
const AUTO_COMPACT_BUFFER_PCT = 16.5;
|
|
42
|
+
let ctx = '';
|
|
43
|
+
if (remaining != null) {
|
|
44
|
+
const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100);
|
|
45
|
+
const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining)));
|
|
46
|
+
|
|
47
|
+
// Write bridge file for context-monitor PostToolUse hook (skip if remaining unchanged)
|
|
48
|
+
if (session) {
|
|
49
|
+
try {
|
|
50
|
+
const bridgePath = path.join(os.tmpdir(), `gsd-ctx-${session}.json`);
|
|
51
|
+
let needsWrite = true;
|
|
52
|
+
try {
|
|
53
|
+
const existing = JSON.parse(fs.readFileSync(bridgePath, 'utf8'));
|
|
54
|
+
if (existing.remaining_percentage === remaining) needsWrite = false;
|
|
55
|
+
} catch { /* no existing file */ }
|
|
56
|
+
if (needsWrite) {
|
|
57
|
+
const tmpBridge = bridgePath + '.tmp';
|
|
58
|
+
fs.writeFileSync(tmpBridge, JSON.stringify({
|
|
59
|
+
session_id: session,
|
|
60
|
+
remaining_percentage: remaining,
|
|
61
|
+
used_pct: used,
|
|
62
|
+
has_gsd: hasGsd,
|
|
63
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
64
|
+
}));
|
|
65
|
+
fs.renameSync(tmpBridge, bridgePath);
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Silent fail — bridge is best-effort
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Also write to .gsd/.context-health for MCP server reads (skip if unchanged)
|
|
73
|
+
try {
|
|
74
|
+
const healthPath = path.join(gsdDir, '.context-health');
|
|
75
|
+
const current = fs.readFileSync(healthPath, 'utf8').trim();
|
|
76
|
+
if (current !== String(remaining)) {
|
|
77
|
+
fs.writeFileSync(healthPath, String(remaining));
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// File doesn't exist yet or .gsd/ missing — ensure dir exists then atomic write
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(gsdDir, { recursive: true });
|
|
83
|
+
const tmpHealth = path.join(gsdDir, `.context-health.${process.pid}.tmp`);
|
|
84
|
+
fs.writeFileSync(tmpHealth, String(remaining));
|
|
85
|
+
fs.renameSync(tmpHealth, path.join(gsdDir, '.context-health'));
|
|
86
|
+
} catch { /* silent */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Progress bar (10 segments)
|
|
90
|
+
const filled = Math.floor(used / 10);
|
|
91
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
|
|
92
|
+
|
|
93
|
+
if (used < 50) {
|
|
94
|
+
ctx = ` \x1b[32m${bar} ${used}%\x1b[0m`;
|
|
95
|
+
} else if (used < 65) {
|
|
96
|
+
ctx = ` \x1b[33m${bar} ${used}%\x1b[0m`;
|
|
97
|
+
} else if (used < 80) {
|
|
98
|
+
ctx = ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
|
|
99
|
+
} else {
|
|
100
|
+
ctx = ` \x1b[5;31m\uD83D\uDC80 ${bar} ${used}%\x1b[0m`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Output
|
|
105
|
+
const dirname = path.basename(cwd);
|
|
106
|
+
if (task) {
|
|
107
|
+
process.stdout.write(`\x1b[2m${model}\x1b[0m \u2502 \x1b[1m${task}\x1b[0m \u2502 \x1b[2m${dirname}\x1b[0m${ctx}`);
|
|
108
|
+
} else {
|
|
109
|
+
process.stdout.write(`\x1b[2m${model}\x1b[0m \u2502 \x1b[2m${dirname}\x1b[0m${ctx}`);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Silent fail
|
|
113
|
+
}
|
|
114
|
+
});
|
package/hooks/hooks.json
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "GSD-Lite context
|
|
2
|
+
"description": "GSD-Lite hooks: statusline auto-registration + context health monitor",
|
|
3
3
|
"hooks": {
|
|
4
|
+
"SessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "startup",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-session-init.cjs\"",
|
|
11
|
+
"timeout": 3
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
],
|
|
4
16
|
"PostToolUse": [
|
|
5
17
|
{
|
|
6
18
|
"matcher": "*",
|
|
7
19
|
"hooks": [
|
|
8
20
|
{
|
|
9
21
|
"type": "command",
|
|
10
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/context-monitor.
|
|
22
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.cjs\"",
|
|
23
|
+
"timeout": 3
|
|
11
24
|
}
|
|
12
25
|
]
|
|
13
26
|
}
|
package/install.js
CHANGED
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Plugin installer for GSD-Lite
|
|
3
3
|
|
|
4
|
-
import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
5
5
|
import { join, dirname } from 'node:path';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
8
|
import { execSync } from 'node:child_process';
|
|
9
9
|
|
|
10
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
const CLAUDE_DIR = join(homedir(), '.claude');
|
|
12
|
-
const RUNTIME_DIR = join(CLAUDE_DIR, 'gsd
|
|
11
|
+
const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
12
|
+
const RUNTIME_DIR = join(CLAUDE_DIR, 'gsd');
|
|
13
13
|
const DRY_RUN = process.argv.includes('--dry-run');
|
|
14
14
|
|
|
15
15
|
function log(msg) { console.log(msg); }
|
|
16
16
|
|
|
17
|
-
function formatHookCommand(scriptPath, hookName) {
|
|
18
|
-
return `node ${JSON.stringify(scriptPath)} ${hookName}`;
|
|
19
|
-
}
|
|
20
17
|
|
|
21
|
-
function registerStatusLine(settings,
|
|
22
|
-
const command =
|
|
18
|
+
function registerStatusLine(settings, statuslineScriptPath) {
|
|
19
|
+
const command = `node ${JSON.stringify(statuslineScriptPath)}`;
|
|
23
20
|
// Don't overwrite non-GSD statusLine
|
|
24
21
|
if (settings.statusLine && typeof settings.statusLine === 'object'
|
|
25
|
-
&& !settings.statusLine.command?.includes('
|
|
22
|
+
&& !settings.statusLine.command?.includes('gsd-statusline')) {
|
|
26
23
|
log(' ! Preserved existing statusLine');
|
|
27
24
|
return false;
|
|
28
25
|
}
|
|
@@ -32,8 +29,8 @@ function registerStatusLine(settings, hookPath) {
|
|
|
32
29
|
return true;
|
|
33
30
|
}
|
|
34
31
|
|
|
35
|
-
function registerPostToolUseHook(hooks,
|
|
36
|
-
const command =
|
|
32
|
+
function registerPostToolUseHook(hooks, contextMonitorPath) {
|
|
33
|
+
const command = `node ${JSON.stringify(contextMonitorPath)}`;
|
|
37
34
|
const entry = { matcher: '*', hooks: [{ type: 'command', command }] };
|
|
38
35
|
if (!hooks.PostToolUse) {
|
|
39
36
|
hooks.PostToolUse = [entry];
|
|
@@ -41,7 +38,7 @@ function registerPostToolUseHook(hooks, hookPath) {
|
|
|
41
38
|
}
|
|
42
39
|
// Handle legacy string format
|
|
43
40
|
if (typeof hooks.PostToolUse === 'string') {
|
|
44
|
-
if (!hooks.PostToolUse.includes('context-monitor
|
|
41
|
+
if (!hooks.PostToolUse.includes('gsd-context-monitor')) {
|
|
45
42
|
log(' ! Preserved existing PostToolUse hook');
|
|
46
43
|
return false;
|
|
47
44
|
}
|
|
@@ -50,7 +47,7 @@ function registerPostToolUseHook(hooks, hookPath) {
|
|
|
50
47
|
}
|
|
51
48
|
if (Array.isArray(hooks.PostToolUse)) {
|
|
52
49
|
const idx = hooks.PostToolUse.findIndex(e =>
|
|
53
|
-
e.hooks?.some(h => h.command?.includes('context-monitor
|
|
50
|
+
e.hooks?.some(h => h.command?.includes('gsd-context-monitor')));
|
|
54
51
|
if (idx >= 0) hooks.PostToolUse[idx] = entry;
|
|
55
52
|
else hooks.PostToolUse.push(entry);
|
|
56
53
|
return true;
|
|
@@ -88,6 +85,13 @@ export function main() {
|
|
|
88
85
|
|
|
89
86
|
log('Installing files...');
|
|
90
87
|
|
|
88
|
+
// Clean up legacy "gsd-lite" runtime directory from older versions
|
|
89
|
+
const LEGACY_RUNTIME_DIR = join(CLAUDE_DIR, 'gsd-lite');
|
|
90
|
+
if (!DRY_RUN && existsSync(LEGACY_RUNTIME_DIR)) {
|
|
91
|
+
rmSync(LEGACY_RUNTIME_DIR, { recursive: true, force: true });
|
|
92
|
+
log(' ✓ Removed legacy gsd-lite runtime');
|
|
93
|
+
}
|
|
94
|
+
|
|
91
95
|
// Reset managed runtime directory to avoid stale files on reinstall
|
|
92
96
|
if (!DRY_RUN && existsSync(RUNTIME_DIR)) {
|
|
93
97
|
rmSync(RUNTIME_DIR, { recursive: true, force: true });
|
|
@@ -109,8 +113,8 @@ export function main() {
|
|
|
109
113
|
copyDir(join(__dirname, 'hooks'), join(CLAUDE_DIR, 'hooks'), 'hooks → ~/.claude/hooks/');
|
|
110
114
|
|
|
111
115
|
// 6. Stable runtime for MCP server
|
|
112
|
-
copyDir(join(__dirname, 'src'), join(RUNTIME_DIR, 'src'), 'runtime/src → ~/.claude/gsd
|
|
113
|
-
copyFile(join(__dirname, 'package.json'), join(RUNTIME_DIR, 'package.json'), 'runtime/package.json → ~/.claude/gsd
|
|
116
|
+
copyDir(join(__dirname, 'src'), join(RUNTIME_DIR, 'src'), 'runtime/src → ~/.claude/gsd/src/');
|
|
117
|
+
copyFile(join(__dirname, 'package.json'), join(RUNTIME_DIR, 'package.json'), 'runtime/package.json → ~/.claude/gsd/package.json');
|
|
114
118
|
|
|
115
119
|
// 7. Runtime dependencies — copy local node_modules or install fresh (npx hoists deps)
|
|
116
120
|
const localNM = join(__dirname, 'node_modules');
|
|
@@ -130,21 +134,30 @@ export function main() {
|
|
|
130
134
|
let settings = {};
|
|
131
135
|
try {
|
|
132
136
|
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
133
|
-
} catch {
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err.code !== 'ENOENT') {
|
|
139
|
+
log(` ! Warning: Could not parse ${settingsPath}: ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
134
142
|
|
|
135
143
|
if (!settings.mcpServers) settings.mcpServers = {};
|
|
136
|
-
|
|
144
|
+
// Remove legacy "gsd-lite" server entry from older versions
|
|
145
|
+
delete settings.mcpServers['gsd-lite'];
|
|
146
|
+
settings.mcpServers.gsd = {
|
|
137
147
|
command: 'node',
|
|
138
148
|
args: [join(RUNTIME_DIR, 'src', 'server.js')],
|
|
139
149
|
};
|
|
140
150
|
|
|
141
151
|
// Register statusLine (top-level setting) and PostToolUse hook
|
|
142
152
|
if (!settings.hooks) settings.hooks = {};
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
const statuslinePath = join(CLAUDE_DIR, 'hooks', 'gsd-statusline.cjs');
|
|
154
|
+
const contextMonitorPath = join(CLAUDE_DIR, 'hooks', 'gsd-context-monitor.cjs');
|
|
155
|
+
const statusLineRegistered = registerStatusLine(settings, statuslinePath);
|
|
156
|
+
const postToolUseRegistered = registerPostToolUseHook(settings.hooks, contextMonitorPath);
|
|
157
|
+
|
|
158
|
+
const tmpSettings = settingsPath + `.${process.pid}-${Date.now()}.tmp`;
|
|
159
|
+
writeFileSync(tmpSettings, JSON.stringify(settings, null, 2) + '\n');
|
|
160
|
+
renameSync(tmpSettings, settingsPath);
|
|
148
161
|
log(' ✓ MCP server registered in settings.json');
|
|
149
162
|
if (statusLineRegistered || postToolUseRegistered) {
|
|
150
163
|
log(' ✓ GSD-Lite hooks registered in settings.json');
|
package/launcher.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Auto-install dependencies and start MCP server
|
|
3
|
+
// Used by plugin system where npm install is not run during /plugin install
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
if (!existsSync(join(__dirname, 'node_modules', '@modelcontextprotocol'))) {
|
|
13
|
+
try {
|
|
14
|
+
execSync('npm install --omit=dev --ignore-scripts', {
|
|
15
|
+
cwd: __dirname,
|
|
16
|
+
stdio: 'pipe',
|
|
17
|
+
});
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error('Failed to install dependencies:', err.stderr?.toString() || err.message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { main } = await import('./src/server.js');
|
|
25
|
+
await main();
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gsd-lite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"gsd
|
|
7
|
+
"gsd": "./cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test tests/*.test.js",
|
|
11
|
-
"test:coverage": "c8 --reporter=text --reporter=lcov node --test tests/*.test.js",
|
|
11
|
+
"test:coverage": "c8 --check-coverage --lines 80 --branches 75 --reporter=text --reporter=lcov node --test tests/*.test.js",
|
|
12
12
|
"lint": "biome check src/ tests/ hooks/",
|
|
13
13
|
"lint:fix": "biome check --write src/ tests/ hooks/",
|
|
14
14
|
"start": "node src/server.js"
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"references/",
|
|
36
36
|
"hooks/",
|
|
37
37
|
"cli.js",
|
|
38
|
+
"launcher.js",
|
|
38
39
|
"install.js",
|
|
39
40
|
"uninstall.js"
|
|
40
41
|
],
|
package/src/schema.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// State schema + lifecycle validation
|
|
2
2
|
|
|
3
|
+
import { isPlainObject } from './utils.js';
|
|
4
|
+
|
|
3
5
|
export const WORKFLOW_MODES = [
|
|
4
6
|
'planning',
|
|
5
7
|
'executing_task',
|
|
@@ -21,7 +23,7 @@ export const TASK_LIFECYCLE = {
|
|
|
21
23
|
checkpointed: ['accepted', 'needs_revalidation'],
|
|
22
24
|
accepted: ['needs_revalidation'],
|
|
23
25
|
blocked: ['pending'],
|
|
24
|
-
failed: [],
|
|
26
|
+
failed: ['pending'],
|
|
25
27
|
needs_revalidation: ['pending'],
|
|
26
28
|
};
|
|
27
29
|
|
|
@@ -52,10 +54,6 @@ export const CANONICAL_FIELDS = [
|
|
|
52
54
|
'evidence',
|
|
53
55
|
];
|
|
54
56
|
|
|
55
|
-
function isPlainObject(value) {
|
|
56
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
57
|
function validateResearchSourcesArray(sources, errors, path = 'sources') {
|
|
60
58
|
if (!Array.isArray(sources)) {
|
|
61
59
|
errors.push(`${path} must be array`);
|
|
@@ -423,6 +421,14 @@ export function validateDebuggerResult(r) {
|
|
|
423
421
|
}
|
|
424
422
|
|
|
425
423
|
export function createInitialState({ project, phases }) {
|
|
424
|
+
// Validate task names before creating state
|
|
425
|
+
for (const [pi, p] of (phases || []).entries()) {
|
|
426
|
+
for (const [ti, t] of (p.tasks || []).entries()) {
|
|
427
|
+
if (!t.name || typeof t.name !== 'string') {
|
|
428
|
+
return { error: true, message: `Phase ${pi + 1} task ${ti + 1}: name is required (got ${JSON.stringify(t.name)})` };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
426
432
|
return {
|
|
427
433
|
project,
|
|
428
434
|
workflow_mode: 'executing_task',
|