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.
@@ -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 monitoring hooks",
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.js\" postToolUse"
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-lite');
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, hookPath) {
22
- const command = formatHookCommand(hookPath, 'statusLine');
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('context-monitor.js')) {
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, hookPath) {
36
- const command = formatHookCommand(hookPath, 'postToolUse');
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.js')) {
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.js')));
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-lite/src/');
113
- copyFile(join(__dirname, 'package.json'), join(RUNTIME_DIR, 'package.json'), 'runtime/package.json → ~/.claude/gsd-lite/package.json');
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
- settings.mcpServers['gsd-lite'] = {
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 hookPath = join(CLAUDE_DIR, 'hooks', 'context-monitor.js');
144
- const statusLineRegistered = registerStatusLine(settings, hookPath);
145
- const postToolUseRegistered = registerPostToolUseHook(settings.hooks, hookPath);
146
-
147
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
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.2.1",
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-lite": "./cli.js"
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
  ],
@@ -1,6 +1,6 @@
1
1
  # 提问技巧参考 (Questioning Reference)
2
2
 
3
- > 本文档供编排器在需求讨论阶段 (`/gsd:start` STEP 1) 使用。
3
+ > 本文档供编排器在需求讨论阶段 (`/gsd:start` STEP 4) 使用。
4
4
  > 目标: 通过结构化提问,将模糊需求转化为可执行规格。
5
5
 
6
6
  ---
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',