claude-dev-env 1.94.0 → 1.95.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 (57) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +35 -27
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
  6. package/bin/CLAUDE.md +10 -1
  7. package/bin/ever-shipped-skills.mjs +70 -0
  8. package/bin/install.mjs +136 -6
  9. package/bin/install.prune.test.mjs +457 -0
  10. package/docs/CODE_RULES.md +1 -1
  11. package/hooks/blocking/code_rules_enforcer.py +4 -0
  12. package/hooks/blocking/code_rules_shared.py +82 -0
  13. package/hooks/blocking/code_rules_test_layout.py +9 -3
  14. package/hooks/blocking/plain_language_blocker.py +138 -4
  15. package/hooks/blocking/sensitive_file_protector.py +114 -48
  16. package/hooks/blocking/tdd_enforcer.py +9 -2
  17. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  18. package/hooks/blocking/test_code_rules_shared.py +181 -0
  19. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  20. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  21. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  22. package/hooks/hooks_constants/CLAUDE.md +2 -0
  23. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  24. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  25. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  26. package/hooks/pyproject.toml +75 -4
  27. package/hooks/validators/CLAUDE.md +1 -1
  28. package/hooks/validators/README.md +2 -0
  29. package/hooks/validators/python_style_checks.py +114 -136
  30. package/hooks/validators/python_style_helpers.py +95 -0
  31. package/hooks/validators/test_python_style_checks.py +0 -164
  32. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  33. package/hooks/validators/test_python_style_fixes.py +251 -0
  34. package/hooks/validators/test_python_style_helpers.py +125 -0
  35. package/package.json +1 -1
  36. package/rules/CLAUDE.md +1 -0
  37. package/rules/anti-corollary-tests.md +69 -0
  38. package/rules/bdd.md +1 -3
  39. package/rules/code-reviews.md +1 -1
  40. package/rules/gh-paginate.md +1 -1
  41. package/rules/plain-language.md +2 -0
  42. package/skills/CLAUDE.md +4 -3
  43. package/skills/autoconverge/workflow/converge.mjs +2 -2
  44. package/skills/bugteam/reference/README.md +2 -3
  45. package/skills/closeout/SKILL.md +153 -0
  46. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  47. package/skills/closeout/reference/issue-body-templates.md +108 -0
  48. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  49. package/skills/orchestrator/SKILL.md +27 -21
  50. package/skills/orchestrator-refresh/SKILL.md +12 -8
  51. package/skills/pr-converge/CLAUDE.md +1 -1
  52. package/skills/pr-fix-protocol/SKILL.md +65 -0
  53. package/skills/skill-builder/references/skill-modularity.md +1 -1
  54. package/skills/team-advisor/SKILL.md +15 -11
  55. package/system-prompts/software-engineer.xml +7 -6
  56. package/hooks/validators/test_verify_paths.py +0 -32
  57. package/hooks/validators/verify_paths.py +0 -57
package/bin/install.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
4
- import { join, dirname, resolve, relative } from 'node:path';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync, renameSync, realpathSync } from 'node:fs';
4
+ import { join, dirname, resolve, relative, basename } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { execSync, execFileSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
@@ -9,6 +9,7 @@ import { createRequire } from 'node:module';
9
9
  import { installAllGitHooks } from './git_hooks_installer.mjs';
10
10
  import { installMypyIniForClaudeHooks } from './install_mypy_ini.mjs';
11
11
  import { expandHomeDirectoryTokensInSettings } from './expand_home_directory_tokens.mjs';
12
+ import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
12
13
 
13
14
  const CLAUDE_HOME = join(homedir(), '.claude');
14
15
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -19,6 +20,10 @@ const packageRequire = createRequire(import.meta.url);
19
20
 
20
21
  export const CONTENT_DIRECTORIES = ['rules', 'docs', 'commands', 'agents', 'system-prompts', 'scripts', '_shared', 'audit-rubrics'];
21
22
 
23
+ const SKILL_MANIFEST_FILENAME = 'SKILL.md';
24
+ const NEVER_PRUNED_SKILL_DIRECTORIES = new Set(['_shared']);
25
+ const PRUNED_SKILLS_BACKUP_DIRECTORY_NAME = '.claude-dev-env-pruned';
26
+
22
27
  export const CORE_INCLUDE_DIRECTORIES = [
23
28
  'rules', 'docs', 'commands', 'agents', 'audit-rubrics', '_shared', 'scripts',
24
29
  ];
@@ -94,17 +99,33 @@ function resolveDependencyPackageRoot(dependencyPackageName) {
94
99
  return dirname(dependencyPackageJsonPath);
95
100
  }
96
101
 
102
+ /**
103
+ * Discovers the install groups contributed by resolvable dependency packages and
104
+ * the names of the declared dependencies that failed to resolve.
105
+ *
106
+ * A dependency that cannot be resolved contributes no group, so its skills never
107
+ * enter the installed set. The retired-skill prune subtracts the installed set
108
+ * from the ever-shipped set, so an unresolved dependency whose skills migrated
109
+ * out of the main package would leave those live skills looking retired. The
110
+ * returned unresolved-name list lets the caller skip the prune in that degraded
111
+ * mode rather than delete a live skill.
112
+ *
113
+ * @returns {{groups: object, unresolvedDependencyNames: string[]}} The resolvable
114
+ * dependency groups keyed by group name, and the names that failed to resolve.
115
+ */
97
116
  function discoverDependencyGroups() {
98
117
  const ownPackageJsonPath = join(PACKAGE_ROOT, 'package.json');
99
118
  const ownPackageJson = JSON.parse(readFileSync(ownPackageJsonPath, 'utf8'));
100
119
  const dependencies = ownPackageJson.dependencies || {};
101
120
  const discoveredGroups = {};
121
+ const unresolvedDependencyNames = [];
102
122
  for (const dependencyName of Object.keys(dependencies)) {
103
123
  let dependencyRoot;
104
124
  try {
105
125
  dependencyRoot = resolveDependencyPackageRoot(dependencyName);
106
126
  } catch {
107
127
  console.error(` WARNING: Could not resolve dependency ${dependencyName}, skipping`);
128
+ unresolvedDependencyNames.push(dependencyName);
108
129
  continue;
109
130
  }
110
131
  const dependencyPackageJson = JSON.parse(
@@ -145,9 +166,12 @@ function discoverDependencyGroups() {
145
166
  }
146
167
  discoveredGroups[groupName] = group;
147
168
  }
148
- return discoveredGroups;
169
+ return { groups: discoveredGroups, unresolvedDependencyNames };
149
170
  }
150
171
 
172
+ const dependencyDiscovery = discoverDependencyGroups();
173
+ const UNRESOLVED_DEPENDENCY_NAMES = dependencyDiscovery.unresolvedDependencyNames;
174
+
151
175
  const INSTALL_GROUPS = {
152
176
  core: {
153
177
  description: 'Development standards, hooks, agents, commands',
@@ -164,7 +188,7 @@ const INSTALL_GROUPS = {
164
188
  description: 'Session logging and memory',
165
189
  skills: ['session-log', 'session-tidy'],
166
190
  },
167
- ...discoverDependencyGroups(),
191
+ ...dependencyDiscovery.groups,
168
192
  };
169
193
 
170
194
  /**
@@ -606,11 +630,99 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
606
630
  return groupCount;
607
631
  }
608
632
 
609
- function writeManifest(installedFiles) {
633
+ function writeManifest(installedFiles, skillNames) {
610
634
  const manifest = { package: PACKAGE_NAME, version: PACKAGE_VERSION, installedAt: new Date().toISOString(), files: installedFiles };
635
+ if (skillNames) {
636
+ manifest.skills = skillNames;
637
+ }
611
638
  writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2) + '\n');
612
639
  }
613
640
 
641
+ /**
642
+ * Read the skill directory names the previous install recorded.
643
+ *
644
+ * Returns null when no manifest exists or the manifest predates the skills key,
645
+ * so a caller can treat "unknown prior skills" the same in both cases and lean
646
+ * on the ever-shipped set to find retired skills.
647
+ *
648
+ * @returns {string[]|null} The prior manifest's skill names, or null when absent.
649
+ */
650
+ function readPriorManifestSkills() {
651
+ if (!existsSync(MANIFEST_FILE)) return null;
652
+ try {
653
+ const priorManifest = JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
654
+ return Array.isArray(priorManifest.skills) ? priorManifest.skills : null;
655
+ } catch {
656
+ return null;
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Move retired skill directories a prior install left under ~/.claude/skills into
662
+ * a timestamped backup directory rather than deleting them.
663
+ *
664
+ * A directory is pruned only when the package no longer ships it and either the
665
+ * prior manifest recorded it or the package once shipped it. Subtracting the
666
+ * just-installed set from the ever-shipped set at call time means restoring a
667
+ * skill to the package protects it — it re-enters the installed set and never
668
+ * counts as retired. Matching is by directory name alone, so a personal
669
+ * directory a user authored under a name that collides with a retired skill
670
+ * (for example `code`, `implement`, `refine`, `gotcha`, `caveman`) is treated as
671
+ * that retired skill and moved to backup; only a name in neither set, and
672
+ * ~/.claude/skills/_shared, are left in place.
673
+ *
674
+ * Each pruned directory is renamed to
675
+ * ~/.claude/.claude-dev-env-pruned/<timestamp>/<skill-name>/ — a backup root
676
+ * outside ~/.claude/skills, so a moved directory is never re-discovered as a
677
+ * skill — under one shared timestamp per run. A backup is never cleaned up, so a
678
+ * user can recover a wrongly-matched directory. One directory whose rename fails
679
+ * (for example a read-only file or a cross-device move) is logged and left in
680
+ * place, never deleted, so a prune failure costs at most a cosmetic leftover.
681
+ *
682
+ * @param {Set<string>} installedSkillNames Skill names this install just wrote.
683
+ * @param {string[]|null} priorManifestSkills The prior manifest's skill names, or null.
684
+ */
685
+ function pruneRetiredSkills(installedSkillNames, priorManifestSkills) {
686
+ const skillsDirectory = join(CLAUDE_HOME, 'skills');
687
+ if (!existsSync(skillsDirectory)) return;
688
+ const retiredSkillNames = new Set(
689
+ [...EVER_SHIPPED_SKILL_NAMES].filter(skillName => !installedSkillNames.has(skillName))
690
+ );
691
+ const priorSkillNames = new Set(priorManifestSkills || []);
692
+ const runTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
693
+ const backupRoot = join(CLAUDE_HOME, PRUNED_SKILLS_BACKUP_DIRECTORY_NAME, runTimestamp);
694
+ const existingSkillDirs = readdirSync(skillsDirectory, { withFileTypes: true })
695
+ .filter(entry => entry.isDirectory());
696
+ for (const skillDir of existingSkillDirs) {
697
+ const skillName = skillDir.name;
698
+ if (NEVER_PRUNED_SKILL_DIRECTORIES.has(skillName)) continue;
699
+ if (installedSkillNames.has(skillName)) continue;
700
+ const isPruneCandidate = priorSkillNames.has(skillName) || retiredSkillNames.has(skillName);
701
+ if (!isPruneCandidate) continue;
702
+ moveRetiredSkillToBackup(skillsDirectory, backupRoot, skillName);
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Move one retired skill directory into the run's backup root, leaving it in
708
+ * place when the move fails.
709
+ *
710
+ * @param {string} skillsDirectory The ~/.claude/skills directory holding the skill.
711
+ * @param {string} backupRoot The run's timestamped backup directory.
712
+ * @param {string} skillName The retired skill directory name to move.
713
+ */
714
+ function moveRetiredSkillToBackup(skillsDirectory, backupRoot, skillName) {
715
+ const skillPath = join(skillsDirectory, skillName);
716
+ const backupPath = join(backupRoot, skillName);
717
+ try {
718
+ mkdirSync(backupRoot, { recursive: true });
719
+ renameSync(skillPath, backupPath);
720
+ console.log(` ✗ ${join('skills', skillName)} (retired — moved to ${join(PRUNED_SKILLS_BACKUP_DIRECTORY_NAME, basename(backupRoot), skillName)})`);
721
+ } catch (moveError) {
722
+ console.warn(` Warning: could not move retired ${join('skills', skillName)} to backup, leaving in place (${moveError.message})`);
723
+ }
724
+ }
725
+
614
726
  function install(selectedGroups, options = {}) {
615
727
  const isUpdateRefresh = Boolean(options.isUpdateRefresh);
616
728
  if (isUpdateRefresh && !selectedGroups && existsSync(MANIFEST_FILE)) {
@@ -708,6 +820,7 @@ function install(selectedGroups, options = {}) {
708
820
  let skillsCreated = 0;
709
821
  let skillsUpdated = 0;
710
822
  const skillPaths = [];
823
+ const installedSkillNames = new Set();
711
824
  for (const sourceRoot of allSourceRoots) {
712
825
  const skillsSource = join(sourceRoot, 'skills');
713
826
  if (!existsSync(skillsSource)) continue;
@@ -718,6 +831,9 @@ function install(selectedGroups, options = {}) {
718
831
  skillsCreated += stats.created;
719
832
  skillsUpdated += stats.updated;
720
833
  skillPaths.push(...stats.paths);
834
+ if (existsSync(join(skillsSource, skillDir.name, SKILL_MANIFEST_FILENAME))) {
835
+ installedSkillNames.add(skillDir.name);
836
+ }
721
837
  }
722
838
  }
723
839
  summary.skills = { created: skillsCreated, updated: skillsUpdated, paths: skillPaths };
@@ -804,7 +920,21 @@ function install(selectedGroups, options = {}) {
804
920
  allInstalledFiles.push(claudeHubDest);
805
921
  console.log(` \u2713 ${relative(CLAUDE_HOME, claudeHubDest)} (hub)`);
806
922
  }
807
- writeManifest(allInstalledFiles);
923
+ const isFullInstall = !selectedGroups;
924
+ const priorManifestSkills = readPriorManifestSkills();
925
+ let manifestSkillNames = priorManifestSkills;
926
+ if (isFullInstall) {
927
+ if (UNRESOLVED_DEPENDENCY_NAMES.length > 0) {
928
+ console.log(
929
+ ` Skipping retired-skill prune — unresolved dependency group(s): ${UNRESOLVED_DEPENDENCY_NAMES.join(', ')}. `
930
+ + 'A skill that migrated to a dependency package would look retired and be moved to backup, so the prune is held until every dependency resolves.',
931
+ );
932
+ } else {
933
+ pruneRetiredSkills(installedSkillNames, priorManifestSkills);
934
+ }
935
+ manifestSkillNames = [...installedSkillNames].sort();
936
+ }
937
+ writeManifest(allInstalledFiles, manifestSkillNames);
808
938
  console.log(`\nInstalled ${PACKAGE_NAME}:`);
809
939
  for (const directory of CONTENT_DIRECTORIES) {
810
940
  if (summary[directory]) {
@@ -0,0 +1,457 @@
1
+ import { test, after } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { execFileSync } from 'node:child_process';
4
+ import {
5
+ mkdtempSync,
6
+ mkdirSync,
7
+ writeFileSync,
8
+ existsSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ rmSync,
12
+ cpSync,
13
+ } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join, dirname, basename } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
19
+ const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
20
+ const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
21
+ const EXCLUDED_PACKAGE_COPY_DIRECTORY = 'node_modules';
22
+
23
+ const RETIRED_SKILL_DIRECTORIES = [
24
+ 'findbugs',
25
+ 'fixbugs',
26
+ 'pr-scope-resolve',
27
+ 'post-audit-findings',
28
+ 'pr-consistency-audit',
29
+ 'bdd-protocol',
30
+ ];
31
+ const PERSONAL_SKILL_DIRECTORIES = ['balatro', 'midjourney-sref', 'credit-card-picker'];
32
+ const SHIPPED_SKILL_DIRECTORY = 'autoconverge';
33
+ const PRUNED_BACKUP_DIRECTORY_NAME = '.claude-dev-env-pruned';
34
+ const SKIP_PRUNE_NOTICE_MARKER = 'Skipping retired-skill prune';
35
+ const DEPENDENCY_STUB_PACKAGE_SEGMENTS = ['@jl-cmd', 'prompt-generator'];
36
+
37
+ /**
38
+ * Create a stub node_modules tree the installer can resolve the declared
39
+ * dependency package from, and return its root for the child's NODE_PATH.
40
+ *
41
+ * The installer resolves `@jl-cmd/prompt-generator/package.json`; a lone
42
+ * package.json at that path is enough for `createRequire.resolve` to succeed.
43
+ * The stub makes the dependency resolvable inside the sandbox so a full install
44
+ * exercises the real prune path. It does not prove the real private package
45
+ * resolves in a real install — CI cannot host that package — only that the
46
+ * resolved-dependency branch prunes as designed.
47
+ *
48
+ * @param {string} homeDirectory The sandbox home the stub is nested under.
49
+ * @returns {string} The stub modules root to place on the child's NODE_PATH.
50
+ */
51
+ let isolatedInstallerPath = null;
52
+ let isolatedPackageCopyRoot = null;
53
+
54
+ /**
55
+ * Copy the package into a temp directory without node_modules and return the
56
+ * copy's installer path.
57
+ *
58
+ * The installer resolves its declared dependencies with Node's regular
59
+ * node_modules walk starting at its own file, so the real installer under the
60
+ * repo always finds `@jl-cmd/prompt-generator` once `npm install` has run —
61
+ * removing NODE_PATH from the child cannot make the dependency unresolvable
62
+ * there. Running the copy makes resolution genuinely fail through the
63
+ * installer's own catch path: no node_modules sits in the copy's ancestry and
64
+ * the child gets no NODE_PATH. The copy is created once and shared by every
65
+ * unresolved-dependency install; installs write only into their sandbox HOME.
66
+ *
67
+ * @returns {string} The path to `bin/install.mjs` inside the package copy.
68
+ */
69
+ function ensureIsolatedInstallerPath() {
70
+ if (isolatedInstallerPath !== null) return isolatedInstallerPath;
71
+ isolatedPackageCopyRoot = mkdtempSync(join(tmpdir(), 'cdev-prune-package-'));
72
+ cpSync(PACKAGE_DIRECTORY, isolatedPackageCopyRoot, {
73
+ recursive: true,
74
+ filter: sourcePath => basename(sourcePath) !== EXCLUDED_PACKAGE_COPY_DIRECTORY,
75
+ });
76
+ isolatedInstallerPath = join(isolatedPackageCopyRoot, 'bin', 'install.mjs');
77
+ return isolatedInstallerPath;
78
+ }
79
+
80
+ after(() => {
81
+ if (isolatedPackageCopyRoot !== null) {
82
+ rmSync(isolatedPackageCopyRoot, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ function ensureDependencyStub(homeDirectory) {
87
+ const stubModulesRoot = join(homeDirectory, 'dependency-stub-modules');
88
+ const stubPackageDirectory = join(stubModulesRoot, ...DEPENDENCY_STUB_PACKAGE_SEGMENTS);
89
+ mkdirSync(stubPackageDirectory, { recursive: true });
90
+ writeFileSync(
91
+ join(stubPackageDirectory, 'package.json'),
92
+ JSON.stringify({
93
+ name: DEPENDENCY_STUB_PACKAGE_SEGMENTS.join('/'),
94
+ version: '1.0.0',
95
+ description: 'sandbox dependency stub',
96
+ }) + '\n',
97
+ );
98
+ return stubModulesRoot;
99
+ }
100
+
101
+ /**
102
+ * Report whether a retired skill directory landed under the prune backup root.
103
+ *
104
+ * @param {string} claudeDirectory The sandbox ~/.claude directory.
105
+ * @param {string} skillName The retired skill directory name to look for.
106
+ * @returns {boolean} True when a timestamped backup holds the skill directory.
107
+ */
108
+ function prunedBackupContains(claudeDirectory, skillName) {
109
+ const backupRoot = join(claudeDirectory, PRUNED_BACKUP_DIRECTORY_NAME);
110
+ if (!existsSync(backupRoot)) return false;
111
+ return readdirSync(backupRoot, { withFileTypes: true })
112
+ .filter(entry => entry.isDirectory())
113
+ .some(timestampDir => existsSync(join(backupRoot, timestampDir.name, skillName)));
114
+ }
115
+
116
+ /**
117
+ * Create an isolated ~/.claude sandbox and return the paths a prune test reads.
118
+ *
119
+ * The returned home directory becomes the installer's HOME, so every file the
120
+ * install writes and every skill directory the prune inspects stays inside the
121
+ * temp tree and never touches the machine's real ~/.claude.
122
+ *
123
+ * @returns {{homeDirectory: string, claudeDirectory: string, skillsDirectory: string, manifestPath: string}}
124
+ */
125
+ function createSandbox() {
126
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-prune-home-'));
127
+ const claudeDirectory = join(homeDirectory, '.claude');
128
+ const skillsDirectory = join(claudeDirectory, 'skills');
129
+ mkdirSync(skillsDirectory, { recursive: true });
130
+ const manifestPath = join(claudeDirectory, '.claude-dev-env-manifest.json');
131
+ return { homeDirectory, claudeDirectory, skillsDirectory, manifestPath };
132
+ }
133
+
134
+ /**
135
+ * Plant a skill directory under the sandbox with a single marker file.
136
+ *
137
+ * A personal directory is planted with no ``SKILL.md`` so it never looks like a
138
+ * shipped skill; a retired directory is planted with a ``SKILL.md`` so it mirrors
139
+ * a real skill the package once installed and later dropped.
140
+ *
141
+ * @param {string} skillsDirectory The sandbox skills directory.
142
+ * @param {string} skillName The directory name to plant.
143
+ * @param {boolean} withSkillManifest Whether to write a ``SKILL.md`` marker.
144
+ */
145
+ function plantSkillDirectory(skillsDirectory, skillName, withSkillManifest) {
146
+ const skillDirectory = join(skillsDirectory, skillName);
147
+ mkdirSync(skillDirectory, { recursive: true });
148
+ const markerName = withSkillManifest ? 'SKILL.md' : 'notes.md';
149
+ writeFileSync(join(skillDirectory, markerName), `seeded ${skillName}\n`);
150
+ }
151
+
152
+ /**
153
+ * Run the real installer against the sandbox home and return its stdout.
154
+ *
155
+ * The declared dependency package is resolvable by default: the child's
156
+ * NODE_PATH points at a sandbox stub so `createRequire.resolve` finds it and the
157
+ * full-install prune runs. Passing ``{ dependencyResolvable: false }`` runs the
158
+ * installer from an isolated package copy with no node_modules in its ancestry
159
+ * and no NODE_PATH, so the dependency fails to resolve and the installer skips
160
+ * the prune.
161
+ *
162
+ * @param {string} homeDirectory The sandbox home the installer writes into.
163
+ * @param {string[]} extraArguments Installer arguments (for example ``['--only', 'core']``).
164
+ * @param {{dependencyResolvable?: boolean}} options Whether the dependency resolves.
165
+ * @returns {string} The installer's stdout.
166
+ */
167
+ function runInstaller(homeDirectory, extraArguments, options = {}) {
168
+ const dependencyResolvable = options.dependencyResolvable !== false;
169
+ const childEnvironment = {
170
+ ...process.env,
171
+ HOME: homeDirectory,
172
+ USERPROFILE: homeDirectory,
173
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
174
+ };
175
+ let installerPath;
176
+ if (dependencyResolvable) {
177
+ childEnvironment.NODE_PATH = ensureDependencyStub(homeDirectory);
178
+ installerPath = INSTALLER_PATH;
179
+ } else {
180
+ delete childEnvironment.NODE_PATH;
181
+ installerPath = ensureIsolatedInstallerPath();
182
+ }
183
+ return execFileSync('node', [installerPath, ...extraArguments], {
184
+ cwd: dirname(installerPath),
185
+ encoding: 'utf8',
186
+ env: childEnvironment,
187
+ });
188
+ }
189
+
190
+ function readManifest(manifestPath) {
191
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
192
+ }
193
+
194
+ test('a full reinstall over a pre-manifest dirty tree prunes retired skills and keeps personal ones', () => {
195
+ const sandbox = createSandbox();
196
+ try {
197
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
198
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
199
+ }
200
+ for (const personalSkill of PERSONAL_SKILL_DIRECTORIES) {
201
+ plantSkillDirectory(sandbox.skillsDirectory, personalSkill, false);
202
+ }
203
+ assert.equal(existsSync(sandbox.manifestPath), false, 'sandbox starts with no manifest');
204
+
205
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
206
+
207
+ assert.equal(
208
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
209
+ false,
210
+ 'the resolvable-dependency install runs the prune rather than skipping it',
211
+ );
212
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
213
+ assert.equal(
214
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
215
+ false,
216
+ `retired skill ${retiredSkill} should be pruned`,
217
+ );
218
+ assert.equal(
219
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
220
+ true,
221
+ `retired skill ${retiredSkill} should be moved to the prune backup`,
222
+ );
223
+ }
224
+ for (const personalSkill of PERSONAL_SKILL_DIRECTORIES) {
225
+ assert.equal(
226
+ existsSync(join(sandbox.skillsDirectory, personalSkill)),
227
+ true,
228
+ `personal skill ${personalSkill} should survive`,
229
+ );
230
+ }
231
+ assert.equal(
232
+ existsSync(join(sandbox.skillsDirectory, SHIPPED_SKILL_DIRECTORY)),
233
+ true,
234
+ 'shipped skills should install',
235
+ );
236
+ const manifest = readManifest(sandbox.manifestPath);
237
+ assert.ok(Array.isArray(manifest.skills), 'manifest gains a skills array');
238
+ assert.ok(manifest.skills.includes(SHIPPED_SKILL_DIRECTORY), 'manifest skills lists shipped skills');
239
+ assert.equal(manifest.skills.includes('_shared'), false, 'manifest skills omits _shared');
240
+ assert.equal(manifest.skills.includes('__pycache__'), false, 'manifest skills omits __pycache__');
241
+ } finally {
242
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
243
+ }
244
+ });
245
+
246
+ test('a full reinstall over an old-format manifest without a skills key still prunes via the ever-shipped fallback', () => {
247
+ const sandbox = createSandbox();
248
+ try {
249
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
250
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
251
+ }
252
+ writeFileSync(
253
+ sandbox.manifestPath,
254
+ JSON.stringify({
255
+ package: 'claude-dev-env',
256
+ version: '0.0.0',
257
+ installedAt: new Date().toISOString(),
258
+ files: [join(sandbox.skillsDirectory, 'findbugs', 'SKILL.md')],
259
+ }, null, 2) + '\n',
260
+ );
261
+
262
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
263
+
264
+ assert.equal(
265
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
266
+ false,
267
+ 'the resolvable-dependency install runs the prune rather than skipping it',
268
+ );
269
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
270
+ assert.equal(
271
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
272
+ false,
273
+ `retired skill ${retiredSkill} should be pruned via the ever-shipped fallback`,
274
+ );
275
+ assert.equal(
276
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
277
+ true,
278
+ `retired skill ${retiredSkill} should be moved to the prune backup`,
279
+ );
280
+ }
281
+ const manifest = readManifest(sandbox.manifestPath);
282
+ assert.ok(Array.isArray(manifest.skills), 'the reinstall writes the skills key onto the old-format manifest');
283
+ } finally {
284
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
285
+ }
286
+ });
287
+
288
+ test('a full reinstall prunes a manifest-recorded skill absent from the package and keeps a personal directory', () => {
289
+ const sandbox = createSandbox();
290
+ try {
291
+ const retiredManifestSkill = 'ghost-skill';
292
+ const personalSkill = 'balatro';
293
+ plantSkillDirectory(sandbox.skillsDirectory, retiredManifestSkill, true);
294
+ plantSkillDirectory(sandbox.skillsDirectory, personalSkill, false);
295
+ writeFileSync(
296
+ sandbox.manifestPath,
297
+ JSON.stringify({
298
+ package: 'claude-dev-env',
299
+ version: '0.0.0',
300
+ installedAt: new Date().toISOString(),
301
+ files: [],
302
+ skills: [SHIPPED_SKILL_DIRECTORY, retiredManifestSkill],
303
+ }, null, 2) + '\n',
304
+ );
305
+
306
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
307
+
308
+ assert.equal(
309
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
310
+ false,
311
+ 'the resolvable-dependency install runs the prune rather than skipping it',
312
+ );
313
+ assert.equal(
314
+ existsSync(join(sandbox.skillsDirectory, retiredManifestSkill)),
315
+ false,
316
+ 'a skill the prior manifest recorded but the package no longer ships is pruned',
317
+ );
318
+ assert.equal(
319
+ prunedBackupContains(sandbox.claudeDirectory, retiredManifestSkill),
320
+ true,
321
+ 'the manifest-recorded retired skill is moved to the prune backup',
322
+ );
323
+ assert.equal(
324
+ existsSync(join(sandbox.skillsDirectory, personalSkill)),
325
+ true,
326
+ 'a personal directory in neither the manifest nor the ever-shipped set survives',
327
+ );
328
+ } finally {
329
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
330
+ }
331
+ });
332
+
333
+ test('a scoped --only install leaves retired skills in place because prune runs on full installs only', () => {
334
+ const sandbox = createSandbox();
335
+ try {
336
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
337
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
338
+ }
339
+
340
+ runInstaller(sandbox.homeDirectory, ['--only', 'core']);
341
+
342
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
343
+ assert.equal(
344
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
345
+ true,
346
+ `retired skill ${retiredSkill} should survive a scoped install`,
347
+ );
348
+ assert.equal(
349
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
350
+ false,
351
+ `retired skill ${retiredSkill} should not be moved to backup by a scoped install`,
352
+ );
353
+ }
354
+ } finally {
355
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
356
+ }
357
+ });
358
+
359
+ test('a full reinstall keeps pr-fix-protocol because the package ships it again', () => {
360
+ const sandbox = createSandbox();
361
+ try {
362
+ plantSkillDirectory(sandbox.skillsDirectory, 'pr-fix-protocol', true);
363
+ writeFileSync(join(sandbox.skillsDirectory, 'pr-fix-protocol', 'SKILL.md'), 'stale seeded copy\n');
364
+
365
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
366
+
367
+ assert.equal(
368
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
369
+ false,
370
+ 'the resolvable-dependency install runs the prune rather than skipping it',
371
+ );
372
+ const restoredSkillPath = join(sandbox.skillsDirectory, 'pr-fix-protocol', 'SKILL.md');
373
+ assert.equal(existsSync(restoredSkillPath), true, 'pr-fix-protocol survives and is reinstalled');
374
+ assert.notEqual(
375
+ readFileSync(restoredSkillPath, 'utf8'),
376
+ 'stale seeded copy\n',
377
+ 'the shipped pr-fix-protocol overwrites the stale seeded copy',
378
+ );
379
+ } finally {
380
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
381
+ }
382
+ });
383
+
384
+ test('a full reinstall with an unresolved dependency skips the prune and leaves retired skills untouched', () => {
385
+ const sandbox = createSandbox();
386
+ try {
387
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
388
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
389
+ }
390
+
391
+ const installerOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: false });
392
+
393
+ assert.equal(
394
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
395
+ true,
396
+ 'the installer logs a notice that it is skipping the prune',
397
+ );
398
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
399
+ assert.equal(
400
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
401
+ true,
402
+ `retired skill ${retiredSkill} should survive when a dependency is unresolved`,
403
+ );
404
+ assert.equal(
405
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
406
+ false,
407
+ `retired skill ${retiredSkill} should not be moved to backup when the prune is skipped`,
408
+ );
409
+ }
410
+ } finally {
411
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
412
+ }
413
+ });
414
+
415
+ test('the prune skip is scoped: once the dependency resolves a later full install prunes normally', () => {
416
+ const sandbox = createSandbox();
417
+ try {
418
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
419
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
420
+ }
421
+
422
+ const skippedOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: false });
423
+ assert.equal(
424
+ skippedOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
425
+ true,
426
+ 'the unresolved-dependency install skips the prune',
427
+ );
428
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
429
+ assert.equal(
430
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
431
+ true,
432
+ `retired skill ${retiredSkill} should still be present after the skipped prune`,
433
+ );
434
+ }
435
+
436
+ const resolvedOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: true });
437
+ assert.equal(
438
+ resolvedOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
439
+ false,
440
+ 'the resolved-dependency install runs the prune, proving the skip is not a permanent disable',
441
+ );
442
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
443
+ assert.equal(
444
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
445
+ false,
446
+ `retired skill ${retiredSkill} should be pruned once the dependency resolves`,
447
+ );
448
+ assert.equal(
449
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
450
+ true,
451
+ `retired skill ${retiredSkill} should be moved to the prune backup on the resolved install`,
452
+ );
453
+ }
454
+ } finally {
455
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
456
+ }
457
+ });