claude-dev-env 7.2.0 → 7.3.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.
@@ -15,7 +15,11 @@ Search files instantly on Windows using the Everything command-line interface (e
15
15
 
16
16
  Every search carries a scope: a project path or registry token, an `ext:` filter, a `dm:` date filter, a `size:` filter, or a name pattern. A bare whole-drive scan or a network-share sweep is out of bounds. Narrow the search to what you need.
17
17
 
18
- When `es.exe` fails or returns nothing, self-heal first: fall back to the `Glob` tool (name and path patterns) or `Grep` (file contents), and report the outage so the reader knows the index was unavailable. When self-healing also fails, ask the user through `AskUserQuestion` with a short analysis and next-step options.
18
+ When `es.exe` returns Error 8, the Everything IPC client window is missing. Retry the same search twice with a short pause. Do not report that Everything is not running, not loaded, or that the index is unavailable from that first miss. After those retries fail, report that the Everything IPC client did not answer and that the service state was not probed, then fall back to the `Glob` tool (name and path patterns) or `Grep` (file contents) with the same scope.
19
+
20
+ When `es.exe` is missing, or a later search returns no hits, fall back to `Glob` or `Grep`. A failed search is not proof the index is down. When those fallbacks also fail, ask the user through `AskUserQuestion` with a short analysis and next-step options.
21
+
22
+ Direct `es.exe` calls use the same Error 8 retry. The wrapper in `scripts/everything_search.py` already retries Error 8 twice.
19
23
 
20
24
  ## Registry tokens
21
25
 
@@ -139,7 +143,8 @@ Run separate searches for each extension type:
139
143
 
140
144
  ## Best Practices
141
145
 
142
- - Everything must be running (system tray) for es.exe to work
146
+ - `es.exe` talks to the Everything tray client IPC window. The Everything service can be running while Error 8 still fires.
147
+ - On Error 8, retry the same search twice before treating the search as failed.
143
148
  - Run one extension per search for cleaner results
144
149
  - Use `dm:` to filter recent files by the index timestamp
145
150
  - Combine with path to narrow scope
@@ -165,5 +170,5 @@ Everything indexes REAL paths only, not junctions or mapped drives.
165
170
 
166
171
  ## Files
167
172
 
168
- - `scripts/everything_search.py` runs one Everything search without a shell.
169
- - `scripts/test_everything_search.py` verifies registry expansion and command failures.
173
+ - `scripts/everything_search.py` runs one Everything search without a shell, retries Error 8 twice, and reports that the IPC client did not answer and that the service state was not probed when those retries fail.
174
+ - `scripts/test_everything_search.py` verifies registry expansion, command failures, Error 8 retry, and the IPC-client miss message.
@@ -4,6 +4,7 @@ import json
4
4
  import shutil
5
5
  import subprocess
6
6
  import sys
7
+ import time
7
8
  from collections.abc import Mapping, Sequence
8
9
  from pathlib import Path, PurePosixPath, PureWindowsPath
9
10
  from typing import TextIO
@@ -14,6 +15,10 @@ from everything_search_command_constants.config.constants import (
14
15
  EXECUTION_ERROR_EXIT_CODE,
15
16
  INFORMATIONAL_ARGUMENT,
16
17
  INVALID_INPUT_EXIT_CODE,
18
+ IPC_CLIENT_DID_NOT_ANSWER_MESSAGE,
19
+ IPC_WINDOW_NOT_FOUND_EXIT_CODE,
20
+ IPC_WINDOW_RETRY_COUNT,
21
+ IPC_WINDOW_RETRY_DELAY_SECONDS,
17
22
  PROJECT_PATHS_FILE_NAME,
18
23
  REGISTRY_META_KEY,
19
24
  SEARCH_SCOPE_REQUIRED_MESSAGE,
@@ -115,21 +120,32 @@ def _run_search(
115
120
  search_stdout: TextIO,
116
121
  search_stderr: TextIO,
117
122
  ) -> int:
118
- try:
119
- search_run = subprocess.run(
120
- [executable_path, *all_search_arguments],
121
- capture_output=True,
122
- check=False,
123
- encoding=UTF8_ENCODING,
124
- shell=False,
125
- text=True,
126
- )
127
- except OSError as error:
128
- search_stderr.write(f"{error}\n")
129
- return EXECUTION_ERROR_EXIT_CODE
130
- search_stdout.write(search_run.stdout)
131
- search_stderr.write(search_run.stderr)
132
- return search_run.returncode
123
+ ipc_client_did_not_answer_message = IPC_CLIENT_DID_NOT_ANSWER_MESSAGE
124
+ ipc_window_not_found_exit_code = IPC_WINDOW_NOT_FOUND_EXIT_CODE
125
+ ipc_window_retry_count = IPC_WINDOW_RETRY_COUNT
126
+ ipc_window_retry_delay_seconds = IPC_WINDOW_RETRY_DELAY_SECONDS
127
+ last_search_run = None
128
+ for each_attempt_index in range(ipc_window_retry_count + 1):
129
+ try:
130
+ last_search_run = subprocess.run(
131
+ [executable_path, *all_search_arguments],
132
+ capture_output=True,
133
+ check=False,
134
+ encoding=UTF8_ENCODING,
135
+ shell=False,
136
+ text=True,
137
+ )
138
+ except OSError as error:
139
+ search_stderr.write(f"{error}\n")
140
+ return EXECUTION_ERROR_EXIT_CODE
141
+ if last_search_run.returncode != ipc_window_not_found_exit_code:
142
+ search_stdout.write(last_search_run.stdout)
143
+ search_stderr.write(last_search_run.stderr)
144
+ return last_search_run.returncode
145
+ if each_attempt_index < ipc_window_retry_count:
146
+ time.sleep(ipc_window_retry_delay_seconds)
147
+ search_stderr.write(ipc_client_did_not_answer_message)
148
+ return ipc_window_not_found_exit_code
133
149
 
134
150
 
135
151
  def _run_registered_search(
@@ -5,6 +5,12 @@ EXECUTABLE_NAME = "es.exe"
5
5
  EXECUTION_ERROR_EXIT_CODE = 1
6
6
  INFORMATIONAL_ARGUMENT = "-version"
7
7
  INVALID_INPUT_EXIT_CODE = 2
8
+ IPC_CLIENT_DID_NOT_ANSWER_MESSAGE = (
9
+ "Everything IPC client did not answer. Everything service state was not probed.\n"
10
+ )
11
+ IPC_WINDOW_NOT_FOUND_EXIT_CODE = 8
12
+ IPC_WINDOW_RETRY_COUNT = 2
13
+ IPC_WINDOW_RETRY_DELAY_SECONDS = 0.5
8
14
  PROJECT_PATHS_FILE_NAME = "project-paths.json"
9
15
  REGISTRY_META_KEY = "_meta"
10
16
  SEARCH_SCOPE_REQUIRED_MESSAGE = "The first search argument must define a path, project, name, extension, date, or size scope.\n"
@@ -113,12 +113,79 @@ def test_main_passes_through_child_exit_status() -> None:
113
113
  with (
114
114
  patch.object(search, "load_registry", return_value={}),
115
115
  patch.object(search.shutil, "which", return_value="es.exe"),
116
- patch.object(search.subprocess, "run", return_value=completed_process),
116
+ patch.object(
117
+ search.subprocess, "run", return_value=completed_process
118
+ ) as run_process,
117
119
  patch.object(search.sys, "stderr", captured_stderr),
118
120
  ):
119
121
  exit_code = search.main(["config.py"])
120
122
  assert exit_code == 7
121
123
  assert captured_stderr.getvalue() == "failed\n"
124
+ run_process.assert_called_once()
125
+
126
+
127
+ def _ipc_window_error_process() -> search.subprocess.CompletedProcess:
128
+ return search.subprocess.CompletedProcess(
129
+ args=["es.exe", "config.py"],
130
+ returncode=8,
131
+ stdout="",
132
+ stderr=(
133
+ "Error 8: Everything IPC window not found. "
134
+ "Please make sure Everything is running.\n"
135
+ ),
136
+ )
137
+
138
+
139
+ def test_main_retries_ipc_window_error_then_returns_search_hits() -> None:
140
+ successful_process = search.subprocess.CompletedProcess(
141
+ args=["es.exe", "config.py"],
142
+ returncode=0,
143
+ stdout="found path\n",
144
+ stderr="",
145
+ )
146
+ captured_stdout = io.StringIO()
147
+ captured_stderr = io.StringIO()
148
+ with (
149
+ patch.object(search, "load_registry", return_value={}),
150
+ patch.object(search.shutil, "which", return_value="es.exe"),
151
+ patch.object(
152
+ search.subprocess,
153
+ "run",
154
+ side_effect=[_ipc_window_error_process(), successful_process],
155
+ ) as run_process,
156
+ patch.object(search.sys, "stdout", captured_stdout),
157
+ patch.object(search.sys, "stderr", captured_stderr),
158
+ patch("time.sleep"),
159
+ ):
160
+ exit_code = search.main(["config.py"])
161
+ assert exit_code == 0
162
+ assert captured_stdout.getvalue() == "found path\n"
163
+ assert "not loaded" not in captured_stderr.getvalue().casefold()
164
+ assert "not running" not in captured_stderr.getvalue().casefold()
165
+ assert run_process.call_count == 2
166
+
167
+
168
+ def test_main_reports_ipc_client_miss_after_ipc_window_retries_exhaust() -> None:
169
+ captured_stderr = io.StringIO()
170
+ with (
171
+ patch.object(search, "load_registry", return_value={}),
172
+ patch.object(search.shutil, "which", return_value="es.exe"),
173
+ patch.object(
174
+ search.subprocess,
175
+ "run",
176
+ return_value=_ipc_window_error_process(),
177
+ ) as run_process,
178
+ patch.object(search.sys, "stderr", captured_stderr),
179
+ patch("time.sleep"),
180
+ ):
181
+ exit_code = search.main(["config.py"])
182
+ assert exit_code == 8
183
+ stderr_text = captured_stderr.getvalue()
184
+ assert "Everything IPC client did not answer" in stderr_text
185
+ assert "service state was not probed" in stderr_text.casefold()
186
+ assert "Please make sure Everything is running" not in stderr_text
187
+ assert "not loaded" not in stderr_text.casefold()
188
+ assert run_process.call_count == 3
122
189
 
123
190
 
124
191
  def test_main_reports_missing_executable() -> None:
@@ -1353,6 +1353,23 @@ test('mergeHooksIntoSettings is idempotent when run twice against an already-upd
1353
1353
  });
1354
1354
 
1355
1355
 
1356
+ test('shipped SessionEnd hook timeouts stay within the Codex runtime limit', () => {
1357
+ const shippedHooksConfig = JSON.parse(
1358
+ readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
1359
+ );
1360
+ const allSessionEndHooks = (shippedHooksConfig.hooks.SessionEnd || [])
1361
+ .flatMap(eachMatcherGroup => eachMatcherGroup.hooks);
1362
+
1363
+ assert.ok(allSessionEndHooks.length > 0, 'shipped hooks.json must register SessionEnd hooks');
1364
+ for (const eachSessionEndHook of allSessionEndHooks) {
1365
+ assert.ok(
1366
+ eachSessionEndHook.timeout <= 3,
1367
+ `${eachSessionEndHook.command} exceeds the Codex SessionEnd runtime limit`,
1368
+ );
1369
+ }
1370
+ });
1371
+
1372
+
1356
1373
  test('shipped hooks.json keeps only nonblocking policy handlers and lifecycle hooks', () => {
1357
1374
  const shippedHooksConfig = JSON.parse(
1358
1375
  readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
@@ -0,0 +1,79 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { createInterface } from 'node:readline';
7
+ import test from 'node:test';
8
+ import { verifyInstallation } from './pstack.mjs';
9
+
10
+ const executable = process.env.CDE_CODEX_EXECUTABLE;
11
+ const project = process.env.CDE_PSTACK_PROOF_PROJECT;
12
+
13
+ test('native Codex discovers every installed pstack skill and its real entry path', {
14
+ skip: !executable || !project ? 'Set CDE_CODEX_EXECUTABLE and CDE_PSTACK_PROOF_PROJECT for native discovery' : false,
15
+ timeout: 60000,
16
+ }, async t => {
17
+ const installation = verifyInstallation({ project });
18
+ const release = join(resolve(project), '.claude', 'pstack', 'releases', installation.release);
19
+ const manifest = JSON.parse(readFileSync(join(release, 'release.json'), 'utf8'));
20
+ const home = mkdtempSync(join(tmpdir(), 'pstack codex home '));
21
+ const codexHome = join(home, '.codex');
22
+ mkdirSync(codexHome);
23
+ const env = { PATH: process.env.PATH, HOME: home, CODEX_HOME: codexHome };
24
+ const version = spawnSync(executable, ['--version'], { env, encoding: 'utf8', timeout: 15000 });
25
+ assert.equal(version.status, 0, version.stderr || version.error?.message);
26
+ const child = spawn(executable, ['app-server'], { cwd: project, env, stdio: ['pipe', 'pipe', 'pipe'] });
27
+ const lines = createInterface({ input: child.stdout });
28
+ let stderr = '';
29
+ child.stderr.on('data', chunk => { stderr += chunk; });
30
+ child.stdin.on('error', () => {});
31
+ t.after(async () => {
32
+ lines.close();
33
+ const stopped = child.exitCode !== null || child.signalCode !== null
34
+ ? Promise.resolve() : new Promise(accept => child.once('close', accept));
35
+ child.kill();
36
+ await stopped;
37
+ rmSync(home, { recursive: true, force: true });
38
+ });
39
+ const transcript = [];
40
+ const send = message => child.stdin.write(JSON.stringify(message) + '\n');
41
+ const request = (id, method, params) => new Promise((accept, reject) => {
42
+ const timer = setTimeout(() => finish(new Error(`Codex ${method} timed out: ${stderr}`)), 15000);
43
+ const failed = error => finish(error);
44
+ const closed = code => finish(new Error(`Codex exited ${code}: ${stderr}`));
45
+ const receive = line => {
46
+ let message;
47
+ try { message = JSON.parse(line); } catch (error) { finish(error); return; }
48
+ transcript.push(message);
49
+ if (message.id === id) finish(message.error ? new Error(JSON.stringify(message.error)) : null, message.result);
50
+ };
51
+ const finish = (error, response) => {
52
+ clearTimeout(timer);
53
+ child.off('error', failed);
54
+ child.off('close', closed);
55
+ lines.off('line', receive);
56
+ if (error) reject(error); else accept(response);
57
+ };
58
+ child.once('error', failed);
59
+ child.once('close', closed);
60
+ lines.on('line', receive);
61
+ send({ id, method, params });
62
+ });
63
+ await request(0, 'initialize', { clientInfo: { name: 'skillcheck', version: '1.0.0' } });
64
+ send({ method: 'initialized', params: {} });
65
+ const discovered = await request(1, 'skills/list', { cwds: [resolve(project)], forceReload: true });
66
+ const evidence = { verification: 'native-codex-skill-discovery', codexVersion: version.stdout.trim(),
67
+ upstreamCommit: installation.commit, adapterDigest: installation.adapterDigest, transcript };
68
+ if (process.env.CDE_PSTACK_DISCOVERY_REPORT) writeFileSync(process.env.CDE_PSTACK_DISCOVERY_REPORT, JSON.stringify(evidence, null, 2) + '\n');
69
+ const workspace = discovered.data.find(entry => resolve(entry.cwd) === resolve(project));
70
+ assert.ok(workspace, 'Codex returned the requested workspace');
71
+ assert.deepEqual(workspace.errors, []);
72
+ for (const expected of manifest.skills) {
73
+ const found = workspace.skills.filter(skill => skill.name === expected.name);
74
+ assert.equal(found.length, 1, `Codex discovers ${expected.name} once`);
75
+ assert.equal(found[0].enabled, true, expected.name);
76
+ assert.equal(realpathSync(found[0].path), realpathSync(join(release, expected.path, 'SKILL.md')));
77
+ }
78
+ t.diagnostic(`${version.stdout.trim()}: ${manifest.skills.length} installed skills discovered through Codex`);
79
+ });
package/bin/pstack.mjs ADDED
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { execFileSync, spawnSync } from 'node:child_process';
5
+ import { homedir } from 'node:os';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const bundledLockPath = join(packageRoot, 'scripts', 'pstack.lock.json');
11
+ const hosts = ['claude', 'codex', 'cursor'];
12
+ const digest = value => createHash('sha256').update(value).digest('hex');
13
+ const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
14
+ const json = value => JSON.stringify(value, null, 2) + '\n';
15
+ const entryExists = path => { try { lstatSync(path); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } };
16
+
17
+ export function validateLock(lock) {
18
+ if (lock?.schemaVersion !== 1 || lock?.adapterVersion !== 1
19
+ || lock.repository !== 'https://github.com/cursor/plugins.git'
20
+ || !/^[0-9a-f]{40}$/.test(lock.commit ?? '')
21
+ || JSON.stringify(lock.components) !== JSON.stringify(['pstack', 'cursor-team-kit'])) {
22
+ throw new Error('Unsupported pstack lock. Update claude-dev-env for a newer adapter.');
23
+ }
24
+ const required = { pstack: ['poteto-mode'], 'cursor-team-kit': ['deslop', 'control-cli', 'control-ui'] };
25
+ for (const [component, names] of Object.entries(required)) {
26
+ const declared = lock.requiredSkills?.[component];
27
+ if (!Array.isArray(declared) || declared.some(name => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
28
+ || names.some(name => !declared.includes(name))) throw new Error(`Invalid ${component} dependencies`);
29
+ }
30
+ return lock;
31
+ }
32
+
33
+ function filesUnder(root) {
34
+ return readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap(entry => {
35
+ const path = join(root, entry.name);
36
+ if (entry.isSymbolicLink()) throw new Error(`Upstream symlinks require review: ${path}`);
37
+ if (entry.isDirectory()) return filesUnder(path);
38
+ if (!entry.isFile()) throw new Error(`Unsupported upstream file: ${path}`);
39
+ return [path];
40
+ });
41
+ }
42
+
43
+ function runGit(args, cwd) {
44
+ return execFileSync('git', args, {
45
+ cwd, encoding: 'utf8', timeout: 40000, maxBuffer: 16 * 1024 * 1024,
46
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ }).trim();
49
+ }
50
+
51
+ export function fetchUpstream(lock, destination) {
52
+ mkdirSync(destination, { recursive: true });
53
+ runGit(['init', '--quiet'], destination);
54
+ runGit(['config', 'core.autocrlf', 'false'], destination);
55
+ runGit(['config', 'core.hooksPath', join(destination, '.disabled-hooks')], destination);
56
+ runGit(['remote', 'add', 'origin', lock.repository], destination);
57
+ runGit(['config', 'core.sparseCheckout', 'true'], destination);
58
+ writeFileSync(join(destination, '.git', 'info', 'sparse-checkout'), '/pstack/\n/cursor-team-kit/\n/LICENSE\n');
59
+ runGit(['fetch', '--quiet', '--depth=1', '--filter=blob:none', 'origin', lock.commit], destination);
60
+ if (runGit(['rev-parse', 'FETCH_HEAD'], destination) !== lock.commit) throw new Error('Upstream commit mismatch');
61
+ runGit(['checkout', '--quiet', '--detach', 'FETCH_HEAD'], destination);
62
+ }
63
+
64
+ function adapterFiles(root) {
65
+ const adapters = join(root, 'scripts', 'pstack-adapters');
66
+ return Object.fromEntries([
67
+ ...['common.md', ...hosts.map(host => `host-${host}.md`), 'create-skill.md'].map(name => [name, join(adapters, name)]),
68
+ ...['pstack-host-mapping.md', 'pstack-models.md'].map(name => [name, join(root, 'rules', name)]),
69
+ ['select_pstack_models.mjs', join(root, 'scripts', 'select_pstack_models.mjs')],
70
+ ].map(([name, path]) => [name, readFileSync(path, 'utf8')]));
71
+ }
72
+
73
+ function adaptSkill(text, name, releaseRoot, skillRoot) {
74
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
75
+ if (!match || !/^description:\s*\S/m.test(match[1])) throw new Error(`Missing skill metadata: ${name}`);
76
+ const frontmatter = match[1].replace(/^name:.*$/m, `name: ${name}`);
77
+ if (!/^name:/m.test(frontmatter)) throw new Error(`Missing skill name: ${name}`);
78
+ const safeFrontmatter = frontmatter.replace(/^(?:context|agent|model):.*\r?\n?/gm, '');
79
+ const fork = /^context:\s*fork\s*$/m.test(frontmatter)
80
+ ? 'The upstream entry requests a fork. Delegate through the native host after reading the compatibility mapping.\n' : '';
81
+ return `---\n${safeFrontmatter}\n---\n\n## Host compatibility\n\n`
82
+ + `Read ${JSON.stringify(join(releaseRoot, 'compat', 'common.md'))} and the matching host file before following this workflow.\n`
83
+ + `Resolve this skill's relative paths from ${JSON.stringify(skillRoot)}. Read ${JSON.stringify(join(releaseRoot, 'release.json'))} for skill and agent paths.\n`
84
+ + fork + '\n' + match[2].trimStart();
85
+ }
86
+
87
+ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
88
+ const skills = [];
89
+ const agents = [];
90
+ mkdirSync(join(stage, 'compat'), { recursive: true });
91
+ for (const [name, content] of Object.entries(adapters)) writeFileSync(join(stage, 'compat', name), content);
92
+ for (const component of lock.components) {
93
+ const source = join(checkout, component);
94
+ filesUnder(source);
95
+ cpSync(source, join(stage, 'upstream', component), { recursive: true });
96
+ cpSync(source, join(stage, 'runtime', component), { recursive: true });
97
+ rmSync(join(stage, 'runtime', component, '.cursor-plugin', 'plugin.json'), { force: true });
98
+ const skillHome = join(source, 'skills');
99
+ const names = readdirSync(skillHome).filter(name => existsSync(join(skillHome, name, 'SKILL.md'))).sort();
100
+ for (const name of lock.requiredSkills[component]) {
101
+ if (!names.includes(name)) throw new Error(`Missing dependency: ${component}:${name}`);
102
+ }
103
+ for (const slug of names) {
104
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error(`Invalid skill directory: ${slug}`);
105
+ const name = `${component}-${slug}`;
106
+ const path = join('runtime', component, 'skills', slug);
107
+ const text = readFileSync(join(source, 'skills', slug, 'SKILL.md'), 'utf8');
108
+ writeFileSync(join(stage, path, 'SKILL.md'), adaptSkill(text, name, finalRoot, join(finalRoot, path)));
109
+ skills.push({ name, component, slug, path });
110
+ }
111
+ const agentHome = join(source, 'agents');
112
+ if (existsSync(agentHome)) {
113
+ for (const path of filesUnder(agentHome)) agents.push({ component, path: join('runtime', component, 'agents', relative(agentHome, path)) });
114
+ }
115
+ }
116
+ if (existsSync(join(checkout, 'LICENSE'))) cpSync(join(checkout, 'LICENSE'), join(stage, 'upstream', 'LICENSE'));
117
+ const creatorPath = join('runtime', 'cde-create-skill');
118
+ mkdirSync(join(stage, creatorPath), { recursive: true });
119
+ writeFileSync(join(stage, creatorPath, 'SKILL.md'), adapters['create-skill.md']);
120
+ skills.push({ name: 'cde-create-skill', component: 'cde', slug: 'create-skill', path: creatorPath });
121
+ const knownNames = new Set(skills.map(skill => `${skill.component}:${skill.slug}`));
122
+ for (const path of filesUnder(join(stage, 'upstream')).filter(path => path.endsWith('.md') && (path.includes(sep + 'skills' + sep) || path.includes(sep + 'agents' + sep)))) {
123
+ const references = readFileSync(path, 'utf8').matchAll(/\b(pstack|cursor-team-kit):([a-z][a-z0-9-]*)/g);
124
+ for (const [, component, slug] of references) {
125
+ if (!knownNames.has(`${component}:${slug}`)) throw new Error(`Unresolved skill dependency: ${component}:${slug}`);
126
+ }
127
+ }
128
+ const fileDigests = Object.fromEntries(filesUnder(stage).map(path => [relative(stage, path), digest(readFileSync(path))]));
129
+ const managedRoot = dirname(dirname(dirname(finalRoot)));
130
+ const agentsRoot = basename(managedRoot) === '.claude' ? join(dirname(managedRoot), '.agents') : managedRoot + '.agents';
131
+ const release = { preferencesDirectory: join(agentsRoot, 'rules'), selectorPath: join(finalRoot, 'compat', 'select_pstack_models.mjs'), schemaVersion: 1, commit: lock.commit, adapterVersion: lock.adapterVersion, skills, agents, fileDigests, verification: 'filesystem-only' };
132
+ writeFileSync(join(stage, 'release.json'), json(release));
133
+ return release;
134
+ }
135
+
136
+ function assertContained(root, path) {
137
+ const rel = relative(root, resolve(root, path));
138
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep) || resolve(root, path) === resolve(root)) throw new Error('Invalid release path');
139
+ return resolve(root, path);
140
+ }
141
+
142
+ export function verifyRelease(root) {
143
+ const release = readJson(join(root, 'release.json'));
144
+ if (release.schemaVersion !== 1 || !Array.isArray(release.skills) || !release.skills.length || !release.fileDigests) throw new Error('Invalid release manifest');
145
+ for (const [path, hash] of Object.entries(release.fileDigests)) {
146
+ if (digest(readFileSync(assertContained(root, path))) !== hash) throw new Error(`Changed installed file: ${path}`);
147
+ }
148
+ for (const skill of release.skills) {
149
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skill.name)) throw new Error('Invalid installed skill name');
150
+ const path = join(assertContained(root, skill.path), 'SKILL.md');
151
+ if (!Object.hasOwn(release.fileDigests, relative(root, path)) || !existsSync(path)) throw new Error(`Missing entry: ${skill.name}`);
152
+ }
153
+ return release;
154
+ }
155
+
156
+ function layout(options) {
157
+ const root = resolve(options.root ?? (options.project ? join(options.project, '.claude') : process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')));
158
+ const agents = basename(root) === '.claude' ? join(dirname(root), '.agents') : root + '.agents';
159
+ return { root, store: join(root, 'pstack'), skillHomes: [join(root, 'skills'), join(agents, 'skills')] };
160
+ }
161
+
162
+ function loadState(store) {
163
+ const path = join(store, 'installed.json');
164
+ return existsSync(path) ? readJson(path) : null;
165
+ }
166
+
167
+ export function verifyInstallation(options = {}) {
168
+ const { store } = layout(options);
169
+ const state = loadState(store);
170
+ if (!state) throw new Error('Pstack is not installed');
171
+ const release = verifyRelease(assertContained(join(store, 'releases'), state.release));
172
+ for (const [path, target] of Object.entries(state.links)) {
173
+ if (!lstatSync(path).isSymbolicLink() || realpathSync(path) !== realpathSync(target)) throw new Error(`Changed skill pointer: ${path}`);
174
+ }
175
+ return { ...state, skillCount: release.skills.length, verification: release.verification };
176
+ }
177
+
178
+ function publish(releaseRoot, release, homes, prior) {
179
+ const uniqueHomes = [...new Set(homes.map(home => { mkdirSync(home, { recursive: true }); return realpathSync(home); }))];
180
+ const links = Object.fromEntries(uniqueHomes.flatMap(home => release.skills.map(skill => [join(home, skill.name), join(releaseRoot, skill.path)])));
181
+ const paths = new Set([...Object.keys(prior?.links ?? {}), ...Object.keys(links)]);
182
+ const before = new Map();
183
+ for (const path of paths) {
184
+ if (entryExists(path)) {
185
+ if (!lstatSync(path).isSymbolicLink() || !prior?.links[path]
186
+ || resolve(dirname(path), readlinkSync(path)) !== resolve(prior.links[path])) {
187
+ throw new Error(`Keep existing unmanaged path: ${path}`);
188
+ }
189
+ before.set(path, readlinkSync(path));
190
+ } else before.set(path, null);
191
+ }
192
+ const restore = () => {
193
+ for (const [path, target] of before) {
194
+ if (entryExists(path)) unlinkSync(path);
195
+ if (target !== null) symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir');
196
+ }
197
+ };
198
+ try {
199
+ for (const path of paths) {
200
+ if (entryExists(path)) unlinkSync(path);
201
+ if (links[path]) symlinkSync(links[path], path, process.platform === 'win32' ? 'junction' : 'dir');
202
+ }
203
+ } catch (error) { restore(); throw error; }
204
+ return { links, restore };
205
+ }
206
+
207
+ function liveLeases(store) {
208
+ const root = join(store, 'sessions');
209
+ if (!existsSync(root)) return false;
210
+ return readdirSync(root).some(name => {
211
+ const pid = Number(name);
212
+ if (!Number.isSafeInteger(pid) || pid < 1) throw new Error('Invalid session lease');
213
+ try { process.kill(pid, 0); return true; } catch (error) {
214
+ if (error.code !== 'ESRCH') return true;
215
+ unlinkSync(join(root, name));
216
+ return false;
217
+ }
218
+ });
219
+ }
220
+
221
+ function readCentralLock(temporaryRoot) {
222
+ const repository = join(temporaryRoot, 'record');
223
+ mkdirSync(repository);
224
+ runGit(['init', '--quiet'], repository);
225
+ runGit(['fetch', '--quiet', '--depth=1', '--filter=blob:none', 'https://github.com/jl-cmd/claude-dev-env.git', 'main'], repository);
226
+ return JSON.parse(runGit(['show', 'FETCH_HEAD:packages/claude-dev-env/scripts/pstack.lock.json'], repository));
227
+ }
228
+
229
+ export function installPstack(options = {}, dependencies = {}) {
230
+ const { store, skillHomes } = layout(options);
231
+ mkdirSync(store, { recursive: true });
232
+ const lockDirectory = join(store, '.install-lock');
233
+ mkdirSync(lockDirectory);
234
+ let temporaryRoot;
235
+ const finish = installation => {
236
+ if (options.reserveSession) {
237
+ const sessions = join(store, 'sessions');
238
+ mkdirSync(sessions, { recursive: true });
239
+ writeFileSync(join(sessions, String(process.pid)), installation.release);
240
+ }
241
+ return installation;
242
+ };
243
+ try {
244
+ const prior = loadState(store);
245
+ if ((options.offline || liveLeases(store)) && prior) return finish({ ...verifyInstallation(options), status: 'retained' });
246
+ if (options.offline) throw new Error('Offline install needs an existing verified release');
247
+ temporaryRoot = mkdtempSync(join(store, '.prepare-'));
248
+ const now = dependencies.now ?? Date.now();
249
+ const interval = options.intervalMs ?? 3600000;
250
+ if (!Number.isFinite(interval) || interval < 0) throw new Error('Invalid update interval');
251
+ const bundled = options.lock ?? prior?.lock ?? readJson(bundledLockPath);
252
+ let lock = bundled;
253
+ let checkedAt = prior?.checkedAt ?? 0;
254
+ if (options.refresh && (options.force || now - checkedAt >= interval)) {
255
+ lock = (dependencies.readCentralLock ?? readCentralLock)(temporaryRoot);
256
+ checkedAt = now;
257
+ } else if (options.refresh && prior?.lock) lock = prior.lock;
258
+ validateLock(lock);
259
+ const adapters = adapterFiles(options.packageRoot ?? packageRoot);
260
+ const installerSource = readFileSync(fileURLToPath(import.meta.url), 'utf8').replace(/\r\n/g, '\n');
261
+ const adapterDigest = digest(json(adapters) + installerSource);
262
+ const id = `${lock.commit}-${adapterDigest.slice(0, 16)}`;
263
+ const releaseRoot = join(store, 'releases', id);
264
+ mkdirSync(dirname(releaseRoot), { recursive: true });
265
+ if (!existsSync(releaseRoot)) {
266
+ const checkout = join(temporaryRoot, 'source');
267
+ (dependencies.fetchSource ?? fetchUpstream)(lock, checkout);
268
+ const stage = join(temporaryRoot, 'release');
269
+ prepareRelease(checkout, stage, releaseRoot, lock, adapters);
270
+ verifyRelease(stage);
271
+ renameSync(stage, releaseRoot);
272
+ }
273
+ const release = verifyRelease(releaseRoot);
274
+ const publication = publish(releaseRoot, release, skillHomes, prior);
275
+ const state = { release: id, commit: lock.commit, adapterVersion: lock.adapterVersion, adapterDigest, checkedAt, lock, links: publication.links };
276
+ try {
277
+ writeFileSync(join(store, 'installed.next.json'), json(state));
278
+ renameSync(join(store, 'installed.next.json'), join(store, 'installed.json'));
279
+ } catch (error) { publication.restore(); throw error; }
280
+ return finish({ ...verifyInstallation(options), status: prior?.release === id ? 'unchanged' : 'installed' });
281
+ } catch (error) {
282
+ if (!options.strict) {
283
+ try { return finish({ ...verifyInstallation(options), status: 'retained', warning: error.message }); } catch { }
284
+ }
285
+ throw error;
286
+ } finally {
287
+ if (temporaryRoot) rmSync(temporaryRoot, { recursive: true, force: true });
288
+ rmSync(lockDirectory, { recursive: true, force: true });
289
+ }
290
+ }
291
+
292
+ function parseArguments(args) {
293
+ const options = {};
294
+ const tailIndex = args.indexOf('--');
295
+ const tokens = tailIndex < 0 ? args : args.slice(0, tailIndex);
296
+ const forwarded = tailIndex < 0 ? [] : args.slice(tailIndex + 1);
297
+ for (let index = 0; index < tokens.length; index++) {
298
+ const flag = tokens[index];
299
+ if (['--offline', '--refresh', '--force', '--strict'].includes(flag)) options[flag.slice(2)] = true;
300
+ else if (['--root', '--project', '--host', '--lock', '--interval-ms'].includes(flag)) {
301
+ const argument = tokens[++index];
302
+ if (!argument || argument.startsWith('--')) throw new Error(`${flag} requires a value`);
303
+ if (flag === '--lock') options.lock = readJson(resolve(argument));
304
+ else if (flag === '--interval-ms') options.intervalMs = Number(argument);
305
+ else options[flag.slice(2)] = argument;
306
+ } else throw new Error(`Unknown option: ${flag}`);
307
+ }
308
+ if (options.host && !hosts.includes(options.host)) throw new Error('Host must be claude, codex, or cursor');
309
+ if (options.root && options.project) throw new Error('Choose --root or --project');
310
+ return { options, forwarded };
311
+ }
312
+
313
+ export function main(args = process.argv.slice(2)) {
314
+ try {
315
+ const [command = 'help', ...rest] = args;
316
+ if (['help', '--help', '-h'].includes(command)) {
317
+ console.log('Usage: cde-pstack <install|verify|launch|hook> [--root PATH|--project PATH] [--refresh] [--offline] [--strict] [--lock PATH] [--host claude|codex|cursor] [-- agent arguments]');
318
+ return 0;
319
+ }
320
+ const { options, forwarded } = parseArguments(rest);
321
+ if (!['install', 'verify', 'launch', 'hook'].includes(command)) throw new Error(`Unknown command: ${command}`);
322
+ let hookInput;
323
+ if (command === 'hook') {
324
+ hookInput = JSON.parse(readFileSync(0, 'utf8') || '{}');
325
+ options.project ??= process.env.CLAUDE_PROJECT_DIR || process.cwd();
326
+ options.offline = hookInput.source !== 'startup';
327
+ options.refresh = !options.offline && loadState(layout(options).store) !== null;
328
+ }
329
+ if (command === 'launch' && !options.host) throw new Error('launch requires --host');
330
+ if (command === 'launch') { options.refresh = !options.offline && !options.lock; options.reserveSession = true; }
331
+ const installation = command === 'verify' ? verifyInstallation(options) : installPstack(options);
332
+ if (installation.warning) console.error(`Pstack update failed; keeping ${installation.commit}: ${installation.warning}`);
333
+ if (command === 'hook') {
334
+ const { store } = layout(options);
335
+ const release = join(store, 'releases', installation.release);
336
+ console.log(json({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `Pstack ${installation.commit} is installed. Use the immutable release recorded by session-continuity or the loaded skill entry for this session. Read common.md and host-claude.md in that release's compat directory. New sessions can use the installed inventory at ${join(release, 'release.json')}. ${installation.warning ?? ''}` } }));
337
+ } else if (command === 'launch') {
338
+ const sessions = join(layout(options).store, 'sessions');
339
+ const lease = join(sessions, String(process.pid));
340
+ try {
341
+ const child = spawnSync(options.host, forwarded, { stdio: 'inherit', shell: false, env: { ...process.env, CDE_PSTACK_RELEASE: join(layout(options).store, 'releases', installation.release) } });
342
+ if (child.error) throw child.error;
343
+ return child.status ?? 1;
344
+ } finally { unlinkSync(lease); }
345
+ } else console.log(json(installation));
346
+ return 0;
347
+ } catch (error) { console.error(`Pstack: ${error.message}`); return 1; }
348
+ }
349
+
350
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main();
@@ -0,0 +1,289 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { installPstack, prepareRelease, validateLock, verifyInstallation, verifyRelease } from './pstack.mjs';
9
+
10
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const baseLock = JSON.parse(readFileSync(join(packageRoot, 'scripts', 'pstack.lock.json'), 'utf8'));
12
+
13
+ function put(path, content) {
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ writeFileSync(path, content);
16
+ }
17
+
18
+ function fixture(t) {
19
+ const temporary = mkdtempSync(join(tmpdir(), 'cde-pstack-'));
20
+ t.after(() => rmSync(temporary, { recursive: true, force: true }));
21
+ const checkout = join(temporary, 'checkout');
22
+ for (const [component, names] of Object.entries(baseLock.requiredSkills)) {
23
+ for (const name of names) put(join(checkout, component, 'skills', name, 'SKILL.md'), `---\nname: ${name}\ndescription: Test ${name}.\n---\n\nRun ${name}.\n`);
24
+ put(join(checkout, component, 'LICENSE'), `${component} license\n`);
25
+ put(join(checkout, component, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: component, skills: './skills' }));
26
+ }
27
+ put(join(checkout, 'pstack', 'skills', 'poteto-mode', 'playbooks', 'feature.md'), 'Build a small task, delegate, and verify it.\n');
28
+ put(join(checkout, 'pstack', 'agents', 'poteto-agent.md'), 'Read poteto-mode and report complete findings.\n');
29
+ put(join(checkout, 'pstack', 'scripts', 'proof.sh'), '#!/bin/sh\nprintf proof\n');
30
+ chmodSync(join(checkout, 'pstack', 'scripts', 'proof.sh'), 0o755);
31
+ put(join(checkout, 'pstack', 'assets', 'example.png'), Buffer.from([137, 80, 78, 71, 0, 255]));
32
+ const project = join(temporary, 'project with spaces');
33
+ const options = { project, lock: structuredClone(baseLock), packageRoot };
34
+ const dependencies = { fetchSource: (_lock, destination) => cpSync(checkout, destination, { recursive: true }), now: 10000000 };
35
+ return { temporary, checkout, options, dependencies, store: join(project, '.claude', 'pstack') };
36
+ }
37
+
38
+ for (const host of ['claude', 'codex', 'cursor']) {
39
+ test(`clean ${host} filesystem install publishes all entry points and supporting files`, t => {
40
+ const f = fixture(t);
41
+ const result = installPstack({ ...f.options, host }, f.dependencies);
42
+ assert.equal(result.status, 'installed');
43
+ assert.equal(result.skillCount, 5);
44
+ assert.equal(result.commit, '27e2a62ff94f9af4b5e68435e41cdceacadb840c');
45
+ assert.equal(result.verification, 'filesystem-only');
46
+ const release = join(f.store, 'releases', result.release);
47
+ const expected = ['cde-create-skill', 'cursor-team-kit-control-cli', 'cursor-team-kit-control-ui', 'cursor-team-kit-deslop', 'pstack-poteto-mode'];
48
+ for (const home of ['.claude', '.agents']) {
49
+ assert.deepEqual(readdirSync(join(f.options.project, home, 'skills')).sort(), expected);
50
+ const entry = join(f.options.project, home, 'skills', 'pstack-poteto-mode');
51
+ assert.equal(realpathSync(entry), join(release, 'runtime', 'pstack', 'skills', 'poteto-mode'));
52
+ const text = readFileSync(join(entry, 'SKILL.md'), 'utf8');
53
+ assert.match(text, /name: pstack-poteto-mode/);
54
+ assert.ok(text.includes(JSON.stringify(join(release, 'compat', 'common.md'))));
55
+ assert.equal(readFileSync(join(entry, 'playbooks', 'feature.md'), 'utf8'), 'Build a small task, delegate, and verify it.\n');
56
+ }
57
+ assert.equal(existsSync(join(f.options.project, '.cursor')), false);
58
+ for (const component of baseLock.components) {
59
+ assert.equal(existsSync(join(release, 'runtime', component, '.cursor-plugin', 'plugin.json')), false);
60
+ assert.equal(readFileSync(join(release, 'upstream', component, '.cursor-plugin', 'plugin.json'), 'utf8'),
61
+ readFileSync(join(f.checkout, component, '.cursor-plugin', 'plugin.json'), 'utf8'));
62
+ }
63
+ assert.equal(readFileSync(join(release, 'upstream', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8'), readFileSync(join(f.checkout, 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8'));
64
+ assert.deepEqual(readFileSync(join(release, 'upstream', 'pstack', 'assets', 'example.png')), Buffer.from([137, 80, 78, 71, 0, 255]));
65
+ assert.equal(readFileSync(join(release, 'runtime', 'pstack', 'agents', 'poteto-agent.md'), 'utf8'), 'Read poteto-mode and report complete findings.\n');
66
+ assert.equal(readFileSync(join(release, 'upstream', 'cursor-team-kit', 'LICENSE'), 'utf8'), 'cursor-team-kit license\n');
67
+ if (process.platform !== 'win32') assert.equal(statSync(join(release, 'runtime', 'pstack', 'scripts', 'proof.sh')).mode & 0o777, 0o755);
68
+ });
69
+ }
70
+
71
+ test('unchanged install avoids another upstream fetch', t => {
72
+ const f = fixture(t);
73
+ const first = installPstack(f.options, f.dependencies);
74
+ const second = installPstack(f.options, { fetchSource: () => assert.fail('unexpected fetch') });
75
+ assert.equal(second.status, 'unchanged');
76
+ assert.equal(second.release, first.release);
77
+ assert.deepEqual(second.links, first.links);
78
+ });
79
+
80
+ test('changed, added, and removed skills converge while old releases remain readable', t => {
81
+ const f = fixture(t);
82
+ put(join(f.checkout, 'pstack', 'skills', 'old', 'SKILL.md'), '---\nname: old\ndescription: Old skill.\n---\nOld body.\n');
83
+ const before = installPstack(f.options, f.dependencies);
84
+ const oldRelease = join(f.store, 'releases', before.release);
85
+ const firstBody = readFileSync(join(oldRelease, 'runtime', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8');
86
+ rmSync(join(f.checkout, 'pstack', 'skills', 'old'), { recursive: true });
87
+ put(join(f.checkout, 'pstack', 'skills', 'new', 'SKILL.md'), '---\nname: new\ndescription: New skill.\n---\nNew body.\n');
88
+ put(join(f.checkout, 'pstack', 'skills', 'poteto-mode', 'playbooks', 'feature.md'), 'Changed playbook.\n');
89
+ const after = installPstack({ ...f.options, lock: { ...baseLock, commit: 'b'.repeat(40) } }, f.dependencies);
90
+ assert.equal(after.skillCount, 6);
91
+ for (const home of ['.claude', '.agents']) {
92
+ const path = join(f.options.project, home, 'skills');
93
+ assert.equal(existsSync(join(path, 'pstack-old')), false);
94
+ assert.equal(readFileSync(join(path, 'pstack-new', 'SKILL.md'), 'utf8').endsWith('New body.\n'), true);
95
+ assert.equal(readFileSync(join(path, 'pstack-poteto-mode', 'playbooks', 'feature.md'), 'utf8'), 'Changed playbook.\n');
96
+ }
97
+ assert.equal(readFileSync(join(oldRelease, 'runtime', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8'), firstBody);
98
+ });
99
+
100
+ test('missing required dependency retains the previous complete installation', t => {
101
+ const f = fixture(t);
102
+ const first = installPstack(f.options, f.dependencies);
103
+ const priorBytes = readFileSync(join(f.store, 'installed.json'), 'utf8');
104
+ rmSync(join(f.checkout, 'cursor-team-kit', 'skills', 'control-ui'), { recursive: true });
105
+ const result = installPstack({ ...f.options, lock: { ...baseLock, commit: 'c'.repeat(40) } }, f.dependencies);
106
+ assert.equal(result.status, 'retained');
107
+ assert.match(result.warning, /Missing dependency: cursor-team-kit:control-ui/);
108
+ assert.equal(result.release, first.release);
109
+ assert.equal(readFileSync(join(f.store, 'installed.json'), 'utf8'), priorBytes);
110
+ assert.equal(verifyInstallation(f.options).skillCount, 5);
111
+ });
112
+
113
+ test('a failed first install returns failure and publishes no skills', t => {
114
+ const f = fixture(t);
115
+ assert.throws(() => installPstack(f.options, { fetchSource: () => { throw new Error('network unavailable'); } }), /network unavailable/);
116
+ assert.equal(existsSync(join(f.options.project, '.claude', 'skills')), false);
117
+ assert.equal(existsSync(join(f.store, '.install-lock')), false);
118
+ });
119
+
120
+ test('unmanaged skill collision preserves user files', t => {
121
+ const f = fixture(t);
122
+ const userFile = join(f.options.project, '.claude', 'skills', 'pstack-poteto-mode', 'SKILL.md');
123
+ put(userFile, 'Personal workflow\n');
124
+ assert.throws(() => installPstack(f.options, f.dependencies), /Keep existing unmanaged path/);
125
+ assert.equal(readFileSync(userFile, 'utf8'), 'Personal workflow\n');
126
+ assert.equal(existsSync(join(f.store, 'installed.json')), false);
127
+ });
128
+
129
+ test('existing shared skills-directory pointer is preserved', t => {
130
+ const f = fixture(t);
131
+ const agents = join(f.options.project, '.agents', 'skills');
132
+ mkdirSync(agents, { recursive: true });
133
+ mkdirSync(join(f.options.project, '.claude'), { recursive: true });
134
+ symlinkSync(agents, join(f.options.project, '.claude', 'skills'), process.platform === 'win32' ? 'junction' : 'dir');
135
+ const before = readlinkSync(join(f.options.project, '.claude', 'skills'));
136
+ const result = installPstack(f.options, f.dependencies);
137
+ assert.equal(Object.keys(result.links).length, 5);
138
+ assert.equal(readlinkSync(join(f.options.project, '.claude', 'skills')), before);
139
+ });
140
+
141
+ test('offline reuse checks installed bytes and rejects corruption', t => {
142
+ const f = fixture(t);
143
+ const result = installPstack(f.options, f.dependencies);
144
+ assert.equal(installPstack({ ...f.options, offline: true }).release, result.release);
145
+ put(join(f.store, 'releases', result.release, 'runtime', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'corrupted');
146
+ assert.throws(() => installPstack({ ...f.options, offline: true }), /Changed installed file/);
147
+ });
148
+
149
+ test('unknown namespaced upstream dependency requires adapter review', t => {
150
+ const f = fixture(t);
151
+ put(join(f.checkout, 'pstack', 'skills', 'poteto-mode', 'reference.md'), 'Invoke cursor-team-kit:missing-skill.\n');
152
+ assert.throws(() => installPstack(f.options, f.dependencies), /Unresolved skill dependency/);
153
+ });
154
+
155
+ test('source symlinks are rejected before publication', t => {
156
+ const f = fixture(t);
157
+ const outside = join(f.temporary, 'outside-source');
158
+ mkdirSync(outside);
159
+ put(join(outside, 'personal.txt'), 'Retain this file.');
160
+ const dependencies = { ...f.dependencies, fetchSource: (lock, destination) => {
161
+ f.dependencies.fetchSource(lock, destination);
162
+ const link = join(destination, 'pstack', 'escape');
163
+ symlinkSync(outside, link, process.platform === 'win32' ? 'junction' : 'dir');
164
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
165
+ } };
166
+ assert.throws(() => installPstack(f.options, dependencies), /symlinks require review/);
167
+ assert.equal(readFileSync(join(outside, 'personal.txt'), 'utf8'), 'Retain this file.');
168
+ });
169
+
170
+ test('update checks use the shared record and respect the interval', t => {
171
+ const f = fixture(t);
172
+ const shared = { ...baseLock, commit: 'd'.repeat(40) };
173
+ let calls = 0;
174
+ const dependencies = { ...f.dependencies, readCentralLock: () => { calls++; return shared; } };
175
+ const first = installPstack({ ...f.options, refresh: true }, dependencies);
176
+ assert.equal(first.commit, 'd'.repeat(40));
177
+ const second = installPstack({ ...f.options, refresh: true }, { ...dependencies, now: dependencies.now + 100 });
178
+ assert.equal(second.commit, 'd'.repeat(40));
179
+ assert.equal(calls, 1);
180
+ installPstack({ ...f.options, refresh: true }, { ...dependencies, now: dependencies.now + 3600001 });
181
+ assert.equal(calls, 2);
182
+ });
183
+
184
+ test('failed shared-record refresh retains and reports the previous revision', t => {
185
+ const f = fixture(t);
186
+ const first = installPstack(f.options, f.dependencies);
187
+ const result = installPstack({ ...f.options, refresh: true }, { ...f.dependencies, readCentralLock: () => { throw new Error('record unavailable'); } });
188
+ assert.equal(result.release, first.release);
189
+ assert.equal(result.warning, 'record unavailable');
190
+ });
191
+
192
+ test('an active launcher lease defers updating shared instructions', t => {
193
+ const f = fixture(t);
194
+ const first = installPstack(f.options, f.dependencies);
195
+ put(join(f.store, 'sessions', String(process.pid)), first.release);
196
+ const result = installPstack({ ...f.options, lock: { ...baseLock, commit: 'e'.repeat(40) } }, { fetchSource: () => assert.fail('updated during an active session') });
197
+ assert.equal(result.status, 'retained');
198
+ assert.equal(result.release, first.release);
199
+ });
200
+
201
+ test('unsupported lock input fails before source fetch', t => {
202
+ const f = fixture(t);
203
+ for (const change of [{ commit: 'main' }, { adapterVersion: 2 }, { repository: 'https://example.test/repo' }, { components: ['../outside'] }, { requiredSkills: {} }]) {
204
+ assert.throws(() => installPstack({ ...f.options, lock: { ...baseLock, ...change } }, { fetchSource: () => assert.fail('unexpected fetch') }));
205
+ }
206
+ assert.equal(validateLock(baseLock), baseLock);
207
+ });
208
+
209
+ test('CLI exposes deterministic verification and rejects unknown flags', t => {
210
+ const f = fixture(t);
211
+ installPstack(f.options, f.dependencies);
212
+ const command = join(packageRoot, 'bin', 'pstack.mjs');
213
+ const verified = spawnSync(process.execPath, [command, 'verify', '--project', f.options.project], { encoding: 'utf8' });
214
+ assert.equal(verified.status, 0, verified.stderr);
215
+ assert.equal(JSON.parse(verified.stdout).skillCount, 5);
216
+ const invalid = spawnSync(process.execPath, [command, 'install', '--host', 'unknown'], { encoding: 'utf8' });
217
+ assert.equal(invalid.status, 1);
218
+ assert.match(invalid.stderr, /Host must be/);
219
+ const help = spawnSync(process.execPath, [command, '--help'], { encoding: 'utf8' });
220
+ assert.equal(help.status, 0);
221
+ assert.match(help.stdout, /cde-pstack/);
222
+ });
223
+
224
+ test('resumed Claude hook retains the cached release and emits native hook JSON', t => {
225
+ const f = fixture(t);
226
+ const first = installPstack(f.options, f.dependencies);
227
+ const result = spawnSync(process.execPath, [join(packageRoot, 'bin', 'pstack.mjs'), 'hook', '--project', f.options.project], {
228
+ encoding: 'utf8', input: JSON.stringify({ source: 'resume' }),
229
+ });
230
+ assert.equal(result.status, 0, result.stderr);
231
+ const output = JSON.parse(result.stdout);
232
+ assert.equal(output.hookSpecificOutput.hookEventName, 'SessionStart');
233
+ assert.ok(output.hookSpecificOutput.additionalContext.includes(first.commit));
234
+ assert.ok(output.hookSpecificOutput.additionalContext.includes('common.md'));
235
+ });
236
+
237
+ test('release validation catches missing files and escaping manifest paths', t => {
238
+ const f = fixture(t);
239
+ const result = installPstack(f.options, f.dependencies);
240
+ const root = join(f.store, 'releases', result.release);
241
+ assert.equal(verifyRelease(root).skills.length, 5);
242
+ const manifest = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8'));
243
+ manifest.fileDigests['../../outside'] = 'bad';
244
+ put(join(root, 'release.json'), JSON.stringify(manifest));
245
+ assert.throws(() => verifyRelease(root), /Invalid release path/);
246
+ });
247
+
248
+ test('state-write failure restores the old published pointers', t => {
249
+ const f = fixture(t);
250
+ const first = installPstack(f.options, f.dependencies);
251
+ const priorBytes = readFileSync(join(f.store, 'installed.json'), 'utf8');
252
+ mkdirSync(join(f.store, 'installed.next.json'));
253
+ const result = installPstack({ ...f.options, lock: { ...baseLock, commit: 'f'.repeat(40) } }, f.dependencies);
254
+ assert.equal(result.status, 'retained');
255
+ assert.equal(result.release, first.release);
256
+ assert.equal(readFileSync(join(f.store, 'installed.json'), 'utf8'), priorBytes);
257
+ assert.equal(verifyInstallation(f.options).commit, first.commit);
258
+ });
259
+
260
+ test('npm-style linked CLI runs its entry point', t => {
261
+ const f = fixture(t);
262
+ if (process.platform === 'win32') return;
263
+ const link = join(f.temporary, 'cde-pstack');
264
+ symlinkSync(join(packageRoot, 'bin', 'pstack.mjs'), link);
265
+ const result = spawnSync(process.execPath, [link, '--help'], { encoding: 'utf8' });
266
+ assert.equal(result.status, 0);
267
+ assert.match(result.stdout, /Usage: cde-pstack/);
268
+ });
269
+
270
+ test('session reservation is recorded before install releases its lock', t => {
271
+ const f = fixture(t);
272
+ const result = installPstack({ ...f.options, reserveSession: true }, f.dependencies);
273
+ assert.equal(readFileSync(join(f.store, 'sessions', String(process.pid)), 'utf8'), result.release);
274
+ assert.equal(existsSync(join(f.store, '.install-lock')), false);
275
+ });
276
+
277
+
278
+ test('a changed installer creates a fresh generation for the same upstream pin', async t => {
279
+ const f = fixture(t);
280
+ const first = installPstack(f.options, f.dependencies);
281
+ const copied = join(f.temporary, 'updated-installer', 'bin', 'pstack.mjs');
282
+ put(copied, readFileSync(join(packageRoot, 'bin', 'pstack.mjs'), 'utf8') + '\n');
283
+ const updated = await import(pathToFileURL(copied).href);
284
+ const second = updated.installPstack(f.options, f.dependencies);
285
+ assert.equal(second.commit, first.commit);
286
+ assert.notEqual(second.adapterDigest, first.adapterDigest);
287
+ assert.notEqual(second.release, first.release);
288
+ assert.equal(verifyInstallation(f.options).release, second.release);
289
+ });
@@ -0,0 +1 @@
1
+ """Retired search hook entrypoint kept inert for stale command references."""
@@ -0,0 +1,46 @@
1
+ """Regression tests for the inert retired search hook entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
12
+ _ENTRYPOINT_PATH = _BLOCKING_DIRECTORY / "content_search_to_zoekt_redirector.py"
13
+ _HOOKS_MANIFEST_PATH = _BLOCKING_DIRECTORY.parent / "hooks.json"
14
+
15
+
16
+ def test_retired_entrypoint_allows_valid_search_events_silently() -> None:
17
+ all_search_events = (
18
+ {"tool_name": "Bash", "tool_input": {"command": "rg --files packages"}},
19
+ {
20
+ "tool_name": "Grep",
21
+ "tool_input": {"pattern": "zoekt", "path": "packages"},
22
+ },
23
+ {
24
+ "tool_name": "Search",
25
+ "tool_input": {"pattern": "zoekt", "path": "packages"},
26
+ },
27
+ )
28
+
29
+ for each_search_event in all_search_events:
30
+ completed_process = subprocess.run(
31
+ [sys.executable, str(_ENTRYPOINT_PATH)],
32
+ check=False,
33
+ input=json.dumps(each_search_event),
34
+ capture_output=True,
35
+ text=True,
36
+ encoding="utf-8",
37
+ )
38
+ assert completed_process.returncode == 0
39
+ assert completed_process.stdout == ""
40
+ assert completed_process.stderr == ""
41
+
42
+
43
+ def test_shipped_hook_manifest_omits_retired_entrypoint() -> None:
44
+ hooks_manifest_text = _HOOKS_MANIFEST_PATH.read_text(encoding="utf-8")
45
+
46
+ assert _ENTRYPOINT_PATH.name not in hooks_manifest_text
package/hooks/hooks.json CHANGED
@@ -84,12 +84,12 @@
84
84
  {
85
85
  "type": "command",
86
86
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/session_end_cleanup.py",
87
- "timeout": 15
87
+ "timeout": 3
88
88
  },
89
89
  {
90
90
  "type": "command",
91
91
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_edit_tracker_cleanup.py",
92
- "timeout": 10
92
+ "timeout": 3
93
93
  }
94
94
  ]
95
95
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-dev-env": "bin/install.mjs",
8
+ "cde-pstack": "bin/pstack.mjs",
8
9
  "codex-compat": "bin/codex-compat.mjs",
9
10
  "cde": "bin/cde.mjs"
10
11
  },
@@ -19,7 +19,7 @@ Three tools are equally sanctioned; pick by what you know:
19
19
  | A name or path pattern | The harness `Glob` tool |
20
20
  | Text inside files | The harness `Grep` tool |
21
21
 
22
- When `es.exe` fails or returns nothing, fall back to `Glob` or `Grep` without pausing, and report the outage so the reader knows the index was unavailable. Ask the user only after all three tools fail.
22
+ When `es.exe` returns Error 8, retry the same search twice before any fallback. Error 8 is a missing IPC client window, not proof the index is down. After those retries fail, report that the Everything IPC client did not answer and that the service state was not probed, then fall back to `Glob` or `Grep` with the same scope. When `es.exe` is missing, or a later search returns no hits, fall back to `Glob` or `Grep`. Ask the user only after all three tools fail.
23
23
 
24
24
  `skills/everything-search/SKILL.md` holds the full `es.exe` operator reference: `ext:`, `dm:`, `size:`, wildcards, OR/AND/NOT, output flags, and the junction and drive-mapping note.
25
25
 
@@ -0,0 +1,17 @@
1
+ # Pstack compatibility
2
+
3
+ Apply this mapping to the upstream workflow below. User instructions and host permissions stay in charge.
4
+
5
+ Determine the active host from the session's native tools. Read host-claude.md, host-codex.md, or host-cursor.md beside this file for the active host. Read pstack-host-mapping.md and pstack-models.md beside this file before delegation. Those files are copied from claude-dev-env's existing rules into this immutable release. Run the selectorPath recorded in release.json and supply its preferencesDirectory in the selector input. This overrides the old global selector path. Model preferences remain outside the release.
6
+
7
+ Installed skill names have a component prefix. Read the installed inventory in release.json. A pstack request for how, /how, or pstack:how means pstack-how. A cursor-team-kit request for deslop means cursor-team-kit-deslop. Resolve all other skill names through that inventory. Read each leaf skill in full when it applies. Keep each skill's scripts, references, playbooks, and agent definitions at its recorded source root.
8
+
9
+ Every delegation prompt must include this compatibility file's absolute path, the active host file's absolute path, the selected skill's absolute path, and the requested upstream agent definition's absolute path when present. Tell the child to read them before working and to put the complete findings and evidence in its final message. The child receives the same host and model mapping as the parent.
10
+
11
+ Treat upstream model identifiers as role preferences. Use only models and arguments accepted by the native tool in this session. Preserve required independence and model diversity. Report a missing capability when the session cannot supply the required independent panel. Use the native tool's supported concurrency and wait operations.
12
+
13
+ For setup-pstack, configure only the current host's model preference file described in pstack-models.md. Keep that file outside this release. The Cursor .mdc output is a Cursor rule, not a cross-host model configuration.
14
+
15
+ For create-skill, use Cursor's built-in skill on Cursor and cde-create-skill on Claude or Codex. For control-cli and control-ui, load the corresponding installed cursor-team-kit skill and use native terminal or browser tools. Check that the required CLI, browser, credentials, and permissions exist before executing its steps. State an unavailable dependency explicitly. Never claim an unattended browser or remote session ran from a filesystem check.
16
+
17
+ Updates are prepared outside the active release. Follow absolute paths into the release named in this entry point for the rest of this task. Preserve supporting files and scripts, including executable modes and licenses.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: cde-create-skill
3
+ description: Write or update a portable SKILL.md workflow when pstack requests create-skill on a host without Cursor's built-in skill.
4
+ ---
5
+
6
+ # Create a portable skill
7
+
8
+ State the user-visible outcome and the exact trigger first. Inspect applicable repository instructions and existing skills before adding a new skill.
9
+
10
+ Create a lowercase, hyphenated skill directory under the project's .claude/skills. Give SKILL.md YAML frontmatter with a matching name and a one-line description that says when to use it. Put the workflow after the frontmatter. Keep supporting scripts and references together with the skill, and use relative links inside it. Add a Codex .agents/skills directory link where needed, preserving existing files.
11
+
12
+ Use native host tools and capability checks. Give each step a concrete input, action, and observable result. Separate human choices from facts the agent can inspect. Read referenced instructions before using them.
13
+
14
+ Verify metadata, links, and script behavior. Then invoke the installed skill in the intended host and record the actual result. Distinguish a filesystem check from a native invocation. Keep the scope to the requested workflow.
@@ -0,0 +1,7 @@
1
+ # Claude Code
2
+
3
+ Use Claude Code's native Skill mechanism to load installed skills. Use Agent for Task requests, with only arguments the session exposes. For an upstream poteto-agent or another unavailable agent type, use an available general-purpose agent and include the upstream definition and compatibility files in its prompt. A file on disk does not register a native agent type.
4
+
5
+ Use AskUserQuestion for a required human choice. Use Read, Glob, Grep, Edit, Write, Bash, and the available browser tools for their corresponding work. Read native tool schemas rather than forwarding Cursor-only arguments. Use background execution only when the current tool supports it.
6
+
7
+ Choose model aliases accepted by this session. Omit the model argument for permitted parent inheritance. Keep host preferences in the profile's agents-home rules/pstack-model-preferences.claude.json. Use the current session's transcript location, or state that transcripts are unavailable.
@@ -0,0 +1,11 @@
1
+ # Codex
2
+
3
+ Load SKILL.md with the native skill mechanism or read its complete file with the available file tool. Codex does not need a Claude Skill tool.
4
+
5
+ Map Task or Agent to the native spawn_agent, wait, send_input, and close_agent operations that this session exposes. Field names vary by host version. Inspect the live schema. For poteto-agent, include the upstream agent definition, skill entry, and compatibility files in the child prompt. Select an available native agent type. Do not pass subagent_type, run_in_background, or a model argument unless that tool accepts it.
6
+
7
+ Use the native shell for commands, apply_patch or the native editor for edits, and the available browser tool for UI work. Ask the user through the current session's supported interaction mechanism for a genuine preference decision.
8
+
9
+ A multi-agent feature setting in a CLI profile does not establish subagent support in a cloud session. Check available tools. Required independent or cross-model review stops with a clear capability report when that session cannot provide it. Keep configured model preferences in the profile's agents-home rules/pstack-model-preferences.codex.json. Read the current transcript location from host context, not ~/.cursor/projects.
10
+
11
+ The selector's availableModelIds contains only models selectable through this session's delegation tool. A tool without a model argument supplies an empty list and uses permitted parent inheritance. A provider's general model catalog is not proof of per-delegation model selection.
@@ -0,0 +1,7 @@
1
+ # Cursor
2
+
3
+ Use Cursor's available native skill and delegation tools. The installed component-prefixed names in release.json replace upstream plugin short names. Keep project skills under .claude/skills and use the .agents/skills links for shared discovery. The installer writes no skills under .cursor.
4
+
5
+ For an upstream custom agent type absent from the live tool inventory, use an available native agent type and include that upstream agent definition, the skill entry, and compatibility files in the child prompt. Use the session's supported model identifiers and delegation fields. Preserve required panel independence and model diversity.
6
+
7
+ Use Cursor's built-in create-skill when present. Otherwise read cde-create-skill. Keep Cursor-specific preferences separate from Claude and Codex preferences. Resolve transcripts from the current workspace and session context.
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "adapterVersion": 1,
4
+ "repository": "https://github.com/cursor/plugins.git",
5
+ "commit": "27e2a62ff94f9af4b5e68435e41cdceacadb840c",
6
+ "components": ["pstack", "cursor-team-kit"],
7
+ "requiredSkills": {
8
+ "pstack": ["poteto-mode"],
9
+ "cursor-team-kit": ["deslop", "control-cli", "control-ui"]
10
+ }
11
+ }