gsd-lite 0.5.13 → 0.5.15

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.5.13",
16
+ "version": "0.5.15",
17
17
  "keywords": [
18
18
  "orchestration",
19
19
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "author": {
6
6
  "name": "sdsrss",
package/README.md CHANGED
@@ -17,7 +17,7 @@ GSD-Lite is an AI orchestration tool for [Claude Code](https://docs.anthropic.co
17
17
  ### Quality Discipline (Built-in, Not Optional)
18
18
  - **TDD enforcement** — "No production code without a failing test first" baked into every executor dispatch
19
19
  - **Anti-rationalization guards** — Red-flag checklists inline in every agent prompt, blocking common excuses to skip process
20
- - **Multi-level code review** — L0 self-review / L1 phase-batch review / L2 immediate independent review
20
+ - **Multi-level code review** — L0 self-review / L1 phase-batch review / L2 immediate independent review / phase review retry limit
21
21
  - **Contract change propagation** — When an API contract changes, downstream tasks automatically invalidate
22
22
 
23
23
  ### Intelligent Failure Recovery
@@ -27,7 +27,7 @@ GSD-Lite is an AI orchestration tool for [Claude Code](https://docs.anthropic.co
27
27
  - **Rework propagation** — Critical review issues cascade invalidation to dependent tasks
28
28
 
29
29
  ### Adaptive Review & Parallel Execution
30
- - **Confidence-based review adjustment** — Executor self-assesses confidence (high/medium/low); orchestrator auto-adjusts review level accordingly
30
+ - **Confidence-based review adjustment** — Executor self-assesses confidence (high/medium/low); orchestrator auto-adjusts review level with evidence cross-validation
31
31
  - **Impact analysis before review** — Reviewer runs impact analysis on multi-file changes to catch missed downstream effects
32
32
  - **Parallel task scheduling** — Independent tasks within the same phase are identified for concurrent dispatch
33
33
  - **Auto PR suggestion** — Phase/project completion prompts PR creation with evidence summary
@@ -249,7 +249,7 @@ gsd-lite/
249
249
  ├── references/ # 8 reference docs
250
250
  ├── hooks/ # Session lifecycle (StatusLine + PostToolUse + SessionStart + Stop + AutoUpdate)
251
251
  │ └── lib/ # Shared hook utilities (gsd-finder)
252
- ├── tests/ # 822 tests (unit + simulation + E2E)
252
+ ├── tests/ # 826 tests (unit + simulation + E2E)
253
253
  ├── cli.js # Install/uninstall CLI entry
254
254
  ├── install.js # Installation script
255
255
  └── uninstall.js # Uninstall script
@@ -258,8 +258,8 @@ gsd-lite/
258
258
  ## Testing
259
259
 
260
260
  ```bash
261
- npm test # Run all 822 tests
262
- npm run test:coverage # Tests + coverage report (94%+ lines, 81%+ branches)
261
+ npm test # Run all 826 tests
262
+ npm run test:coverage # Tests + coverage report (94%+ lines, 83%+ branches)
263
263
  npm run lint # Biome lint
264
264
  node --test tests/file.js # Run a single test file
265
265
  ```
@@ -337,7 +337,7 @@ function validateExtractedPackage(extractDir) {
337
337
  // ── Download & Install ─────────────────────────────────────
338
338
  async function downloadAndInstall(tarballUrl, verbose = false, token = null) {
339
339
  const tmpDir = path.join(os.tmpdir(), `gsd-update-${Date.now()}`);
340
- const backupPath = path.join(pluginRoot, 'package.json.bak');
340
+ const backupPath = path.join(runtimeDir, 'package.json.bak');
341
341
  let backedUp = false;
342
342
  try {
343
343
  fs.mkdirSync(tmpDir, { recursive: true });
@@ -450,6 +450,35 @@ function writeNotification(notification) {
450
450
  }
451
451
  }
452
452
 
453
+ // ── Cache Cleanup ─────────────────────────────────────────
454
+ // Remove old plugin cache versions, keeping the N most recent.
455
+ function pruneOldCacheVersions(cacheBase, keepCount = 3, verbose = false) {
456
+ try {
457
+ if (!fs.existsSync(cacheBase)) return;
458
+ const entries = fs.readdirSync(cacheBase, { withFileTypes: true })
459
+ .filter(e => e.isDirectory())
460
+ .map(e => e.name);
461
+ if (entries.length <= keepCount) return;
462
+
463
+ // Sort by semver: split into [major, minor, patch] and compare numerically
464
+ const sorted = entries.slice().sort((a, b) => {
465
+ const pa = a.split('.').map(Number);
466
+ const pb = b.split('.').map(Number);
467
+ for (let i = 0; i < 3; i++) {
468
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
469
+ }
470
+ return 0;
471
+ });
472
+
473
+ const toRemove = sorted.slice(0, sorted.length - keepCount);
474
+ for (const ver of toRemove) {
475
+ const verPath = path.join(cacheBase, ver);
476
+ fs.rmSync(verPath, { recursive: true, force: true });
477
+ if (verbose) console.log(` Pruned old cache: ${ver}`);
478
+ }
479
+ } catch { /* best effort */ }
480
+ }
481
+
453
482
  // ── Plugin Cache Sync ─────────────────────────────────────
454
483
  // When installed as a plugin, the MCP server runs from plugins/cache/gsd/gsd/<version>/
455
484
  // The auto-update installs to ~/.claude/gsd/ (runtime dir) via install.js,
@@ -492,11 +521,17 @@ function syncPluginCache(extractedDir, verbose = false) {
492
521
 
493
522
  // Install dependencies in cache dir
494
523
  if (!fs.existsSync(path.join(newCachePath, 'node_modules', '@modelcontextprotocol'))) {
495
- spawnSync('npm', ['install', '--omit=dev', '--ignore-scripts'], {
524
+ const npmResult = spawnSync('npm', ['install', '--omit=dev', '--ignore-scripts'], {
496
525
  cwd: newCachePath,
497
526
  stdio: 'pipe',
498
527
  timeout: 60000,
499
528
  });
529
+ if (npmResult.status !== 0) {
530
+ // npm install failed — don't update registry to point to broken cache
531
+ if (verbose) console.error(' npm install failed in cache dir, aborting cache sync');
532
+ fs.rmSync(newCachePath, { recursive: true, force: true });
533
+ return;
534
+ }
500
535
  }
501
536
 
502
537
  // Update installed_plugins.json to point to new cache path
@@ -507,6 +542,26 @@ function syncPluginCache(extractedDir, verbose = false) {
507
542
  fs.writeFileSync(tmpPlugins, JSON.stringify(plugins, null, 2) + '\n');
508
543
  fs.renameSync(tmpPlugins, pluginsFile);
509
544
 
545
+ // Update settings.json statusLine if it points to the old cache path
546
+ try {
547
+ const settingsPath = path.join(claudeDir, 'settings.json');
548
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
549
+ if (settings.statusLine?.command?.includes('/plugins/cache/gsd/gsd/')) {
550
+ const oldCmd = settings.statusLine.command;
551
+ const updated = oldCmd.replace(/\/plugins\/cache\/gsd\/gsd\/[^/]+\//g, `/plugins/cache/gsd/gsd/${newVersion}/`);
552
+ if (updated !== oldCmd) {
553
+ settings.statusLine.command = updated;
554
+ const tmpSettings = settingsPath + `.${process.pid}.tmp`;
555
+ fs.writeFileSync(tmpSettings, JSON.stringify(settings, null, 2) + '\n');
556
+ fs.renameSync(tmpSettings, settingsPath);
557
+ if (verbose) console.log(' StatusLine path updated to new version');
558
+ }
559
+ }
560
+ } catch {}
561
+
562
+ // Prune old cache versions — keep only the 3 most recent
563
+ pruneOldCacheVersions(cacheBase, 3, verbose);
564
+
510
565
  if (verbose) console.log(` Plugin cache synced to v${newVersion}`);
511
566
  } catch (err) {
512
567
  // Best effort — don't fail the update if cache sync fails
@@ -6,24 +6,7 @@
6
6
  const fs = require('node:fs');
7
7
  const path = require('node:path');
8
8
  const os = require('node:os');
9
-
10
- /**
11
- * Walk from startDir up to filesystem root looking for a .gsd directory.
12
- * Returns the absolute path to .gsd if found, or null.
13
- */
14
- function findGsdDir(startDir) {
15
- let dir = startDir;
16
- while (true) {
17
- const candidate = path.join(dir, '.gsd');
18
- try {
19
- if (fs.statSync(candidate).isDirectory()) return candidate;
20
- } catch {
21
- const parent = path.dirname(dir);
22
- if (parent === dir) return null; // reached filesystem root
23
- dir = parent;
24
- }
25
- }
26
- }
9
+ const { findGsdDir } = require('./lib/gsd-finder.cjs');
27
10
 
28
11
  let input = '';
29
12
  const stdinTimeout = setTimeout(() => process.exit(0), 3000);
package/install.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Plugin installer for GSD-Lite
3
3
 
4
- import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
4
+ import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync, renameSync, rmSync, readdirSync } 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';
@@ -13,12 +13,13 @@ const RUNTIME_DIR = join(CLAUDE_DIR, 'gsd');
13
13
  const DRY_RUN = process.argv.includes('--dry-run');
14
14
 
15
15
  // Single source of truth for hook files (used by copy loop and registration)
16
- const HOOK_FILES = ['gsd-session-init.cjs', 'gsd-auto-update.cjs', 'gsd-context-monitor.cjs', 'gsd-statusline.cjs'];
16
+ const HOOK_FILES = ['gsd-session-init.cjs', 'gsd-auto-update.cjs', 'gsd-context-monitor.cjs', 'gsd-statusline.cjs', 'gsd-session-stop.cjs'];
17
17
 
18
18
  // Hook registration config: hookType → { file identifier, matcher, timeout? }
19
19
  const HOOK_REGISTRY = [
20
20
  { hookType: 'SessionStart', identifier: 'gsd-session-init', matcher: 'startup', timeout: 5 },
21
21
  { hookType: 'PostToolUse', identifier: 'gsd-context-monitor', matcher: '*' },
22
+ { hookType: 'Stop', identifier: 'gsd-session-stop', matcher: '*', timeout: 3 },
22
23
  ];
23
24
 
24
25
  function log(msg) { console.log(msg); }
@@ -135,6 +136,11 @@ export function main() {
135
136
  for (const hookFile of HOOK_FILES) {
136
137
  copyFile(join(__dirname, 'hooks', hookFile), join(CLAUDE_DIR, 'hooks', hookFile), `hooks/${hookFile}`);
137
138
  }
139
+ // 5b. Hook library dependencies (e.g. gsd-finder.cjs used by statusline + session-init)
140
+ const hookLibDir = join(__dirname, 'hooks', 'lib');
141
+ if (existsSync(hookLibDir)) {
142
+ copyDir(hookLibDir, join(CLAUDE_DIR, 'hooks', 'lib'), 'hooks/lib → ~/.claude/hooks/lib/');
143
+ }
138
144
 
139
145
  // 6. Stable runtime for MCP server
140
146
  copyDir(join(__dirname, 'src'), join(RUNTIME_DIR, 'src'), 'runtime/src → ~/.claude/gsd/src/');
@@ -186,12 +192,36 @@ export function main() {
186
192
  }
187
193
 
188
194
  // Register statusLine (top-level setting) and hooks
189
- if (!settings.hooks) settings.hooks = {};
190
- const statuslinePath = join(CLAUDE_DIR, 'hooks', 'gsd-statusline.cjs');
191
- const statusLineRegistered = registerStatusLine(settings, statuslinePath);
195
+ // When installed as a plugin, hooks are managed by hooks.json via the plugin system.
196
+ // Only register in settings.json for manual installs to avoid double execution.
197
+ let statusLineRegistered = false;
192
198
  let hooksRegistered = false;
193
- for (const config of HOOK_REGISTRY) {
194
- if (registerHookEntry(settings.hooks, config)) hooksRegistered = true;
199
+ if (!isPluginInstall) {
200
+ if (!settings.hooks) settings.hooks = {};
201
+ const statuslinePath = join(CLAUDE_DIR, 'hooks', 'gsd-statusline.cjs');
202
+ statusLineRegistered = registerStatusLine(settings, statuslinePath);
203
+ for (const config of HOOK_REGISTRY) {
204
+ if (registerHookEntry(settings.hooks, config)) hooksRegistered = true;
205
+ }
206
+ } else {
207
+ // Clean up stale manual hook entries left from previous install.js runs
208
+ if (settings.hooks) {
209
+ let cleaned = false;
210
+ for (const [hookType, identifier] of [
211
+ ['PostToolUse', 'gsd-context-monitor'],
212
+ ['SessionStart', 'gsd-session-init'],
213
+ ['Stop', 'gsd-session-stop'],
214
+ ]) {
215
+ if (Array.isArray(settings.hooks[hookType])) {
216
+ const before = settings.hooks[hookType].length;
217
+ settings.hooks[hookType] = settings.hooks[hookType].filter(e =>
218
+ !e.hooks?.some(h => h.command?.includes(identifier)));
219
+ if (settings.hooks[hookType].length < before) cleaned = true;
220
+ if (settings.hooks[hookType].length === 0) delete settings.hooks[hookType];
221
+ }
222
+ }
223
+ if (cleaned) log(' ✓ Removed stale manual hook entries (plugin hooks.json handles registration)');
224
+ }
195
225
  }
196
226
 
197
227
  const tmpSettings = settingsPath + `.${process.pid}-${Date.now()}.tmp`;
@@ -204,6 +234,32 @@ export function main() {
204
234
  log(' [dry-run] Would register MCP server in settings.json');
205
235
  }
206
236
 
237
+ // 9. Prune old plugin cache versions (keep latest 3)
238
+ if (!DRY_RUN && isPluginInstall) {
239
+ const cacheBase = join(CLAUDE_DIR, 'plugins', 'cache', 'gsd', 'gsd');
240
+ if (existsSync(cacheBase)) {
241
+ try {
242
+ const entries = readdirSync(cacheBase, { withFileTypes: true })
243
+ .filter(e => e.isDirectory()).map(e => e.name);
244
+ if (entries.length > 3) {
245
+ const sorted = entries.slice().sort((a, b) => {
246
+ const pa = a.split('.').map(Number);
247
+ const pb = b.split('.').map(Number);
248
+ for (let i = 0; i < 3; i++) {
249
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
250
+ }
251
+ return 0;
252
+ });
253
+ const toRemove = sorted.slice(0, sorted.length - 3);
254
+ for (const ver of toRemove) {
255
+ rmSync(join(cacheBase, ver), { recursive: true, force: true });
256
+ }
257
+ log(` ✓ Pruned ${toRemove.length} old cache version(s), kept latest 3`);
258
+ }
259
+ } catch { /* best effort */ }
260
+ }
261
+ }
262
+
207
263
  log('\n✓ GSD-Lite installed successfully!');
208
264
  log(' Use /gsd:start to begin a new project');
209
265
  log(' Use /gsd:resume to continue an existing project');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-lite",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,8 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
16
16
  return { error: true, message: `Invalid debugger result: ${validation.errors.join('; ')}` };
17
17
  }
18
18
 
19
+ // Note: read() is outside the state lock — safe under single-session sequential execution.
20
+ // See executor.js for rationale.
19
21
  const state = await read({ basePath });
20
22
  if (state.error) return state;
21
23
  const { phase, task } = getPhaseAndTask(state, result.task_id);
@@ -20,6 +20,9 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
20
20
  return { error: true, message: `Invalid executor result: ${validation.errors.join('; ')}` };
21
21
  }
22
22
 
23
+ // Note: read() is outside the state lock. This is safe because the MCP server
24
+ // processes tool calls sequentially (single-session, promise-queue serialized).
25
+ // persist() below re-acquires the lock and applies changes atomically.
23
26
  const state = await read({ basePath });
24
27
  if (state.error) return state;
25
28
  const { phase, task } = getPhaseAndTask(state, result.task_id);
@@ -27,6 +30,16 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
27
30
  return { error: true, message: `Task ${result.task_id} not found` };
28
31
  }
29
32
 
33
+ // Auto-start parallel tasks: if a task is still pending (dispatched via parallel_available
34
+ // but not explicitly started by orchestrator-resume), transition it to running first.
35
+ if (task.lifecycle === 'pending') {
36
+ const startError = await persist(basePath, {
37
+ phases: [{ id: phase.id, todo: [{ id: task.id, lifecycle: 'running' }] }],
38
+ });
39
+ if (startError) return startError;
40
+ task.lifecycle = 'running';
41
+ }
42
+
30
43
  // Build new decision entries — actual append happens atomically inside update()'s lock
31
44
  const newDecisions = buildDecisionEntries(result.decisions, phase.id, task.id, (state.decisions || []).length);
32
45
 
@@ -10,6 +10,7 @@ import { getGitHead, getGsdDir } from '../../utils.js';
10
10
  const MAX_DEBUG_RETRY = 3;
11
11
  const MAX_RESUME_DEPTH = 3;
12
12
  const CONTEXT_RESUME_THRESHOLD = 40;
13
+ const MAX_PHASE_REVIEW_RETRY = 5;
13
14
 
14
15
  // ── Result Contracts ──
15
16
  // Provided in dispatch responses so agents produce valid results on the first call.
@@ -424,6 +425,7 @@ export {
424
425
  MAX_DEBUG_RETRY,
425
426
  MAX_RESUME_DEPTH,
426
427
  CONTEXT_RESUME_THRESHOLD,
428
+ MAX_PHASE_REVIEW_RETRY,
427
429
  RESULT_CONTRACTS,
428
430
  isTerminalWorkflowMode,
429
431
  parseTimestamp,
@@ -1,6 +1,5 @@
1
1
  import { storeResearch } from '../state/index.js';
2
2
  import { validateResearcherResult } from '../../schema.js';
3
- import { resumeWorkflow } from './resume.js';
4
3
 
5
4
  export async function handleResearcherResult({ result, artifacts, decision_index, basePath = process.cwd() } = {}) {
6
5
  if (!result || typeof result !== 'object' || Array.isArray(result)) {
@@ -15,11 +14,10 @@ export async function handleResearcherResult({ result, artifacts, decision_index
15
14
  const persisted = await storeResearch({ result, artifacts, decision_index, basePath });
16
15
  if (persisted.error) return persisted;
17
16
 
18
- const resumed = await resumeWorkflow({ basePath });
19
- if (resumed.error) return resumed;
20
-
21
17
  return {
22
- ...resumed,
18
+ success: true,
19
+ action: 'research_stored',
20
+ workflow_mode: persisted.workflow_mode,
23
21
  stored_files: persisted.stored_files,
24
22
  decision_ids: persisted.decision_ids,
25
23
  research_warnings: persisted.warnings,
@@ -1,6 +1,7 @@
1
1
  import { read } from '../state/index.js';
2
2
  import { validateReviewerResult } from '../../schema.js';
3
3
  import {
4
+ MAX_PHASE_REVIEW_RETRY,
4
5
  getCurrentPhase,
5
6
  getTaskById,
6
7
  persist,
@@ -15,6 +16,8 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
15
16
  return { error: true, message: `Invalid reviewer result: ${validation.errors.join('; ')}` };
16
17
  }
17
18
 
19
+ // Note: read() is outside the state lock — safe under single-session sequential execution.
20
+ // See executor.js for rationale.
18
21
  const state = await read({ basePath });
19
22
  if (state.error) return state;
20
23
 
@@ -70,6 +73,40 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
70
73
  const specFailed = result.spec_passed === false;
71
74
  const qualityFailed = result.quality_passed === false;
72
75
  const needsRework = hasCritical || specFailed || qualityFailed;
76
+
77
+ // Compute retry count once for both exhaustion check and state update
78
+ const currentRetryCount = phase.phase_review?.retry_count || 0;
79
+ const nextRetryCount = needsRework ? currentRetryCount + 1 : 0;
80
+
81
+ // Phase review retry limit: prevent infinite reviewing↔active cycles
82
+ if (needsRework && nextRetryCount > MAX_PHASE_REVIEW_RETRY) {
83
+ const persistError = await persist(basePath, {
84
+ workflow_mode: 'awaiting_user',
85
+ current_task: null,
86
+ current_review: {
87
+ scope: 'phase',
88
+ scope_id: phase.id,
89
+ stage: 'review_retry_exhausted',
90
+ retry_count: nextRetryCount,
91
+ },
92
+ phases: [{
93
+ id: phase.id,
94
+ lifecycle: phase.lifecycle === 'reviewing' ? 'active' : phase.lifecycle,
95
+ phase_review: { status: 'rework_required', retry_count: nextRetryCount },
96
+ }],
97
+ });
98
+ if (persistError) return persistError;
99
+
100
+ return {
101
+ success: true,
102
+ action: 'review_retry_exhausted',
103
+ workflow_mode: 'awaiting_user',
104
+ phase_id: phase.id,
105
+ retry_count: nextRetryCount,
106
+ message: `Phase ${phase.id} review failed ${nextRetryCount} times (limit: ${MAX_PHASE_REVIEW_RETRY}). User intervention required.`,
107
+ };
108
+ }
109
+
73
110
  const reviewStatus = needsRework ? 'rework_required' : 'accepted';
74
111
 
75
112
  // done is auto-recomputed by update() — no manual tracking needed
@@ -77,9 +114,7 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
77
114
  id: phase.id,
78
115
  phase_review: {
79
116
  status: reviewStatus,
80
- ...(needsRework
81
- ? { retry_count: (phase.phase_review?.retry_count || 0) + 1 }
82
- : { retry_count: 0 }),
117
+ retry_count: nextRetryCount,
83
118
  },
84
119
  todo: taskPatches,
85
120
  };
@@ -12,7 +12,6 @@ import {
12
12
  createInitialState,
13
13
  migrateState,
14
14
  } from '../../schema.js';
15
- import { runAll } from '../verify.js';
16
15
  import {
17
16
  ERROR_CODES,
18
17
  MAX_EVIDENCE_ENTRIES,
@@ -29,6 +28,11 @@ export async function init({ project, phases, research, force = false, basePath
29
28
  if (!project || typeof project !== 'string') {
30
29
  return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'project must be a non-empty string' };
31
30
  }
31
+ // Sanitize: strip HTML comment delimiters (could break marker-based CLAUDE.md injection) and cap length
32
+ project = project.replace(/<!--|-->/g, '').trim().slice(0, 200);
33
+ if (!project) {
34
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'project name is empty after sanitization' };
35
+ }
32
36
  if (!Array.isArray(phases)) {
33
37
  return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'phases must be an array' };
34
38
  }
@@ -420,7 +424,14 @@ export async function phaseComplete({
420
424
  };
421
425
  }
422
426
 
423
- const verificationResult = verification || (run_verify ? await runAll(basePath) : null);
427
+ if (run_verify && !verification) {
428
+ return {
429
+ error: true,
430
+ code: ERROR_CODES.INVALID_INPUT,
431
+ message: 'run_verify requires verification results to be passed via the verification parameter; the state layer does not execute external tools',
432
+ };
433
+ }
434
+ const verificationResult = verification || null;
424
435
  const testsPassed = verificationResult
425
436
  ? verificationPassed(verificationResult)
426
437
  : phase.phase_handoff.tests_passed === true;
@@ -269,9 +269,17 @@ export function reclassifyReviewLevel(task, executorResult) {
269
269
  }
270
270
 
271
271
  // High confidence on non-sensitive L1 tasks → downgrade to L0 (self-review sufficient)
272
+ // Cross-validate: require objective evidence before trusting self-reported confidence.
273
+ // Without evidence or with failed tests, confidence claim is not credible.
272
274
  if (executorResult.confidence === 'high' && currentLevel === 'L1'
273
275
  && !executorResult.contract_changed) {
274
- return 'L0';
276
+ const hasEvidence = Array.isArray(executorResult.evidence) && executorResult.evidence.length > 0;
277
+ const hasTestFailure = Array.isArray(executorResult.evidence)
278
+ && executorResult.evidence.some(e => e && e.type === 'test' && e.passed === false);
279
+ if (hasEvidence && !hasTestFailure) {
280
+ return 'L0';
281
+ }
282
+ // Insufficient evidence or test failure — stay at L1 despite high confidence claim
275
283
  }
276
284
 
277
285
  return currentLevel;
package/src/utils.js CHANGED
@@ -65,6 +65,7 @@ const LOCK_MAX_RETRIES = 100; // 5 seconds total
65
65
  */
66
66
  export async function withFileLock(lockPath, fn) {
67
67
  let acquired = false;
68
+ let nonLockError = false;
68
69
  for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
69
70
  try {
70
71
  await writeFile(lockPath, String(process.pid), { flag: 'wx' });
@@ -84,11 +85,21 @@ export async function withFileLock(lockPath, fn) {
84
85
  }
85
86
  await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
86
87
  } else {
87
- break; // Non-EEXIST error — proceed without lock
88
+ // Non-EEXIST error (e.g., read-only fs) — proceed without lock
89
+ nonLockError = true;
90
+ break;
88
91
  }
89
92
  }
90
93
  }
91
94
 
95
+ // Lock exhaustion (retries depleted while another process held the lock):
96
+ // throw to prevent concurrent unlocked writes that cause data corruption.
97
+ // Non-EEXIST errors (read-only fs, permission denied) still proceed without lock
98
+ // since locking is physically impossible in those environments.
99
+ if (!acquired && !nonLockError) {
100
+ throw new Error(`Lock acquisition timeout: could not acquire ${lockPath} after ${LOCK_MAX_RETRIES} retries (${LOCK_MAX_RETRIES * LOCK_RETRY_MS}ms)`);
101
+ }
102
+
92
103
  try {
93
104
  return await fn();
94
105
  } finally {
package/uninstall.js CHANGED
@@ -38,13 +38,23 @@ export function main() {
38
38
  removeDir(join(CLAUDE_DIR, 'gsd-lite'), 'legacy gsd-lite runtime/');
39
39
 
40
40
  // Remove hook files (both legacy and current names)
41
- for (const name of ['context-monitor.js', 'gsd-statusline.cjs', 'gsd-context-monitor.cjs', 'gsd-session-init.cjs', 'gsd-auto-update.cjs']) {
41
+ for (const name of ['context-monitor.js', 'gsd-statusline.cjs', 'gsd-context-monitor.cjs', 'gsd-session-init.cjs', 'gsd-auto-update.cjs', 'gsd-session-stop.cjs']) {
42
42
  const hookFile = join(CLAUDE_DIR, 'hooks', name);
43
43
  if (existsSync(hookFile)) {
44
44
  rmSync(hookFile);
45
45
  log(` ✓ Removed hooks/${name}`);
46
46
  }
47
47
  }
48
+ // Remove hook library dependencies
49
+ const hookLibDir = join(CLAUDE_DIR, 'hooks', 'lib');
50
+ if (existsSync(hookLibDir)) {
51
+ // Only remove GSD-owned files, not other plugins' libs
52
+ const gsdLibFile = join(hookLibDir, 'gsd-finder.cjs');
53
+ if (existsSync(gsdLibFile)) {
54
+ rmSync(gsdLibFile);
55
+ log(' ✓ Removed hooks/lib/gsd-finder.cjs');
56
+ }
57
+ }
48
58
 
49
59
  // Clean up plugin system directories (from /plugin install)
50
60
  removeDir(join(CLAUDE_DIR, 'plugins', 'marketplaces', 'gsd'), 'plugins/marketplaces/gsd/');
@@ -123,6 +133,7 @@ export function main() {
123
133
  ['PostToolUse', 'gsd-context-monitor'],
124
134
  ['PostToolUse', 'context-monitor.js'],
125
135
  ['SessionStart', 'gsd-session-init'],
136
+ ['Stop', 'gsd-session-stop'],
126
137
  ]) {
127
138
  if (Array.isArray(settings.hooks[hookType])) {
128
139
  const len = settings.hooks[hookType].length;