gsd-lite 0.3.16 → 0.4.0
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/cli.js +5 -1
- package/commands/doctor.md +98 -0
- package/hooks/gsd-auto-update.cjs +223 -78
- package/hooks/gsd-session-init.cjs +8 -2
- package/package.json +1 -1
- package/src/schema.js +31 -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.0",
|
|
17
17
|
"keywords": [
|
|
18
18
|
"orchestration",
|
|
19
19
|
"mcp",
|
package/cli.js
CHANGED
|
@@ -40,7 +40,11 @@ switch (command) {
|
|
|
40
40
|
if (result?.updated) {
|
|
41
41
|
console.log(`\n✓ Updated: v${result.from} → v${result.to}`);
|
|
42
42
|
} else if (result?.updateAvailable) {
|
|
43
|
-
|
|
43
|
+
if (result.action === 'plugin_update') {
|
|
44
|
+
console.log(`\n! Update available: v${result.to}. Run /plugin update gsd`);
|
|
45
|
+
} else {
|
|
46
|
+
console.log(`\n! Update available v${result.to} but install failed. Try manually.`);
|
|
47
|
+
}
|
|
44
48
|
} else if (!result) {
|
|
45
49
|
console.log('✓ Already up to date');
|
|
46
50
|
}
|
|
@@ -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>
|
|
@@ -20,104 +20,200 @@ const claudeDir =
|
|
|
20
20
|
const runtimeDir = path.join(claudeDir, 'gsd');
|
|
21
21
|
const stateDir = path.join(runtimeDir, 'runtime');
|
|
22
22
|
const STATE_FILE = path.join(stateDir, 'update-state.json');
|
|
23
|
+
const STATE_LOCK_FILE = path.join(stateDir, 'update-state.lock');
|
|
24
|
+
const NOTIFICATION_FILE = path.join(stateDir, 'update-notification.json');
|
|
23
25
|
const pluginRoot = path.resolve(__dirname, '..');
|
|
24
26
|
|
|
27
|
+
const LOCK_STALE_MS = 10_000;
|
|
28
|
+
const LOCK_RETRY_MS = 50;
|
|
29
|
+
const LOCK_MAX_RETRIES = 100;
|
|
30
|
+
|
|
25
31
|
// ── Main Entry ─────────────────────────────────────────────
|
|
26
32
|
async function checkForUpdate(options = {}) {
|
|
27
|
-
const {
|
|
33
|
+
const {
|
|
34
|
+
force = false,
|
|
35
|
+
verbose = false,
|
|
36
|
+
install = true,
|
|
37
|
+
notify = false,
|
|
38
|
+
fetchLatestRelease: fetchLatestReleaseImpl = fetchLatestRelease,
|
|
39
|
+
downloadAndInstall: downloadAndInstallImpl = downloadAndInstall,
|
|
40
|
+
getCurrentVersion: getCurrentVersionImpl = getCurrentVersion,
|
|
41
|
+
} = options;
|
|
42
|
+
const installMode = getInstallMode();
|
|
28
43
|
|
|
29
44
|
try {
|
|
30
45
|
if (!force && shouldSkipUpdateCheck()) {
|
|
31
46
|
if (verbose) console.log('Skipping update check (dev mode or auto-update in progress)');
|
|
32
47
|
return null;
|
|
33
48
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
return await withFileLock(async () => {
|
|
50
|
+
const state = readState();
|
|
51
|
+
if (!force && !shouldCheck(state)) {
|
|
52
|
+
if (state.updateAvailable && state.latestVersion) {
|
|
53
|
+
return {
|
|
54
|
+
updateAvailable: true,
|
|
55
|
+
from: getCurrentVersionImpl(installMode),
|
|
56
|
+
to: state.latestVersion,
|
|
57
|
+
installMode,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (verbose) console.log('Throttled — last check:', state.lastCheck);
|
|
61
|
+
return null;
|
|
43
62
|
}
|
|
44
|
-
if (verbose) console.log('Throttled — last check:', state.lastCheck);
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (verbose) console.log('Checking GitHub for latest release...');
|
|
49
|
-
const token = getGitHubToken();
|
|
50
|
-
const latest = await fetchLatestRelease(token);
|
|
51
|
-
if (!latest) {
|
|
52
|
-
if (latest === false) state.rateLimited = true; // 403 rate-limited
|
|
53
|
-
saveState({ ...state, lastCheck: new Date().toISOString() });
|
|
54
|
-
if (verbose) console.log('Could not fetch latest release');
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
if (verbose) console.log('Checking GitHub for latest release...');
|
|
65
|
+
const token = getGitHubToken();
|
|
66
|
+
const latest = await fetchLatestReleaseImpl(token);
|
|
67
|
+
if (!latest) {
|
|
68
|
+
if (latest === false) state.rateLimited = true; // 403 rate-limited
|
|
69
|
+
saveState({ ...state, lastCheck: new Date().toISOString() });
|
|
70
|
+
if (verbose) console.log('Could not fetch latest release');
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
62
73
|
|
|
63
|
-
|
|
74
|
+
// Successful fetch — clear rate-limit back-off
|
|
75
|
+
state.rateLimited = false;
|
|
76
|
+
const currentVersion = getCurrentVersionImpl(installMode);
|
|
77
|
+
if (verbose) console.log(`Current: v${currentVersion} — Latest: v${latest.version}`);
|
|
78
|
+
|
|
79
|
+
const hasUpdate = compareVersions(latest.version, currentVersion) > 0;
|
|
80
|
+
|
|
81
|
+
if (hasUpdate) {
|
|
82
|
+
if (installMode === 'plugin' && install) {
|
|
83
|
+
saveState({
|
|
84
|
+
...state,
|
|
85
|
+
lastCheck: new Date().toISOString(),
|
|
86
|
+
latestVersion: latest.version,
|
|
87
|
+
updateAvailable: true,
|
|
88
|
+
});
|
|
89
|
+
if (notify) {
|
|
90
|
+
writeNotification({
|
|
91
|
+
kind: 'available',
|
|
92
|
+
from: currentVersion,
|
|
93
|
+
to: latest.version,
|
|
94
|
+
action: 'plugin_update',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
updateAvailable: true,
|
|
99
|
+
from: currentVersion,
|
|
100
|
+
to: latest.version,
|
|
101
|
+
action: 'plugin_update',
|
|
102
|
+
autoInstallSupported: false,
|
|
103
|
+
installMode,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!install) {
|
|
108
|
+
// Check-only mode (used by SessionStart hook)
|
|
109
|
+
saveState({
|
|
110
|
+
...state,
|
|
111
|
+
lastCheck: new Date().toISOString(),
|
|
112
|
+
latestVersion: latest.version,
|
|
113
|
+
updateAvailable: true,
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
updateAvailable: true,
|
|
117
|
+
from: currentVersion,
|
|
118
|
+
to: latest.version,
|
|
119
|
+
installMode,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (verbose) console.log(`Downloading v${latest.version}...`);
|
|
124
|
+
const success = await downloadAndInstallImpl(latest.tarballUrl, verbose, token);
|
|
64
125
|
|
|
65
|
-
if (hasUpdate) {
|
|
66
|
-
if (!install) {
|
|
67
|
-
// Check-only mode (used by SessionStart hook)
|
|
68
126
|
saveState({
|
|
69
127
|
...state,
|
|
70
128
|
lastCheck: new Date().toISOString(),
|
|
71
129
|
latestVersion: latest.version,
|
|
72
|
-
updateAvailable:
|
|
130
|
+
updateAvailable: !success,
|
|
131
|
+
lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
|
|
73
132
|
});
|
|
133
|
+
|
|
134
|
+
if (success && notify) {
|
|
135
|
+
writeNotification({
|
|
136
|
+
kind: 'updated',
|
|
137
|
+
from: currentVersion,
|
|
138
|
+
to: latest.version,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
74
142
|
return {
|
|
75
|
-
updateAvailable:
|
|
143
|
+
updateAvailable: !success,
|
|
144
|
+
updated: success,
|
|
76
145
|
from: currentVersion,
|
|
77
146
|
to: latest.version,
|
|
147
|
+
installMode,
|
|
78
148
|
};
|
|
79
149
|
}
|
|
80
150
|
|
|
81
|
-
|
|
82
|
-
const success = await downloadAndInstall(latest.tarballUrl, verbose, token);
|
|
83
|
-
|
|
151
|
+
// No update needed
|
|
84
152
|
saveState({
|
|
85
153
|
...state,
|
|
86
154
|
lastCheck: new Date().toISOString(),
|
|
87
155
|
latestVersion: latest.version,
|
|
88
|
-
updateAvailable:
|
|
89
|
-
lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
|
|
156
|
+
updateAvailable: false,
|
|
90
157
|
});
|
|
91
|
-
|
|
92
|
-
return
|
|
93
|
-
updateAvailable: !success,
|
|
94
|
-
updated: success,
|
|
95
|
-
from: currentVersion,
|
|
96
|
-
to: latest.version,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// No update needed
|
|
101
|
-
saveState({
|
|
102
|
-
...state,
|
|
103
|
-
lastCheck: new Date().toISOString(),
|
|
104
|
-
latestVersion: latest.version,
|
|
105
|
-
updateAvailable: false,
|
|
158
|
+
if (verbose) console.log('Already up to date');
|
|
159
|
+
return null;
|
|
106
160
|
});
|
|
107
|
-
if (verbose) console.log('Already up to date');
|
|
108
|
-
return null;
|
|
109
161
|
} catch (err) {
|
|
110
162
|
if (verbose) console.error('Update check failed:', err.message);
|
|
111
163
|
return null;
|
|
112
164
|
}
|
|
113
165
|
}
|
|
114
166
|
|
|
167
|
+
async function withFileLock(fn) {
|
|
168
|
+
let acquired = false;
|
|
169
|
+
try {
|
|
170
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
171
|
+
} catch {
|
|
172
|
+
/* best effort */
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
176
|
+
try {
|
|
177
|
+
fs.writeFileSync(STATE_LOCK_FILE, String(process.pid), { flag: 'wx' });
|
|
178
|
+
acquired = true;
|
|
179
|
+
break;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err.code === 'EEXIST') {
|
|
182
|
+
try {
|
|
183
|
+
const stats = fs.statSync(STATE_LOCK_FILE);
|
|
184
|
+
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
|
|
185
|
+
try { fs.rmSync(STATE_LOCK_FILE, { force: true }); } catch {}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_MS));
|
|
192
|
+
} else {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
return await fn();
|
|
200
|
+
} finally {
|
|
201
|
+
if (acquired) {
|
|
202
|
+
try { fs.rmSync(STATE_LOCK_FILE, { force: true }); } catch {}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
115
207
|
// ── Skip Check Detection ──────────────────────────────────
|
|
116
208
|
// Returns true when update checks should be skipped:
|
|
117
209
|
// 1. PLUGIN_AUTO_UPDATE env set → recursive guard (auto-update already in progress)
|
|
118
210
|
// 2. Running from a git clone → dev mode (developer working on source)
|
|
119
211
|
function shouldSkipUpdateCheck() {
|
|
120
212
|
if (process.env.PLUGIN_AUTO_UPDATE) return true;
|
|
213
|
+
return isDevMode();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isDevMode() {
|
|
121
217
|
try {
|
|
122
218
|
if (!fs.existsSync(path.join(pluginRoot, '.git'))) return false;
|
|
123
219
|
const pkg = JSON.parse(
|
|
@@ -129,6 +225,13 @@ function shouldSkipUpdateCheck() {
|
|
|
129
225
|
}
|
|
130
226
|
}
|
|
131
227
|
|
|
228
|
+
function getInstallMode() {
|
|
229
|
+
if (isDevMode()) return 'dev';
|
|
230
|
+
return path.resolve(pluginRoot) === path.resolve(claudeDir)
|
|
231
|
+
? 'manual'
|
|
232
|
+
: 'plugin';
|
|
233
|
+
}
|
|
234
|
+
|
|
132
235
|
// ── Throttle ───────────────────────────────────────────────
|
|
133
236
|
function shouldCheck(state) {
|
|
134
237
|
if (!state.lastCheck) return true;
|
|
@@ -196,11 +299,11 @@ function compareVersions(a, b) {
|
|
|
196
299
|
return 0;
|
|
197
300
|
}
|
|
198
301
|
|
|
199
|
-
function getCurrentVersion() {
|
|
200
|
-
|
|
201
|
-
path.join(pluginRoot, 'package.json')
|
|
202
|
-
path.join(runtimeDir, 'package.json')
|
|
203
|
-
|
|
302
|
+
function getCurrentVersion(mode = getInstallMode()) {
|
|
303
|
+
const candidates = mode === 'manual'
|
|
304
|
+
? [path.join(runtimeDir, 'package.json'), path.join(pluginRoot, 'package.json')]
|
|
305
|
+
: [path.join(pluginRoot, 'package.json'), path.join(runtimeDir, 'package.json')];
|
|
306
|
+
for (const p of candidates) {
|
|
204
307
|
try {
|
|
205
308
|
return JSON.parse(fs.readFileSync(p, 'utf8')).version;
|
|
206
309
|
} catch {
|
|
@@ -210,9 +313,24 @@ function getCurrentVersion() {
|
|
|
210
313
|
return '0.0.0';
|
|
211
314
|
}
|
|
212
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
|
+
|
|
213
329
|
// ── Download & Install ─────────────────────────────────────
|
|
214
330
|
async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
|
|
215
331
|
const tmpDir = path.join(os.tmpdir(), `gsd-update-${Date.now()}`);
|
|
332
|
+
const backupPath = path.join(pluginRoot, 'package.json.bak');
|
|
333
|
+
let backedUp = false;
|
|
216
334
|
try {
|
|
217
335
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
218
336
|
|
|
@@ -238,6 +356,23 @@ async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
|
|
|
238
356
|
const tar = spawnSync('tar', ['xzf', tarPath, '-C', tmpDir, '--strip-components=1'], { timeout: 30000 });
|
|
239
357
|
if (tar.status !== 0) throw new Error(`tar extract failed: ${(tar.stderr || '').toString().slice(0, 200)}`);
|
|
240
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
|
+
|
|
241
376
|
// Run installer with spawnSync (no shell)
|
|
242
377
|
if (verbose) console.log(' Running installer...');
|
|
243
378
|
const install = spawnSync(process.execPath, [path.join(tmpDir, 'install.js')], {
|
|
@@ -245,7 +380,18 @@ async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
|
|
|
245
380
|
stdio: verbose ? 'inherit' : 'pipe',
|
|
246
381
|
env: { ...process.env, PLUGIN_AUTO_UPDATE: '1' },
|
|
247
382
|
});
|
|
248
|
-
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
|
+
}
|
|
249
395
|
|
|
250
396
|
return true;
|
|
251
397
|
} catch (err) {
|
|
@@ -280,32 +426,31 @@ function saveState(state) {
|
|
|
280
426
|
}
|
|
281
427
|
}
|
|
282
428
|
|
|
429
|
+
function writeNotification(notification) {
|
|
430
|
+
try {
|
|
431
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
432
|
+
const tmpPath = NOTIFICATION_FILE + `.${process.pid}.tmp`;
|
|
433
|
+
fs.writeFileSync(tmpPath, JSON.stringify(notification, null, 2) + '\n');
|
|
434
|
+
fs.renameSync(tmpPath, NOTIFICATION_FILE);
|
|
435
|
+
} catch {
|
|
436
|
+
/* silent */
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
283
440
|
module.exports = {
|
|
284
441
|
checkForUpdate,
|
|
285
442
|
getCurrentVersion,
|
|
286
443
|
compareVersions,
|
|
444
|
+
getInstallMode,
|
|
445
|
+
isDevMode,
|
|
446
|
+
shouldCheck,
|
|
287
447
|
shouldSkipUpdateCheck,
|
|
448
|
+
validateExtractedPackage,
|
|
288
449
|
};
|
|
289
450
|
|
|
290
451
|
// ── CLI Entry Point (for background auto-install) ─────────
|
|
291
452
|
if (require.main === module) {
|
|
292
|
-
checkForUpdate({ install: true, verbose: false })
|
|
293
|
-
.then((result) => {
|
|
294
|
-
if (result?.updated) {
|
|
295
|
-
const notifPath = path.join(stateDir, 'update-notification.json');
|
|
296
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
297
|
-
const tmpPath = notifPath + `.${process.pid}.tmp`;
|
|
298
|
-
fs.writeFileSync(
|
|
299
|
-
tmpPath,
|
|
300
|
-
JSON.stringify({
|
|
301
|
-
from: result.from,
|
|
302
|
-
to: result.to,
|
|
303
|
-
at: new Date().toISOString(),
|
|
304
|
-
}) + '\n',
|
|
305
|
-
);
|
|
306
|
-
fs.renameSync(tmpPath, notifPath);
|
|
307
|
-
}
|
|
308
|
-
})
|
|
453
|
+
checkForUpdate({ install: true, verbose: false, notify: true })
|
|
309
454
|
.catch(() => {})
|
|
310
455
|
.finally(() => process.exit(0));
|
|
311
456
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// GSD-Lite SessionStart hook
|
|
3
3
|
// 1. Cleans up stale temp files (throttled to once/day).
|
|
4
4
|
// 2. Auto-registers statusLine in settings.json if not already configured.
|
|
5
|
-
// 3. Shows notification if a previous background
|
|
5
|
+
// 3. Shows notification if a previous background update completed or found a new version.
|
|
6
6
|
// 4. Spawns background auto-update (detached, non-blocking).
|
|
7
7
|
// Idempotent: skips if statusLine already points to gsd-statusline, preserves
|
|
8
8
|
// third-party statuslines.
|
|
@@ -71,7 +71,13 @@ setTimeout(() => process.exit(0), 4000).unref();
|
|
|
71
71
|
const notifPath = path.join(claudeDir, 'gsd', 'runtime', 'update-notification.json');
|
|
72
72
|
if (fs.existsSync(notifPath)) {
|
|
73
73
|
const notif = JSON.parse(fs.readFileSync(notifPath, 'utf8'));
|
|
74
|
-
|
|
74
|
+
if (notif.kind === 'updated') {
|
|
75
|
+
console.log(`✅ GSD-Lite auto-updated: v${notif.from} → v${notif.to}`);
|
|
76
|
+
} else if (notif.kind === 'available' && notif.action === 'plugin_update') {
|
|
77
|
+
console.log(`📦 GSD-Lite update available: v${notif.from} → v${notif.to}. Run /plugin update gsd`);
|
|
78
|
+
} else if (notif.kind === 'available') {
|
|
79
|
+
console.log(`📦 GSD-Lite update available: v${notif.from} → v${notif.to}. Run gsd update`);
|
|
80
|
+
}
|
|
75
81
|
fs.unlinkSync(notifPath);
|
|
76
82
|
}
|
|
77
83
|
} catch { /* silent */ }
|
package/package.json
CHANGED
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,26 @@ 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
|
+
}
|
|
359
390
|
if (Array.isArray(state.phases)) {
|
|
360
391
|
if (typeof state.total_phases === 'number' && state.total_phases !== state.phases.length) {
|
|
361
392
|
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()) {
|