claude-dev-env 7.7.1 → 8.1.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.
Files changed (118) hide show
  1. package/.agents/agents/clean-coder.md +5 -6
  2. package/.agents/agents/issue-tracker.md +1 -1
  3. package/.agents/agents/pr-description-writer.md +1 -1
  4. package/.agents/agents/skill-writer-agent.md +1 -12
  5. package/.agents/agents/test_agent_frontmatter.py +2 -19
  6. package/.agents/skills/issue-tracker/SKILL.md +1 -1
  7. package/.agents/skills/orchestrator/SKILL.md +4 -5
  8. package/.agents/skills/pull-request/SKILL.md +8 -7
  9. package/.agents/skills/pull-request/reference/publication-tasks.md +1 -1
  10. package/.agents/skills/team-advisor/SKILL.md +2 -3
  11. package/_shared/advisor/advisor-protocol.md +0 -1
  12. package/_shared/advisor/reference/lifecycle.md +0 -1
  13. package/_shared/advisor/reference/warm-up.md +1 -2
  14. package/audit-rubrics/audit-categories.json +0 -4
  15. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -1
  16. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -2
  17. package/audit-rubrics/prompts/category-e-dead-code.md +5 -5
  18. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -24
  19. package/bin/install-constants.mjs +0 -5
  20. package/bin/install-pstack-plugin.mjs +192 -0
  21. package/bin/install-pstack-plugin.test.mjs +181 -0
  22. package/bin/install.agents-home.test.mjs +0 -4
  23. package/bin/install.codex-rules.test.mjs +6 -0
  24. package/bin/install.cursor-rules.test.mjs +3 -253
  25. package/bin/install.mjs +55 -160
  26. package/bin/install.test.mjs +110 -149
  27. package/bin/prune-retired-pstack-store.mjs +83 -0
  28. package/bin/prune-retired-pstack-store.test.mjs +105 -0
  29. package/bin/prune-session-continuity.mjs +57 -0
  30. package/bin/prune-session-continuity.test.mjs +70 -0
  31. package/bin/resolve-install-root.mjs +2 -0
  32. package/bin/resolve-install-root.test.mjs +69 -0
  33. package/docs/CODE_RULES.md +1 -1
  34. package/docs/agent-spawn-protocol.md +1 -1
  35. package/docs/codex-compatibility.md +4 -16
  36. package/docs/nas-ssh-invocation.md +1 -1
  37. package/docs/references/prose-style-enforcement.md +2 -2
  38. package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +1 -3
  39. package/hooks/blocking/code_rules_constants_config.py +1 -108
  40. package/hooks/blocking/code_rules_enforcer.py +9 -5
  41. package/hooks/blocking/code_rules_test_layout.py +6 -7
  42. package/hooks/blocking/test_code_rules_constants_config.py +13 -0
  43. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  44. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +15 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +0 -60
  46. package/hooks/blocking/test_context_control_policy_files.py +0 -11
  47. package/hooks/hooks_constants/blocking_check_limits.py +1 -1
  48. package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -1
  49. package/hooks/hooks_constants/setup_project_paths_constants.py +1 -2
  50. package/hooks/session/test_untracked_repo_detector.py +1 -1
  51. package/hooks/validators/code_quality_checks.py +15 -2
  52. package/hooks/validators/test_code_quality_checks.py +22 -0
  53. package/package.json +1 -2
  54. package/rules/anti-corollary-tests.md +3 -1
  55. package/rules/archiving-agent-config.md +70 -0
  56. package/rules/asd-ste100-language.md +1 -1
  57. package/rules/ci-owns-the-gate.md +73 -0
  58. package/rules/cleanup-temp-files.md +1 -1
  59. package/rules/code-standards.md +3 -4
  60. package/rules/docstring-prose-matches-implementation.md +3 -3
  61. package/rules/explore-thoroughly.md +2 -2
  62. package/rules/failure-blast-radius.md +10 -9
  63. package/rules/git-workflow.md +27 -12
  64. package/rules/long-horizon-autonomy.md +3 -1
  65. package/rules/nas-ssh-invocation.md +1 -1
  66. package/rules/orphan-css-class.md +1 -1
  67. package/rules/paired-test-coverage.md +1 -1
  68. package/rules/research-mode.md +3 -1
  69. package/rules/retired-hook-prose.md +10 -2
  70. package/rules/testing.md +3 -3
  71. package/rules/verify-before-asking.md +4 -1
  72. package/rules/verify-runtime-state.md +2 -2
  73. package/rules/windows-filesystem-safe.md +1 -1
  74. package/rules/workflow-substitution-slots.md +1 -1
  75. package/scripts/Sync-RepoMain.ps1 +71 -6
  76. package/scripts/policy_lint/adapter_detectors.py +116 -10
  77. package/scripts/policy_lint/config/approved_test_pairs.py +12 -0
  78. package/scripts/policy_lint/config/constants.py +3 -0
  79. package/scripts/tests/Sync-RepoMain.Tests.ps1 +108 -0
  80. package/scripts/tests/test_codex_compat_materializer.py +3 -3
  81. package/scripts/tests/test_policy_lint_rules_validator_baseline.py +126 -0
  82. package/.agents/skills/session-continuity/AGENTS.md +0 -1
  83. package/.agents/skills/session-continuity/CLAUDE.md +0 -1
  84. package/.agents/skills/session-continuity/README.md +0 -223
  85. package/.agents/skills/session-continuity/SKILL.md +0 -125
  86. package/.agents/skills/session-continuity/continuity.mjs +0 -445
  87. package/.agents/skills/session-continuity/continuity.test.mjs +0 -381
  88. package/bin/install-session-continuity.mjs +0 -131
  89. package/bin/install-session-continuity.test.mjs +0 -106
  90. package/bin/install.pstack-plugin.test.mjs +0 -115
  91. package/bin/pstack-codex-discovery.test.mjs +0 -79
  92. package/bin/pstack-model-preferences.claude.json +0 -23
  93. package/bin/pstack-model-preferences.codex.json +0 -24
  94. package/bin/pstack.mjs +0 -392
  95. package/bin/pstack.test.mjs +0 -347
  96. package/bin/refresh-pstack-plugin-skills.test.mjs +0 -211
  97. package/bin/select-pstack-models.test.mjs +0 -776
  98. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +0 -231
  99. package/rules/agent-spawn-protocol.md +0 -9
  100. package/rules/ask-user-question-required.md +0 -31
  101. package/rules/claims-as-quotes.md +0 -55
  102. package/rules/confirm-implementation-forks.md +0 -11
  103. package/rules/file-global-constants.md +0 -81
  104. package/rules/hedging-claims.md +0 -7
  105. package/rules/measurement-denominators.md +0 -39
  106. package/rules/prompt-workflow-context-controls.md +0 -19
  107. package/rules/pstack-host-mapping.md +0 -48
  108. package/rules/pstack-models.md +0 -142
  109. package/rules/vault-context.md +0 -5
  110. package/scripts/pstack-adapters/common.md +0 -17
  111. package/scripts/pstack-adapters/create-skill.md +0 -14
  112. package/scripts/pstack-adapters/host-claude.md +0 -7
  113. package/scripts/pstack-adapters/host-codex.md +0 -11
  114. package/scripts/pstack-adapters/host-cursor.md +0 -7
  115. package/scripts/pstack.lock.json +0 -11
  116. package/scripts/refresh_pstack_plugin_skills.mjs +0 -200
  117. package/scripts/select_pstack_models.mjs +0 -469
  118. /package/.agents/{skills → skills-archived}/source-command-logifix/SKILL.md +0 -0
@@ -11,8 +11,6 @@ import {
11
11
  readdirSync,
12
12
  existsSync,
13
13
  copyFileSync,
14
- cpSync,
15
- lstatSync,
16
14
  } from 'node:fs';
17
15
  import { tmpdir } from 'node:os';
18
16
  import { join, dirname } from 'node:path';
@@ -20,8 +18,6 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
20
18
 
21
19
  import {
22
20
  collectPackageSourceConflicts,
23
- installPstackRelease,
24
- shouldInstallPstackRelease,
25
21
  CONTENT_DIRECTORIES,
26
22
  CORE_INCLUDE_DIRECTORIES,
27
23
  CORE_SKILLS,
@@ -54,7 +50,6 @@ import {
54
50
  hookCommandComparisonSpelling,
55
51
  detectPython,
56
52
  retainNewestRunBackupOnly,
57
- refreshInstalledPstackPluginManifest,
58
53
  } from './install.mjs';
59
54
  import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
60
55
 
@@ -2881,42 +2876,6 @@ test('pruneStaleInstalledFiles leaves the run backup root standing when the rena
2881
2876
  }
2882
2877
  });
2883
2878
 
2884
- test('the pstack step writes a manifest for a skills root that carries pstack', () => {
2885
- const skillsRoot = mkdtempSync(join(tmpdir(), 'cdev-pstack-step-'));
2886
- try {
2887
- const skillDirectory = join(skillsRoot, 'pstack', 'how');
2888
- mkdirSync(skillDirectory, { recursive: true });
2889
- writeFileSync(join(skillDirectory, 'SKILL.md'), '---\nname: how\n---\n');
2890
-
2891
- const manifestPath = refreshInstalledPstackPluginManifest(skillsRoot);
2892
-
2893
- assert.equal(
2894
- manifestPath,
2895
- join(skillsRoot, 'pstack', '.claude-plugin', 'plugin.json'),
2896
- 'the step reports the manifest it wrote so the run records the path',
2897
- );
2898
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
2899
- assert.deepEqual(manifest.skills, ['./how']);
2900
- assert.equal(
2901
- refreshInstalledPstackPluginManifest(skillsRoot),
2902
- null,
2903
- 'a second run writes nothing and reports no path',
2904
- );
2905
- } finally {
2906
- rmSync(skillsRoot, { recursive: true, force: true });
2907
- }
2908
- });
2909
-
2910
- test('the pstack step reports no path for a skills root without pstack', () => {
2911
- const skillsRoot = mkdtempSync(join(tmpdir(), 'cdev-pstack-absent-'));
2912
- try {
2913
- assert.equal(refreshInstalledPstackPluginManifest(skillsRoot), null);
2914
- assert.equal(existsSync(join(skillsRoot, 'pstack')), false);
2915
- } finally {
2916
- rmSync(skillsRoot, { recursive: true, force: true });
2917
- }
2918
- });
2919
-
2920
2879
  const RETIRED_SKILLS = ['pr-small-cl', 'session-log', 'session-tidy'];
2921
2880
 
2922
2881
  test('each core skill has a shipped entry point', () => {
@@ -2934,13 +2893,9 @@ test('retired skills leave install groups and stay in the cleanup registry', ()
2934
2893
  }
2935
2894
  });
2936
2895
 
2937
- test('README and vault guidance use current session tools', () => {
2896
+ test('the README names current session tools', () => {
2938
2897
  const readme = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8');
2939
- const vaultRule = readFileSync(new URL('../rules/vault-context.md', import.meta.url), 'utf8');
2940
2898
  assert.doesNotMatch(readme, /--only journal|session-log|session-tidy/);
2941
- assert.doesNotMatch(vaultRule, /`session-log`|`recall`/);
2942
- assert.match(vaultRule, /obsidian MCP tools/);
2943
- assert.match(vaultRule, /project.*frontmatter/);
2944
2899
  });
2945
2900
 
2946
2901
  test('the help output states that the installer reads only flags', () => {
@@ -2980,43 +2935,24 @@ test('pruneRetiredHookEntriesFromSettings warns and returns 0 when the host conf
2980
2935
 
2981
2936
  const PSTACK_TEST_PACKAGE_ROOT = dirname(fileURLToPath(new URL('./install.mjs', import.meta.url)));
2982
2937
  const PSTACK_TEST_INSTALLER_PATH = fileURLToPath(new URL('./install.mjs', import.meta.url));
2983
- const PSTACK_TEST_LOCK = JSON.parse(readFileSync(
2984
- new URL('../scripts/pstack.lock.json', import.meta.url),
2985
- 'utf8',
2986
- ));
2987
-
2988
- function writePstackFixtureFile(filePath, fileContent) {
2989
- mkdirSync(dirname(filePath), { recursive: true });
2990
- writeFileSync(filePath, fileContent);
2991
- }
2992
2938
 
2993
- function pstackUpstreamFixture(t) {
2994
- const temporaryRoot = mkdtempSync(join(tmpdir(), 'cdev-install-pstack-'));
2995
- t.after(() => rmSync(temporaryRoot, { recursive: true, force: true }));
2996
- const checkout = join(temporaryRoot, 'checkout');
2997
- for (const [component, allSkillNames] of Object.entries(PSTACK_TEST_LOCK.requiredSkills)) {
2998
- for (const skillName of allSkillNames) {
2999
- writePstackFixtureFile(
3000
- join(checkout, component, 'skills', skillName, 'SKILL.md'),
3001
- `---\nname: ${skillName}\ndescription: Test ${skillName}.\n---\n\nRun ${skillName}.\n`,
3002
- );
3003
- }
3004
- }
3005
- const managedRoot = join(temporaryRoot, 'home', '.claude');
3006
- return {
3007
- managedRoot,
3008
- options: {
3009
- root: managedRoot,
3010
- lock: structuredClone(PSTACK_TEST_LOCK),
3011
- packageRoot: dirname(PSTACK_TEST_PACKAGE_ROOT),
3012
- },
3013
- dependencies: {
3014
- fetchSource: (_lock, destination) => cpSync(checkout, destination, { recursive: true }),
3015
- },
3016
- };
2939
+ function writeRecordingHostCommand(directory, name, logPath) {
2940
+ mkdirSync(directory, { recursive: true });
2941
+ if (process.platform === 'win32') {
2942
+ const batchPath = join(directory, `${name}.cmd`);
2943
+ writeFileSync(batchPath, `@echo off\r\n>>"${logPath}" echo ${name} %*\r\n`);
2944
+ return batchPath;
2945
+ }
2946
+ const commandPath = join(directory, name);
2947
+ writeFileSync(
2948
+ commandPath,
2949
+ `#!/bin/sh\nprintf '%s %s\\n' "${name}" "$*" >> "${logPath}"\n`,
2950
+ { mode: 0o755 },
2951
+ );
2952
+ return commandPath;
3017
2953
  }
3018
2954
 
3019
- function runPstackInstaller(homeDirectory, extraArguments) {
2955
+ function runPstackInstaller(homeDirectory, extraArguments, environmentOverrides = {}) {
3020
2956
  return execFileSync('node', [PSTACK_TEST_INSTALLER_PATH, ...extraArguments], {
3021
2957
  cwd: dirname(PSTACK_TEST_PACKAGE_ROOT),
3022
2958
  encoding: 'utf8',
@@ -3028,82 +2964,80 @@ function runPstackInstaller(homeDirectory, extraArguments) {
3028
2964
  CLAUDE_CONFIG_DIR: join(homeDirectory, '.claude'),
3029
2965
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
3030
2966
  CDE_INSTALL_PSTACK: '1',
3031
- GIT_ALLOW_PROTOCOL: 'none',
2967
+ ...environmentOverrides,
3032
2968
  },
3033
2969
  });
3034
2970
  }
3035
2971
 
3036
- function withPstackTemporaryHome(t, runAssertions) {
2972
+ function pstackPluginSandbox(t) {
3037
2973
  const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-install-pstack-run-'));
3038
2974
  t.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
3039
- runAssertions(homeDirectory);
2975
+ const commandLogPath = join(homeDirectory, 'host-commands.log');
2976
+ const commandDirectory = join(homeDirectory, 'host-commands');
2977
+ return {
2978
+ homeDirectory,
2979
+ commandLogPath,
2980
+ environment: {
2981
+ CDE_CLAUDE_EXECUTABLE: writeRecordingHostCommand(commandDirectory, 'claude', commandLogPath),
2982
+ CDE_CODEX_EXECUTABLE: writeRecordingHostCommand(commandDirectory, 'codex', commandLogPath),
2983
+ },
2984
+ recordedCommands() {
2985
+ if (!existsSync(commandLogPath)) return [];
2986
+ return readFileSync(commandLogPath, 'utf8')
2987
+ .split(/\r?\n/)
2988
+ .map(eachLine => eachLine.trim())
2989
+ .filter(Boolean);
2990
+ },
2991
+ };
3040
2992
  }
3041
2993
 
3042
- test('the base install publishes the pstack poteto-mode entry into the managed skills home', t => {
3043
- const fixture = pstackUpstreamFixture(t);
2994
+ test('a full install adds the pstack marketplace and plugin on both hosts', t => {
2995
+ const sandbox = pstackPluginSandbox(t);
3044
2996
 
3045
- const outcome = installPstackRelease(fixture.options, fixture.dependencies);
2997
+ const installerOutput = runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3046
2998
 
3047
- assert.equal(outcome.status, 'installed');
3048
- assert.equal(outcome.warning, null);
3049
- const pstackRoot = join(fixture.managedRoot, 'skills', 'pstack');
3050
- const entryPath = join(pstackRoot, 'poteto-mode');
3051
- assert.equal(lstatSync(pstackRoot).isSymbolicLink(), true, 'the pstack root is a pointer into the release');
3052
- assert.equal(existsSync(join(entryPath, 'SKILL.md')), true, 'the nested entry resolves to a skill');
2999
+ assert.deepEqual(sandbox.recordedCommands(), [
3000
+ 'claude plugin marketplace add michael-denyer/pstack-claude',
3001
+ 'claude plugin install pstack@pstack-claude',
3002
+ 'codex plugin marketplace add michael-denyer/pstack-claude',
3003
+ 'codex plugin add pstack@pstack-claude',
3004
+ ]);
3005
+ assert.match(installerOutput, /Pstack \(claude\): installed/);
3006
+ assert.match(installerOutput, /Pstack \(codex\): installed/);
3053
3007
  });
3054
3008
 
3055
- test('an unreachable pstack upstream reports a warning and keeps the base install going', t => {
3056
- const fixture = pstackUpstreamFixture(t);
3009
+ test('an absent host command is reported and leaves the other host installed', t => {
3010
+ const sandbox = pstackPluginSandbox(t);
3057
3011
 
3058
- const outcome = installPstackRelease(fixture.options, {
3059
- fetchSource: () => { throw new Error('network unavailable'); },
3012
+ const installerOutput = runPstackInstaller(sandbox.homeDirectory, [], {
3013
+ ...sandbox.environment,
3014
+ CDE_CODEX_EXECUTABLE: join(sandbox.homeDirectory, 'host-commands', 'absent-codex-command'),
3060
3015
  });
3061
3016
 
3062
- assert.equal(outcome.status, 'failed');
3063
- assert.equal(outcome.release, null);
3064
- assert.match(outcome.warning, /network unavailable/);
3065
- });
3066
-
3067
- test('--no-pstack and CDE_INSTALL_PSTACK=0 each turn the pstack step off', () => {
3068
- assert.equal(shouldInstallPstackRelease([], {}), true);
3069
- assert.equal(shouldInstallPstackRelease(['--no-pstack'], {}), false);
3070
- assert.equal(shouldInstallPstackRelease([], { CDE_INSTALL_PSTACK: '0' }), false);
3071
- assert.equal(shouldInstallPstackRelease([], { CDE_INSTALL_PSTACK: '1' }), true);
3017
+ assert.match(installerOutput, /Pstack \(claude\): installed/);
3018
+ assert.match(
3019
+ installerOutput,
3020
+ /Pstack \(codex\): (skipped|failed)/,
3021
+ 'the absent host is reported either way, and the two unit tests pin which signal maps to skipped',
3022
+ );
3023
+ assert.doesNotMatch(installerOutput, /Pstack \(codex\): installed/);
3024
+ assert.deepEqual(
3025
+ sandbox.recordedCommands().filter(eachCommand => eachCommand.startsWith('codex')),
3026
+ [],
3027
+ );
3072
3028
  });
3073
3029
 
3074
- test('a full install runs the pstack step and reports an upstream failure without stopping', t => {
3075
- withPstackTemporaryHome(t, homeDirectory => {
3076
- const installerOutput = runPstackInstaller(homeDirectory, []);
3077
-
3078
- assert.match(installerOutput, /Pstack:/, 'the run reports the pstack step');
3079
- assert.equal(existsSync(join(homeDirectory, '.claude', 'pstack')), true);
3080
- });
3081
- });
3030
+ test('--no-pstack leaves both hosts untouched', t => {
3031
+ const sandbox = pstackPluginSandbox(t);
3082
3032
 
3083
- test('--no-pstack leaves the pstack store absent', t => {
3084
- withPstackTemporaryHome(t, homeDirectory => {
3085
- const installerOutput = runPstackInstaller(homeDirectory, ['--no-pstack']);
3033
+ const installerOutput = runPstackInstaller(
3034
+ sandbox.homeDirectory, ['--no-pstack'], sandbox.environment,
3035
+ );
3086
3036
 
3087
- assert.doesNotMatch(installerOutput, /Pstack:/);
3088
- assert.equal(existsSync(join(homeDirectory, '.claude', 'pstack')), false);
3089
- });
3037
+ assert.deepEqual(sandbox.recordedCommands(), []);
3038
+ assert.doesNotMatch(installerOutput, /Pstack \(/);
3090
3039
  });
3091
3040
 
3092
- function runContinuityInstaller(homeDirectory, extraArguments) {
3093
- return execFileSync('node', [PSTACK_TEST_INSTALLER_PATH, ...extraArguments], {
3094
- cwd: dirname(PSTACK_TEST_PACKAGE_ROOT),
3095
- encoding: 'utf8',
3096
- env: {
3097
- ...process.env,
3098
- CDE_INSTALL_PSTACK: '0',
3099
- HOME: homeDirectory,
3100
- USERPROFILE: homeDirectory,
3101
- GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
3102
- CODEX_HOME: join(homeDirectory, '.codex'),
3103
- },
3104
- });
3105
- }
3106
-
3107
3041
  function continuityCommandCount(configurationPath) {
3108
3042
  if (!existsSync(configurationPath)) return 0;
3109
3043
  const configuration = JSON.parse(readFileSync(configurationPath, 'utf8'));
@@ -3116,32 +3050,59 @@ function continuityCommandCount(configurationPath) {
3116
3050
  ).length;
3117
3051
  }
3118
3052
 
3119
- test('a full install registers the session-continuity companion in every host, and uninstall takes it back out', t => {
3120
- const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-continuity-install-'));
3121
- t.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
3122
- const allConfigurationPaths = [
3123
- join(homeDirectory, '.claude', 'settings.json'),
3124
- join(homeDirectory, '.codex', 'hooks.json'),
3125
- join(homeDirectory, '.cursor', 'hooks.json'),
3053
+ test('a full install removes the retired pstack pointers and release store', t => {
3054
+ const sandbox = pstackPluginSandbox(t);
3055
+ const managedRoot = join(sandbox.homeDirectory, '.claude');
3056
+ const agentsSkillsRoot = join(sandbox.homeDirectory, '.agents', 'skills');
3057
+ const releaseSkillsRoot = join(managedRoot, 'pstack', 'releases', 'abc123', 'runtime', 'pstack', 'skills');
3058
+ mkdirSync(join(releaseSkillsRoot, 'poteto-mode'), { recursive: true });
3059
+ mkdirSync(join(managedRoot, 'skills'), { recursive: true });
3060
+ mkdirSync(agentsSkillsRoot, { recursive: true });
3061
+ const allPointerPaths = [
3062
+ join(managedRoot, 'skills', 'pstack'),
3063
+ join(agentsSkillsRoot, 'pstack'),
3126
3064
  ];
3065
+ for (const pointerPath of allPointerPaths) {
3066
+ symlinkSync(releaseSkillsRoot, pointerPath, 'dir');
3067
+ }
3127
3068
 
3128
- runContinuityInstaller(homeDirectory, []);
3069
+ const installerOutput = runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3129
3070
 
3130
- for (const configurationPath of allConfigurationPaths) {
3071
+ for (const pointerPath of allPointerPaths) {
3131
3072
  assert.equal(
3132
- continuityCommandCount(configurationPath) > 0,
3133
- true,
3134
- `the install registers the companion in ${configurationPath} without a second setup command`,
3073
+ existsSync(pointerPath),
3074
+ false,
3075
+ `the install removes ${pointerPath}, which Claude Code would load as a second pstack plugin`,
3135
3076
  );
3136
3077
  }
3078
+ assert.equal(existsSync(join(managedRoot, 'pstack')), false);
3079
+ assert.match(installerOutput, /Pstack: retired release store removed/);
3080
+ });
3081
+
3082
+ test('a full install clears a retired session-continuity registration from every host', t => {
3083
+ const sandbox = pstackPluginSandbox(t);
3084
+ const companionCommand = 'node "/stale/.agents/skills/session-continuity/continuity.mjs" hook claude';
3085
+ const allConfigurationPaths = [
3086
+ join(sandbox.homeDirectory, '.claude', 'settings.json'),
3087
+ join(sandbox.homeDirectory, '.codex', 'hooks.json'),
3088
+ join(sandbox.homeDirectory, '.cursor', 'hooks.json'),
3089
+ ];
3090
+ for (const configurationPath of allConfigurationPaths) {
3091
+ mkdirSync(dirname(configurationPath), { recursive: true });
3092
+ writeFileSync(configurationPath, JSON.stringify({
3093
+ hooks: {
3094
+ SessionStart: [{ hooks: [{ type: 'command', command: companionCommand }] }],
3095
+ },
3096
+ }, null, 2) + '\n');
3097
+ }
3137
3098
 
3138
- runContinuityInstaller(homeDirectory, ['--uninstall']);
3099
+ runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3139
3100
 
3140
3101
  for (const configurationPath of allConfigurationPaths) {
3141
3102
  assert.equal(
3142
3103
  continuityCommandCount(configurationPath),
3143
3104
  0,
3144
- `uninstall leaves no registration in ${configurationPath} pointing at a removed script`,
3105
+ `the install leaves no registration in ${configurationPath} pointing at a removed script`,
3145
3106
  );
3146
3107
  }
3147
3108
  });
@@ -0,0 +1,83 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readdirSync,
5
+ readlinkSync,
6
+ rmSync,
7
+ unlinkSync,
8
+ } from 'node:fs';
9
+ import { isAbsolute, join, resolve, sep } from 'node:path';
10
+
11
+ export const RETIRED_PSTACK_STORE_DIRECTORY_NAME = 'pstack';
12
+
13
+ /**
14
+ * Name the store directory the retired pstack installer kept its releases in.
15
+ *
16
+ * @param {string} managedRoot The managed Claude root.
17
+ * @returns {string} The retired store root.
18
+ */
19
+ export function retiredPstackStoreRoot(managedRoot) {
20
+ return join(managedRoot, RETIRED_PSTACK_STORE_DIRECTORY_NAME);
21
+ }
22
+
23
+ function pointsIntoStore(entryPath, storeRoot) {
24
+ let entryStats;
25
+ try {
26
+ entryStats = lstatSync(entryPath);
27
+ } catch {
28
+ return false;
29
+ }
30
+ if (!entryStats.isSymbolicLink()) return false;
31
+ let linkTarget;
32
+ try {
33
+ linkTarget = readlinkSync(entryPath);
34
+ } catch {
35
+ return false;
36
+ }
37
+ const resolvedTarget = isAbsolute(linkTarget)
38
+ ? resolve(linkTarget)
39
+ : resolve(join(entryPath, '..', linkTarget));
40
+ return resolvedTarget.startsWith(resolve(storeRoot) + sep);
41
+ }
42
+
43
+ function removePointersUnder(skillsRoot, storeRoot) {
44
+ if (!skillsRoot || !existsSync(skillsRoot)) return [];
45
+ const removedPointerPaths = [];
46
+ for (const entryName of readdirSync(skillsRoot)) {
47
+ const entryPath = join(skillsRoot, entryName);
48
+ if (!pointsIntoStore(entryPath, storeRoot)) continue;
49
+ unlinkSync(entryPath);
50
+ removedPointerPaths.push(entryPath);
51
+ }
52
+ return removedPointerPaths;
53
+ }
54
+
55
+ /**
56
+ * Remove the retired pstack installation this package used to publish.
57
+ *
58
+ * The retired installer kept a release store under the managed root and
59
+ * published one symlink per skill into it. Claude Code loads the `pstack`
60
+ * pointer as a skills-directory plugin, so a pointer left behind collides with
61
+ * the `pstack@pstack-claude` plugin the marketplace now installs.
62
+ *
63
+ * Only a symlink whose target resolves inside the store is removed, so a
64
+ * directory a user keeps under a similar name stays in place. A pointer left
65
+ * dangling by a store someone already deleted is removed too.
66
+ *
67
+ * @param {object} roots An `InstallRootResolution`.
68
+ * @returns {{removedPointerPaths: string[], removedStorePath: string|null}} What this call removed.
69
+ */
70
+ export function removeRetiredPstackInstallation(roots) {
71
+ const storeRoot = retiredPstackStoreRoot(roots.managedRoot);
72
+ const allSkillsRoots = [...new Set([
73
+ roots.skillsLookupDirectory,
74
+ roots.skillsInstallDirectory,
75
+ ])];
76
+ const removedPointerPaths = allSkillsRoots
77
+ .flatMap(skillsRoot => removePointersUnder(skillsRoot, storeRoot));
78
+ if (!existsSync(storeRoot)) {
79
+ return { removedPointerPaths, removedStorePath: null };
80
+ }
81
+ rmSync(storeRoot, { recursive: true, force: true });
82
+ return { removedPointerPaths, removedStorePath: storeRoot };
83
+ }
@@ -0,0 +1,105 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import {
15
+ removeRetiredPstackInstallation,
16
+ retiredPstackStoreRoot,
17
+ } from './prune-retired-pstack-store.mjs';
18
+
19
+ function retiredInstallationFixture(t) {
20
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-retired-pstack-'));
21
+ t.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
22
+ const managedRoot = join(homeDirectory, '.claude');
23
+ const agentsHome = join(homeDirectory, '.agents');
24
+ const releaseRoot = join(managedRoot, 'pstack', 'releases', 'abc123', 'runtime');
25
+ mkdirSync(join(releaseRoot, 'pstack', 'skills', 'poteto-mode'), { recursive: true });
26
+ mkdirSync(join(releaseRoot, 'cursor-team-kit', 'skills', 'deslop'), { recursive: true });
27
+ writeFileSync(join(managedRoot, 'pstack', 'installed.json'), '{}\n');
28
+ const managedSkillsRoot = join(managedRoot, 'skills');
29
+ const agentsSkillsRoot = join(agentsHome, 'skills');
30
+ mkdirSync(managedSkillsRoot, { recursive: true });
31
+ mkdirSync(agentsSkillsRoot, { recursive: true });
32
+ const pointers = [
33
+ [join(managedSkillsRoot, 'pstack'), join(releaseRoot, 'pstack', 'skills')],
34
+ [join(agentsSkillsRoot, 'pstack'), join(releaseRoot, 'pstack', 'skills')],
35
+ [
36
+ join(agentsSkillsRoot, 'cursor-team-kit-deslop'),
37
+ join(releaseRoot, 'cursor-team-kit', 'skills', 'deslop'),
38
+ ],
39
+ ];
40
+ for (const [pointerPath, targetPath] of pointers) {
41
+ symlinkSync(targetPath, pointerPath, 'dir');
42
+ }
43
+ return {
44
+ roots: { managedRoot, agentsHome, skillsLookupDirectory: managedSkillsRoot, skillsInstallDirectory: agentsSkillsRoot },
45
+ managedSkillsRoot,
46
+ agentsSkillsRoot,
47
+ pointerPaths: pointers.map(([pointerPath]) => pointerPath),
48
+ };
49
+ }
50
+
51
+ test('the store root sits under the managed root', () => {
52
+ assert.equal(
53
+ retiredPstackStoreRoot(join('/managed', '.claude')),
54
+ join('/managed', '.claude', 'pstack'),
55
+ );
56
+ });
57
+
58
+ test('every pointer into the retired store is removed along with the store', t => {
59
+ const fixture = retiredInstallationFixture(t);
60
+
61
+ const outcome = removeRetiredPstackInstallation(fixture.roots);
62
+
63
+ assert.deepEqual(outcome.removedPointerPaths.sort(), fixture.pointerPaths.sort());
64
+ assert.equal(outcome.removedStorePath, retiredPstackStoreRoot(fixture.roots.managedRoot));
65
+ for (const pointerPath of fixture.pointerPaths) {
66
+ assert.equal(existsSync(pointerPath), false);
67
+ }
68
+ assert.equal(existsSync(retiredPstackStoreRoot(fixture.roots.managedRoot)), false);
69
+ });
70
+
71
+ test('a skill that is not a pointer into the store stays in place', t => {
72
+ const fixture = retiredInstallationFixture(t);
73
+ const ownSkillDirectory = join(fixture.agentsSkillsRoot, 'pull-request');
74
+ mkdirSync(ownSkillDirectory, { recursive: true });
75
+ writeFileSync(join(ownSkillDirectory, 'SKILL.md'), '---\nname: pull-request\n---\n');
76
+ const unrelatedTarget = join(fixture.roots.managedRoot, 'elsewhere', 'pstack');
77
+ mkdirSync(unrelatedTarget, { recursive: true });
78
+ const unrelatedPointer = join(fixture.agentsSkillsRoot, 'pstack-of-my-own');
79
+ symlinkSync(unrelatedTarget, unrelatedPointer, 'dir');
80
+
81
+ removeRetiredPstackInstallation(fixture.roots);
82
+
83
+ assert.equal(existsSync(join(ownSkillDirectory, 'SKILL.md')), true);
84
+ assert.equal(lstatSync(unrelatedPointer).isSymbolicLink(), true);
85
+ });
86
+
87
+ test('a home with no retired installation reports nothing removed', t => {
88
+ const fixture = retiredInstallationFixture(t);
89
+ removeRetiredPstackInstallation(fixture.roots);
90
+
91
+ const secondOutcome = removeRetiredPstackInstallation(fixture.roots);
92
+
93
+ assert.deepEqual(secondOutcome.removedPointerPaths, []);
94
+ assert.equal(secondOutcome.removedStorePath, null);
95
+ });
96
+
97
+ test('a dangling pointer into the store is removed after the store is gone', t => {
98
+ const fixture = retiredInstallationFixture(t);
99
+ rmSync(retiredPstackStoreRoot(fixture.roots.managedRoot), { recursive: true, force: true });
100
+
101
+ const outcome = removeRetiredPstackInstallation(fixture.roots);
102
+
103
+ assert.deepEqual(outcome.removedPointerPaths.sort(), fixture.pointerPaths.sort());
104
+ assert.equal(outcome.removedStorePath, null);
105
+ });
@@ -0,0 +1,57 @@
1
+ import { dirname, join } from 'node:path';
2
+
3
+ export const CONTINUITY_SCRIPT_MARKER = '/session-continuity/continuity.mjs';
4
+
5
+ /**
6
+ * True when a hook command calls the retired session-continuity companion.
7
+ *
8
+ * @param {unknown} command The `command` field of one host hook entry.
9
+ * @returns {boolean} True when this entry calls the retired companion.
10
+ */
11
+ export function isContinuityHookCommand(command) {
12
+ return typeof command === 'string' && command.includes(CONTINUITY_SCRIPT_MARKER);
13
+ }
14
+
15
+ /**
16
+ * Locate the hook configuration file each host keeps its registrations in.
17
+ *
18
+ * @param {object} roots An `InstallRootResolution`.
19
+ * @returns {Record<string, string>} One configuration path per host.
20
+ */
21
+ export function continuityHostConfigurationPaths(roots) {
22
+ return {
23
+ claude: join(roots.managedRoot, 'settings.json'),
24
+ codex: join(dirname(roots.codexRulesInstallDirectory), 'hooks.json'),
25
+ cursor: join(roots.cursorInstallDirectory, 'hooks.json'),
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Strip every retired session-continuity registration from one host
31
+ * configuration, in place, and drop an event left with no entries.
32
+ *
33
+ * An earlier claude-dev-env registered this companion in each host. The pstack
34
+ * plugin now carries the SessionStart context the companion supplied, so a
35
+ * registration left behind points every session at a deleted script.
36
+ *
37
+ * @param {object} configuration A parsed host hook configuration.
38
+ * @returns {number} How many registrations this call removed.
39
+ */
40
+ export function removeContinuityHooks(configuration) {
41
+ let removedCount = 0;
42
+ for (const [event, entries] of Object.entries(configuration.hooks || {})) {
43
+ const kept = entries.flatMap(entry => {
44
+ if (!entry.hooks) {
45
+ if (!isContinuityHookCommand(entry.command)) return [entry];
46
+ removedCount += 1;
47
+ return [];
48
+ }
49
+ const hooks = entry.hooks.filter(hook => !isContinuityHookCommand(hook.command));
50
+ removedCount += entry.hooks.length - hooks.length;
51
+ return hooks.length ? [{ ...entry, hooks }] : [];
52
+ });
53
+ if (kept.length) configuration.hooks[event] = kept;
54
+ else delete configuration.hooks[event];
55
+ }
56
+ return removedCount;
57
+ }
@@ -0,0 +1,70 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { join } from 'node:path';
4
+ import {
5
+ continuityHostConfigurationPaths,
6
+ isContinuityHookCommand,
7
+ removeContinuityHooks,
8
+ } from './prune-session-continuity.mjs';
9
+
10
+ const ROOTS = {
11
+ managedRoot: join('/managed', '.claude'),
12
+ codexRulesInstallDirectory: join('/managed', '.codex', 'rules'),
13
+ cursorInstallDirectory: join('/managed', '.cursor'),
14
+ };
15
+
16
+ const COMPANION_COMMAND = 'node "/managed/.agents/skills/session-continuity/continuity.mjs" hook claude';
17
+
18
+ test('a companion command is recognized and another hook command is not', () => {
19
+ assert.equal(isContinuityHookCommand(COMPANION_COMMAND), true);
20
+ assert.equal(isContinuityHookCommand('node "/managed/.agents/hooks/routing/other.mjs"'), false);
21
+ assert.equal(isContinuityHookCommand(undefined), false);
22
+ });
23
+
24
+ test('each host names the configuration file that holds its registrations', () => {
25
+ assert.deepEqual(continuityHostConfigurationPaths(ROOTS), {
26
+ claude: join('/managed', '.claude', 'settings.json'),
27
+ codex: join('/managed', '.codex', 'hooks.json'),
28
+ cursor: join('/managed', '.cursor', 'hooks.json'),
29
+ });
30
+ });
31
+
32
+ test('a grouped registration is removed and the sibling hook stays', () => {
33
+ const configuration = {
34
+ hooks: {
35
+ SessionStart: [{
36
+ matcher: 'startup',
37
+ hooks: [
38
+ { type: 'command', command: COMPANION_COMMAND },
39
+ { type: 'command', command: 'node "/managed/.agents/hooks/keep.mjs"' },
40
+ ],
41
+ }],
42
+ },
43
+ };
44
+ assert.equal(removeContinuityHooks(configuration), 1);
45
+ assert.deepEqual(configuration.hooks.SessionStart[0].hooks, [
46
+ { type: 'command', command: 'node "/managed/.agents/hooks/keep.mjs"' },
47
+ ]);
48
+ });
49
+
50
+ test('an event left with no entries is dropped rather than left empty', () => {
51
+ const configuration = {
52
+ hooks: {
53
+ UserPromptExpansion: [{ hooks: [{ type: 'command', command: COMPANION_COMMAND }] }],
54
+ },
55
+ };
56
+ assert.equal(removeContinuityHooks(configuration), 1);
57
+ assert.deepEqual(configuration.hooks, {});
58
+ });
59
+
60
+ test("Cursor's flat entry shape is removed the same way", () => {
61
+ const configuration = { version: 1, hooks: { sessionStart: [{ command: COMPANION_COMMAND }] } };
62
+ assert.equal(removeContinuityHooks(configuration), 1);
63
+ assert.deepEqual(configuration.hooks, {});
64
+ });
65
+
66
+ test('a configuration holding no registration is left untouched', () => {
67
+ const configuration = { hooks: { SessionStart: [{ hooks: [{ command: 'node "/keep.mjs"' }] }] } };
68
+ assert.equal(removeContinuityHooks(configuration), 0);
69
+ assert.deepEqual(configuration.hooks.SessionStart, [{ hooks: [{ command: 'node "/keep.mjs"' }] }]);
70
+ });