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.
@@ -13,7 +13,7 @@
13
13
  "name": "gsd",
14
14
  "source": "./",
15
15
  "description": "AI orchestration tool — GSD management shell + Superpowers quality core. 5 commands, 4 agents, 5 workflows, MCP server, context monitoring.",
16
- "version": "0.3.16",
16
+ "version": "0.4.0",
17
17
  "keywords": [
18
18
  "orchestration",
19
19
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd",
3
- "version": "0.3.16",
3
+ "version": "0.4.0",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "author": {
6
6
  "name": "sdsrss",
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
- console.log(`\n! Update available v${result.to} but install failed. Try manually.`);
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 { force = false, verbose = false, install = true } = options;
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
- const state = readState();
36
- if (!force && !shouldCheck(state)) {
37
- if (state.updateAvailable && state.latestVersion) {
38
- return {
39
- updateAvailable: true,
40
- from: getCurrentVersion(),
41
- to: state.latestVersion,
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
- // Successful fetch clear rate-limit back-off
59
- state.rateLimited = false;
60
- const currentVersion = getCurrentVersion();
61
- if (verbose) console.log(`Current: v${currentVersion} — Latest: v${latest.version}`);
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
- const hasUpdate = compareVersions(latest.version, currentVersion) > 0;
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: true,
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: true,
143
+ updateAvailable: !success,
144
+ updated: success,
76
145
  from: currentVersion,
77
146
  to: latest.version,
147
+ installMode,
78
148
  };
79
149
  }
80
150
 
81
- if (verbose) console.log(`Downloading v${latest.version}...`);
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: !success,
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
- for (const p of [
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) throw new Error(`Installer failed: ${(install.stderr || '').toString().slice(0, 200)}`);
383
+ if (install.status !== 0) {
384
+ // Restore backup on install failure
385
+ if (backedUp) {
386
+ try { fs.copyFileSync(backupPath, currentPkgPath); } catch { /* best effort */ }
387
+ }
388
+ throw new Error(`Installer failed: ${(install.stderr || '').toString().slice(0, 200)}`);
389
+ }
390
+
391
+ // Success — remove backup
392
+ if (backedUp) {
393
+ try { fs.rmSync(backupPath, { force: true }); } catch { /* ignore */ }
394
+ }
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 auto-update completed.
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
- console.log(`✅ GSD-Lite auto-updated: v${notif.from} → v${notif.to}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-lite",
3
- "version": "0.3.16",
3
+ "version": "0.4.0",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "type": "module",
6
6
  "bin": {
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
  }
@@ -75,7 +75,14 @@ export async function runTypeCheck(pm, cwd) {
75
75
  if (pm === 'pnpm') return runCommand('pnpm', ['exec', 'tsc', '--noEmit'], cwd);
76
76
  if (pm === 'yarn') return runCommand('yarn', ['tsc', '--noEmit'], cwd);
77
77
  if (pm === 'bun') return runCommand('bun', ['run', 'tsc', '--noEmit'], cwd);
78
- return runCommand('npx', ['tsc', '--noEmit'], cwd);
78
+ // Local-first: use node_modules/.bin/tsc if available, skip otherwise
79
+ const localTsc = join(cwd, 'node_modules', '.bin', 'tsc');
80
+ try {
81
+ await stat(localTsc);
82
+ } catch {
83
+ return { exit_code: 0, summary: 'skipped: no local typescript found' };
84
+ }
85
+ return runCommand(localTsc, ['--noEmit'], cwd);
79
86
  }
80
87
 
81
88
  export async function runAll(cwd = process.cwd()) {