claude-dev-env 2.9.0 → 2.10.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 (289) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/CLAUDE.md +1 -1
  3. package/_shared/pr-loop/audit-contract.md +17 -6
  4. package/_shared/pr-loop/audit-reply-template.md +4 -4
  5. package/_shared/pr-loop/code-rules-gate.md +3 -5
  6. package/_shared/pr-loop/fix-protocol.md +2 -3
  7. package/_shared/pr-loop/gh-payloads.md +1 -1
  8. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  9. package/_shared/pr-loop/scripts/README.md +1 -1
  10. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
  13. package/_shared/pr-loop/scripts/preflight.py +9 -4
  14. package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
  15. package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
  17. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
  18. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  19. package/_shared/pr-loop/state-schema.md +5 -14
  20. package/agents/CLAUDE.md +2 -2
  21. package/agents/clean-coder.md +58 -548
  22. package/agents/code-quality-agent.md +10 -2
  23. package/agents/code-verifier.md +1 -1
  24. package/agents/test_agent_frontmatter.py +32 -40
  25. package/audit-rubrics/CLAUDE.md +2 -1
  26. package/audit-rubrics/audit-categories.json +704 -0
  27. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  28. package/bin/CLAUDE.md +16 -5
  29. package/bin/ever-shipped-skills.mjs +2 -0
  30. package/bin/install-plan.mjs +402 -0
  31. package/bin/install-transaction.mjs +455 -0
  32. package/bin/install.mjs +593 -147
  33. package/bin/install.plan.test.mjs +194 -0
  34. package/bin/install.profile-root.test.mjs +154 -0
  35. package/bin/install.profiles.test.mjs +253 -0
  36. package/bin/install.settings-defaults.test.mjs +200 -0
  37. package/bin/install.transaction.test.mjs +400 -0
  38. package/bin/install.uninstall-transaction.test.mjs +418 -0
  39. package/bin/merge_managed_permissions.mjs +130 -0
  40. package/bin/resolve-install-root.mjs +181 -0
  41. package/bin/select-install-targets.mjs +401 -0
  42. package/commands/CLAUDE.md +0 -2
  43. package/docs/references/CLAUDE.md +1 -0
  44. package/docs/references/advisor-tool.md +1 -1
  45. package/docs/references/prose-style-enforcement.md +25 -0
  46. package/hooks/blocking/CLAUDE.md +6 -6
  47. package/hooks/blocking/_path_setup.py +9 -5
  48. package/hooks/blocking/code_rules_docstrings.py +124 -30
  49. package/hooks/blocking/code_rules_enforcer.py +161 -16
  50. package/hooks/blocking/code_rules_shared.py +40 -23
  51. package/hooks/blocking/config/CLAUDE.md +3 -5
  52. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  53. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  54. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  55. package/hooks/blocking/hedging_language_blocker.py +103 -20
  56. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  57. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  58. package/hooks/blocking/plain_language_blocker.py +139 -20
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  60. package/hooks/blocking/state_description_blocker.py +7 -1
  61. package/hooks/blocking/tdd_enforcer.py +8 -0
  62. package/hooks/blocking/test__path_setup.py +28 -0
  63. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  64. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  65. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  66. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  67. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  68. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  69. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  70. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  71. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  72. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  73. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  74. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  75. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  76. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  77. package/hooks/blocking/test_state_description_blocker.py +45 -2
  78. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  79. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  80. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  81. package/hooks/hooks.json +15 -0
  82. package/hooks/hooks_constants/CLAUDE.md +14 -3
  83. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  84. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  85. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  86. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  87. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  88. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  89. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  90. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  91. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  92. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  93. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  94. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  95. package/hooks/hooks_constants/session_start_injector.py +163 -0
  96. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  97. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  98. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  99. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  100. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  101. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  102. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  103. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  104. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  105. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  106. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  107. package/hooks/observability/CLAUDE.md +2 -0
  108. package/hooks/observability/prose_matcher_advisory.py +237 -0
  109. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  110. package/hooks/session/CLAUDE.md +9 -1
  111. package/hooks/session/_path_setup.py +13 -0
  112. package/hooks/session/issue_tracker_session_starter.py +135 -0
  113. package/hooks/session/orchestrator_auto_starter.py +100 -0
  114. package/hooks/session/test__path_setup.py +28 -0
  115. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  116. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  117. package/hooks/session/test_working_style_prompt.py +47 -0
  118. package/hooks/session/untracked_repo_detector.py +1 -24
  119. package/hooks/session/working_style_prompt.py +36 -0
  120. package/hooks/validators/_path_setup.py +19 -0
  121. package/hooks/validators/run_all_validators.py +8 -13
  122. package/installable-surfaces.manifest.json +21 -0
  123. package/output-styles/CLAUDE.md +1 -3
  124. package/package.json +4 -2
  125. package/rules/CLAUDE.md +1 -0
  126. package/rules/durable-post-artifacts.md +2 -2
  127. package/rules/eli11-replies.md +6 -1
  128. package/rules/hedging-claims.md +4 -2
  129. package/rules/long-horizon-autonomy.md +3 -1
  130. package/rules/opus5-communication-contract.md +45 -0
  131. package/rules/plain-language.md +2 -2
  132. package/rules/research-mode.md +1 -1
  133. package/scripts/CLAUDE.md +11 -0
  134. package/scripts/Sync-RepoMain.ps1 +215 -0
  135. package/scripts/active_capability_references.py +218 -0
  136. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  137. package/scripts/claude_chain_runner.py +394 -6
  138. package/scripts/claude_chain_usage.py +1 -1
  139. package/scripts/codex_compat_materializer.py +105 -85
  140. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  141. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  142. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  143. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  144. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  145. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  146. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  147. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  148. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  149. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  150. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  151. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  152. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  153. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  154. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  155. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  156. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  157. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  158. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  159. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  160. package/scripts/spawn_grok_batch.py +3 -0
  161. package/scripts/test_active_capability_references.py +108 -0
  162. package/scripts/test_claude_chain_runner.py +414 -82
  163. package/scripts/test_claude_chain_usage.py +12 -12
  164. package/scripts/test_resolve_worker_spawn.py +2 -2
  165. package/scripts/test_verify_installable_package.py +208 -0
  166. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  167. package/scripts/verify_installable_package.py +612 -0
  168. package/settings.json +10 -0
  169. package/skills/CLAUDE.md +2 -0
  170. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  171. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  172. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  173. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  174. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  175. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  176. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  177. package/skills/autoconverge/reference/convergence.md +2 -1
  178. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  179. package/skills/beat-sheet/SKILL.md +54 -0
  180. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  181. package/skills/bugteam/CONSTRAINTS.md +4 -4
  182. package/skills/bugteam/EXAMPLES.md +1 -1
  183. package/skills/bugteam/reference/README.md +1 -1
  184. package/skills/e-code-review/SKILL.md +26 -5
  185. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  186. package/skills/e-code-review/reference/medium.md +15 -4
  187. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  188. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  189. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  190. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  191. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  192. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  193. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  194. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  195. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  196. package/skills/e-code-review/scripts/grok_code_review.py +16 -7
  197. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  198. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  199. package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
  200. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  201. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  202. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  203. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  204. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  205. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  206. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  207. package/skills/pr-converge/scripts/conftest.py +35 -0
  208. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  209. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  210. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  211. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  212. package/skills/rebase/SKILL.md +15 -3
  213. package/skills/reviewer-gates/SKILL.md +2 -2
  214. package/skills/show/SKILL.md +51 -0
  215. package/skills/show/references/accessibility.md +7 -0
  216. package/skills/show/references/art.md +3 -0
  217. package/skills/show/references/charts.md +3 -0
  218. package/skills/show/references/core-design.md +14 -0
  219. package/skills/show/references/erds.md +3 -0
  220. package/skills/show/references/flowcharts.md +3 -0
  221. package/skills/show/references/host-and-html.md +3 -0
  222. package/skills/show/references/illustrative-diagrams.md +10 -0
  223. package/skills/show/references/interaction.md +3 -0
  224. package/skills/show/references/mockups.md +3 -0
  225. package/skills/show/references/quality-gates.md +7 -0
  226. package/skills/show/references/structural-diagrams.md +3 -0
  227. package/skills/show/references/subject-inventory.md +21 -0
  228. package/skills/show/references/svg-contract.md +22 -0
  229. package/skills/show/routing.yaml +30 -0
  230. package/skills/show/samples/pr1262-v2.svg +222 -0
  231. package/skills/show/scripts/README.md +6 -0
  232. package/skills/show/scripts/validate-artifact.py +91 -0
  233. package/skills/show/scripts/validate-package.py +18 -0
  234. package/skills/show/templates/html-widget.html +4 -0
  235. package/skills/show/templates/svg-base.svg +19 -0
  236. package/skills/show/tests/fixtures/css-var.svg +6 -0
  237. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  238. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  239. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  240. package/skills/show/tests/fixtures/invalid.svg +1 -0
  241. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  242. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  243. package/skills/show/tests/fixtures/valid.html +1 -0
  244. package/skills/show/tests/test_validate-artifact.py +74 -0
  245. package/skills/show/tests/test_validators.py +59 -0
  246. package/skills/show/workflows/create-visual.md +13 -0
  247. package/skills/show/workflows/review-visual.md +20 -0
  248. package/skills/split-pr/SKILL.md +85 -0
  249. package/skills/split-pr/reference/path-layers.md +16 -0
  250. package/skills/split-pr/reference/proposal-format.md +15 -0
  251. package/skills/split-pr/reference/split-further-loop.md +10 -0
  252. package/skills/split-pr/reference/splitting-principles.md +26 -0
  253. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  254. package/skills/split-pr/scripts/categorize_files.py +106 -0
  255. package/skills/split-pr/scripts/config/__init__.py +1 -0
  256. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  257. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  258. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  259. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  260. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  261. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  262. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  263. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  264. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  265. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  266. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  267. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  268. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  269. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  270. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  271. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  272. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  273. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  274. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  275. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  276. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  277. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  278. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  279. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  280. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  281. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  282. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  283. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  284. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  285. package/skills/split-pr/scripts/verify_plan.py +139 -0
  286. package/system-prompts/software-engineer.xml +11 -2
  287. package/commands/initialize.md +0 -90
  288. package/commands/stubcheck.md +0 -88
  289. package/output-styles/caveman-agent.md +0 -37
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Read-only installation plan and preflight contract (control-plane E1 / P-17).
3
+ */
4
+
5
+ import { test } from 'node:test';
6
+ import { strict as assert } from 'node:assert';
7
+ import {
8
+ mkdtempSync,
9
+ rmSync,
10
+ mkdirSync,
11
+ writeFileSync,
12
+ readFileSync,
13
+ existsSync,
14
+ readdirSync,
15
+ statSync,
16
+ } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import {
20
+ buildInstallPlan,
21
+ InstallPlanPreflightError,
22
+ PREFLIGHT_ERROR_CODES,
23
+ planShouldInstallHooks,
24
+ describeInstallMutations,
25
+ readPriorManifestArraysFromPath,
26
+ } from './install-plan.mjs';
27
+
28
+ const GROUPS = {
29
+ core: { skills: ['a'], includeDirectories: ['rules'], includeAllHooks: true },
30
+ journal: { skills: ['session-log'] },
31
+ };
32
+
33
+ function sandbox() {
34
+ const root = mkdtempSync(join(tmpdir(), 'cdev-install-plan-'));
35
+ const managedRoot = join(root, 'managed');
36
+ const packageRoot = join(root, 'package');
37
+ mkdirSync(managedRoot, { recursive: true });
38
+ mkdirSync(packageRoot, { recursive: true });
39
+ return { root, managedRoot, packageRoot };
40
+ }
41
+
42
+ function treeStamp(directoryPath) {
43
+ if (!existsSync(directoryPath)) {
44
+ return '';
45
+ }
46
+ const allParts = [];
47
+ function walk(currentPath) {
48
+ const stats = statSync(currentPath);
49
+ if (stats.isDirectory()) {
50
+ for (const eachName of readdirSync(currentPath).sort()) {
51
+ walk(join(currentPath, eachName));
52
+ }
53
+ return;
54
+ }
55
+ allParts.push(`${currentPath}:${stats.size}:${stats.mtimeMs}`);
56
+ }
57
+ walk(directoryPath);
58
+ return allParts.join('|');
59
+ }
60
+
61
+ function planInput(packageRoot, managedRoot, overrides = {}) {
62
+ return {
63
+ packageRoot,
64
+ managedRoot,
65
+ manifestFilePath: join(managedRoot, '.claude-dev-env-manifest.json'),
66
+ targetIdentity: 'main',
67
+ selectedGroups: null,
68
+ isUpdateRefresh: false,
69
+ installGroups: GROUPS,
70
+ collectSourceConflicts: () => [],
71
+ detectPythonCommand: () => 'python3',
72
+ ...overrides,
73
+ };
74
+ }
75
+
76
+ test('planShouldInstallHooks covers full, core, and journal', () => {
77
+ assert.equal(planShouldInstallHooks(null, GROUPS), true);
78
+ assert.equal(planShouldInstallHooks(['core'], GROUPS), true);
79
+ assert.equal(planShouldInstallHooks(['journal'], GROUPS), false);
80
+ });
81
+
82
+ test('buildInstallPlan is zero-write and freezes a success plan', () => {
83
+ const { root, managedRoot, packageRoot } = sandbox();
84
+ try {
85
+ writeFileSync(join(managedRoot, 'settings.json'), '{"hooks":{}}\n');
86
+ writeFileSync(
87
+ join(managedRoot, '.claude-dev-env-manifest.json'),
88
+ JSON.stringify({ files: ['a'], skills: ['a'] }) + '\n',
89
+ );
90
+ const before = treeStamp(managedRoot) + '//' + treeStamp(packageRoot);
91
+ const plan = buildInstallPlan(planInput(packageRoot, managedRoot));
92
+ assert.equal(plan.pythonCommand, 'python3');
93
+ assert.equal(plan.shouldInstallHooks, true);
94
+ assert.equal(plan.shouldPurgeBeforeReinstall, false);
95
+ assert.deepEqual(plan.priorManifest.skills, ['a']);
96
+ assert.ok(Object.isFrozen(plan));
97
+ assert.equal(treeStamp(managedRoot) + '//' + treeStamp(packageRoot), before);
98
+ } finally {
99
+ rmSync(root, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test('preflight fails closed without writing for conflicts, python, and bad root', () => {
104
+ const { root, managedRoot, packageRoot } = sandbox();
105
+ try {
106
+ writeFileSync(join(managedRoot, 'marker.txt'), 'keep\n');
107
+ const before = treeStamp(managedRoot);
108
+ assert.throws(
109
+ () => buildInstallPlan(planInput(packageRoot, managedRoot, {
110
+ isUpdateRefresh: true,
111
+ collectSourceConflicts: () => [{ statusCode: 'UU', path: 'x.md' }],
112
+ })),
113
+ (error) => error instanceof InstallPlanPreflightError
114
+ && error.code === PREFLIGHT_ERROR_CODES.SOURCE_CONFLICTS,
115
+ );
116
+ assert.throws(
117
+ () => buildInstallPlan(planInput(packageRoot, managedRoot, {
118
+ detectPythonCommand: () => null,
119
+ })),
120
+ (error) => error instanceof InstallPlanPreflightError
121
+ && error.code === PREFLIGHT_ERROR_CODES.MISSING_PYTHON,
122
+ );
123
+ assert.equal(treeStamp(managedRoot), before);
124
+ assert.equal(readFileSync(join(managedRoot, 'marker.txt'), 'utf8'), 'keep\n');
125
+ const fileAsRoot = join(root, 'file-root');
126
+ writeFileSync(fileAsRoot, 'x\n');
127
+ assert.throws(
128
+ () => buildInstallPlan(planInput(packageRoot, fileAsRoot)),
129
+ (error) => error.code === PREFLIGHT_ERROR_CODES.INVALID_MANAGED_ROOT,
130
+ );
131
+ assert.throws(
132
+ () => buildInstallPlan(planInput(packageRoot, '')),
133
+ (error) => error.code === PREFLIGHT_ERROR_CODES.INVALID_MANAGED_ROOT,
134
+ );
135
+ } finally {
136
+ rmSync(root, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test('settings preflight is hooks-gated; broken manifest stays tolerant', () => {
141
+ const { root, managedRoot, packageRoot } = sandbox();
142
+ try {
143
+ writeFileSync(join(managedRoot, 'settings.json'), '{not-json');
144
+ assert.throws(
145
+ () => buildInstallPlan(planInput(packageRoot, managedRoot, { selectedGroups: ['core'] })),
146
+ (error) => error.code === PREFLIGHT_ERROR_CODES.MALFORMED_SETTINGS,
147
+ );
148
+ assert.equal(
149
+ buildInstallPlan(planInput(packageRoot, managedRoot, { selectedGroups: ['journal'] }))
150
+ .shouldInstallHooks,
151
+ false,
152
+ );
153
+ const manifestFilePath = join(managedRoot, '.claude-dev-env-manifest.json');
154
+ writeFileSync(manifestFilePath, '{broken');
155
+ const plan = buildInstallPlan(planInput(packageRoot, managedRoot, {
156
+ selectedGroups: ['journal'],
157
+ manifestFilePath,
158
+ }));
159
+ assert.deepEqual(plan.priorManifest, { files: null, skills: null });
160
+ assert.deepEqual(readPriorManifestArraysFromPath(manifestFilePath), {
161
+ files: null,
162
+ skills: null,
163
+ });
164
+ } finally {
165
+ rmSync(root, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ test('update plan marks purge and lists E2 mutation kinds; journal omits hooks', () => {
170
+ const { root, managedRoot, packageRoot } = sandbox();
171
+ try {
172
+ const manifestFilePath = join(managedRoot, '.claude-dev-env-manifest.json');
173
+ writeFileSync(manifestFilePath, JSON.stringify({ files: ['a'], skills: [] }) + '\n');
174
+ writeFileSync(join(managedRoot, 'settings.json'), '{}\n');
175
+ const plan = buildInstallPlan(planInput(packageRoot, managedRoot, {
176
+ manifestFilePath,
177
+ targetIdentity: 'profile-a',
178
+ isUpdateRefresh: true,
179
+ }));
180
+ assert.equal(plan.shouldPurgeBeforeReinstall, true);
181
+ const allMutations = describeInstallMutations(plan);
182
+ assert.ok(allMutations.includes('purge_managed_installation'));
183
+ assert.ok(allMutations.includes('merge_hooks_settings'));
184
+ assert.ok(allMutations.includes('write_manifest'));
185
+ assert.ok(Object.isFrozen(allMutations));
186
+ const journalMutations = describeInstallMutations(
187
+ buildInstallPlan(planInput(packageRoot, managedRoot, { selectedGroups: ['journal'] })),
188
+ );
189
+ assert.ok(!journalMutations.includes('merge_hooks_settings'));
190
+ assert.ok(journalMutations.includes('copy_content_trees'));
191
+ } finally {
192
+ rmSync(root, { recursive: true, force: true });
193
+ }
194
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Install-root resolver contract tests (control-plane A2 / P-11).
3
+ */
4
+
5
+ import { test } from 'node:test';
6
+ import { strict as assert } from 'node:assert';
7
+ import { join, resolve } from 'node:path';
8
+ import { mkdtempSync, rmSync, mkdirSync, readFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+ import {
12
+ resolveInstallRoot,
13
+ isPathWithinManagedRoot,
14
+ isAllowedInstallDestination,
15
+ parseExplicitTargetFromArgv,
16
+ CLAUDE_CONFIG_DIR_ENVIRONMENT_VARIABLE,
17
+ DEFAULT_CLAUDE_DIRECTORY_NAME,
18
+ } from './resolve-install-root.mjs';
19
+
20
+ const BIN_DIRECTORY = fileURLToPath(new URL('.', import.meta.url));
21
+ const INSTALL_MODULE_PATH = join(BIN_DIRECTORY, 'install.mjs');
22
+
23
+ test('default target remains join(homeDirectory, .claude)', () => {
24
+ const homeDirectory = resolve(join(tmpdir(), 'a2-home-default'));
25
+ const resolution = resolveInstallRoot({
26
+ homeDirectory,
27
+ environment: {},
28
+ explicitTarget: null,
29
+ });
30
+ assert.equal(resolution.managedRoot, resolve(join(homeDirectory, DEFAULT_CLAUDE_DIRECTORY_NAME)));
31
+ assert.equal(resolution.source, 'default-home');
32
+ });
33
+
34
+ test('CLAUDE_CONFIG_DIR selects the profile root over the home default', () => {
35
+ const homeDirectory = resolve(join(tmpdir(), 'a2-home-config'));
36
+ const profileRoot = resolve(join(tmpdir(), 'a2-profile-a'));
37
+ const resolution = resolveInstallRoot({
38
+ homeDirectory,
39
+ environment: { [CLAUDE_CONFIG_DIR_ENVIRONMENT_VARIABLE]: profileRoot },
40
+ explicitTarget: null,
41
+ });
42
+ assert.equal(resolution.managedRoot, profileRoot);
43
+ assert.equal(resolution.source, 'claude-config-dir');
44
+ assert.notEqual(
45
+ resolution.managedRoot,
46
+ resolve(join(homeDirectory, DEFAULT_CLAUDE_DIRECTORY_NAME)),
47
+ );
48
+ });
49
+
50
+ test('explicit target has precedence over CLAUDE_CONFIG_DIR and the home default', () => {
51
+ const homeDirectory = resolve(join(tmpdir(), 'a2-home-explicit'));
52
+ const profileRoot = resolve(join(tmpdir(), 'a2-profile-b'));
53
+ const explicitTarget = resolve(join(tmpdir(), 'a2-explicit-target'));
54
+ const resolution = resolveInstallRoot({
55
+ homeDirectory,
56
+ environment: { [CLAUDE_CONFIG_DIR_ENVIRONMENT_VARIABLE]: profileRoot },
57
+ explicitTarget,
58
+ });
59
+ assert.equal(resolution.managedRoot, explicitTarget);
60
+ assert.equal(resolution.source, 'explicit-target');
61
+ });
62
+
63
+ test('isPathWithinManagedRoot requires a separator boundary', () => {
64
+ const managedRoot = resolve(join(tmpdir(), 'containment-root', '.claude'));
65
+ const insideFile = join(managedRoot, 'rules', 'plain-language.md');
66
+ const evilSibling = `${managedRoot}-evil`;
67
+ const evilChild = join(`${managedRoot}-evil`, 'rules', 'x.md');
68
+
69
+ assert.equal(isPathWithinManagedRoot(managedRoot, managedRoot), true);
70
+ assert.equal(isPathWithinManagedRoot(insideFile, managedRoot), true);
71
+ assert.equal(isPathWithinManagedRoot(evilSibling, managedRoot), false);
72
+ assert.equal(isPathWithinManagedRoot(evilChild, managedRoot), false);
73
+ });
74
+
75
+ test('declared external mypy.ini is allowed; unrelated external paths are not', () => {
76
+ const homeDirectory = resolve(join(tmpdir(), 'a2-home-external'));
77
+ const resolution = resolveInstallRoot({
78
+ homeDirectory,
79
+ environment: {},
80
+ });
81
+ assert.equal(
82
+ isAllowedInstallDestination(resolution.mypyIniInstallPath, resolution),
83
+ true,
84
+ );
85
+ assert.equal(
86
+ isAllowedInstallDestination(join(homeDirectory, '.ssh', 'id_rsa'), resolution),
87
+ false,
88
+ );
89
+ assert.equal(
90
+ isAllowedInstallDestination(join(resolution.managedRoot, 'hooks', 'x.py'), resolution),
91
+ true,
92
+ );
93
+ });
94
+
95
+ test('parseExplicitTargetFromArgv reads --target and --target=', () => {
96
+ assert.equal(parseExplicitTargetFromArgv(['--only', 'core']), null);
97
+ assert.equal(parseExplicitTargetFromArgv(['--target', 'C:\\tmp\\root']), 'C:\\tmp\\root');
98
+ assert.equal(parseExplicitTargetFromArgv(['--target=C:\\tmp\\root2']), 'C:\\tmp\\root2');
99
+ assert.throws(
100
+ () => parseExplicitTargetFromArgv(['--target']),
101
+ /--target requires a path argument/,
102
+ );
103
+ });
104
+
105
+ test('installer destinations for disposable main, profile-a, and profile-b stay inside the managed root', () => {
106
+ const runRoot = mkdtempSync(join(tmpdir(), 'a2-disposable-'));
107
+ try {
108
+ for (const eachProfileId of ['main', 'profile-a', 'profile-b']) {
109
+ const profileRoot = join(runRoot, eachProfileId);
110
+ mkdirSync(profileRoot, { recursive: true });
111
+ const homeDirectory = join(runRoot, `${eachProfileId}-home`);
112
+ mkdirSync(homeDirectory, { recursive: true });
113
+
114
+ const resolution = resolveInstallRoot({
115
+ homeDirectory,
116
+ environment: { [CLAUDE_CONFIG_DIR_ENVIRONMENT_VARIABLE]: profileRoot },
117
+ });
118
+ assert.equal(resolution.managedRoot, resolve(profileRoot));
119
+ assert.equal(resolution.source, 'claude-config-dir');
120
+
121
+ const allManagedWritePaths = [
122
+ join(resolution.managedRoot, 'rules', 'sample.md'),
123
+ join(resolution.managedRoot, 'hooks', 'blocking', 'x.py'),
124
+ join(resolution.managedRoot, 'CLAUDE.md'),
125
+ resolution.manifestFilePath,
126
+ ];
127
+ for (const eachWritePath of allManagedWritePaths) {
128
+ assert.equal(
129
+ isAllowedInstallDestination(eachWritePath, resolution),
130
+ true,
131
+ `${eachProfileId}: ${eachWritePath}`,
132
+ );
133
+ }
134
+ assert.equal(
135
+ isAllowedInstallDestination(resolution.mypyIniInstallPath, resolution),
136
+ true,
137
+ );
138
+ }
139
+ } finally {
140
+ rmSync(runRoot, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test('install.mjs module resolves CLAUDE_HOME through the shared resolver (import smoke)', async () => {
145
+ // Guard: the install module must import resolve-install-root so destinations
146
+ // cannot bypass the resolver by re-hardcoding join(homedir(), '.claude').
147
+ const source = readFileSync(INSTALL_MODULE_PATH, 'utf8');
148
+ assert.match(source, /resolve-install-root\.mjs/);
149
+ assert.match(source, /resolveInstallRoot/);
150
+ assert.ok(
151
+ !/const CLAUDE_HOME = join\(homedir\(\), ['"]\.claude['"]\)/.test(source),
152
+ 'install.mjs must not hardcode CLAUDE_HOME = join(homedir(), \'.claude\')',
153
+ );
154
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Profile target-selection and per-target manifest contract (control-plane A3 / P-12).
3
+ */
4
+
5
+ import { test } from 'node:test';
6
+ import { strict as assert } from 'node:assert';
7
+ import { join, resolve } from 'node:path';
8
+ import {
9
+ mkdtempSync,
10
+ rmSync,
11
+ mkdirSync,
12
+ writeFileSync,
13
+ readFileSync,
14
+ existsSync,
15
+ } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { spawnSync } from 'node:child_process';
19
+ import {
20
+ parseInstallTargetSelectionFromArgv,
21
+ resolveInstallTargets,
22
+ buildTargetManifestRecord,
23
+ stripTargetSelectionFlagsFromArgv,
24
+ resolveProfilesRootDirectory,
25
+ assertSafeProfileDirectoryName,
26
+ MAIN_DEFAULT_TARGET_IDENTITY,
27
+ DEFAULT_PROFILES_ROOT_DIRECTORY_NAME,
28
+ } from './select-install-targets.mjs';
29
+ import { MANIFEST_FILE_NAME } from './resolve-install-root.mjs';
30
+
31
+ const BIN_DIRECTORY = fileURLToPath(new URL('.', import.meta.url));
32
+ const INSTALL_MODULE_PATH = join(BIN_DIRECTORY, 'install.mjs');
33
+
34
+ const DIRECTORY_NAME_BY_PROFILE_ID = {
35
+ editor: 'editor',
36
+ mel: 'mel',
37
+ ev: 'ev',
38
+ master: 'master',
39
+ kimi: 'kimi',
40
+ };
41
+
42
+ test('main-default selection resolves a single main target under home/.claude', () => {
43
+ const homeDirectory = resolve(join(tmpdir(), 'a3-home-main'));
44
+ const selection = parseInstallTargetSelectionFromArgv([]);
45
+ assert.equal(selection.mode, 'main-default');
46
+ const allTargets = resolveInstallTargets(selection, {
47
+ homeDirectory,
48
+ environment: {},
49
+ directoryNameByProfileId: DIRECTORY_NAME_BY_PROFILE_ID,
50
+ });
51
+ assert.equal(allTargets.length, 1);
52
+ assert.equal(allTargets[0].targetIdentity, MAIN_DEFAULT_TARGET_IDENTITY);
53
+ assert.equal(allTargets[0].managedRoot, resolve(join(homeDirectory, '.claude')));
54
+ assert.equal(allTargets[0].source, 'default-home');
55
+ });
56
+
57
+ test('explicit --target is single-path mode and rejects combined --profile', () => {
58
+ const selection = parseInstallTargetSelectionFromArgv(['--target', 'C:\\tmp\\a3-root']);
59
+ assert.equal(selection.mode, 'explicit-path');
60
+ assert.equal(selection.explicitTarget, 'C:\\tmp\\a3-root');
61
+ assert.throws(
62
+ () => parseInstallTargetSelectionFromArgv(['--target', 'C:\\tmp\\x', '--profile', 'editor']),
63
+ /ambiguous targets/,
64
+ );
65
+ });
66
+
67
+ test('selected multi-profile resolves one managed root per id under profiles root', () => {
68
+ const homeDirectory = resolve(join(tmpdir(), 'a3-home-multi'));
69
+ const profilesRoot = resolve(join(tmpdir(), 'a3-profiles-root'));
70
+ const selection = parseInstallTargetSelectionFromArgv(['--profiles', 'editor,mel']);
71
+ assert.equal(selection.mode, 'profiles');
72
+ assert.deepEqual(selection.allProfileIds, ['editor', 'mel']);
73
+ const allTargets = resolveInstallTargets(selection, {
74
+ homeDirectory,
75
+ environment: {},
76
+ profilesRoot,
77
+ directoryNameByProfileId: DIRECTORY_NAME_BY_PROFILE_ID,
78
+ });
79
+ assert.equal(allTargets.length, 2);
80
+ assert.equal(allTargets[0].targetIdentity, 'editor');
81
+ assert.equal(allTargets[0].managedRoot, resolve(join(profilesRoot, 'editor')));
82
+ assert.equal(allTargets[1].targetIdentity, 'mel');
83
+ assert.equal(allTargets[1].managedRoot, resolve(join(profilesRoot, 'mel')));
84
+ });
85
+
86
+ test('duplicate profile ids and unknown ids are rejected before mutation', () => {
87
+ assert.throws(
88
+ () => parseInstallTargetSelectionFromArgv(['--profiles', 'editor,editor']),
89
+ /duplicate profile id/,
90
+ );
91
+ const selection = parseInstallTargetSelectionFromArgv(['--profile', 'does-not-exist']);
92
+ assert.throws(
93
+ () => resolveInstallTargets(selection, {
94
+ homeDirectory: resolve(join(tmpdir(), 'a3-home-unknown')),
95
+ profilesRoot: resolve(join(tmpdir(), 'a3-pr')),
96
+ directoryNameByProfileId: DIRECTORY_NAME_BY_PROFILE_ID,
97
+ }),
98
+ /unknown profile id: does-not-exist/,
99
+ );
100
+ });
101
+
102
+ test('child hop with --target-identity rejects residual --profile flags', () => {
103
+ assert.throws(
104
+ () => parseInstallTargetSelectionFromArgv([
105
+ '--target', 'C:\\tmp\\child',
106
+ '--target-identity', 'editor',
107
+ '--profile', 'mel',
108
+ ]),
109
+ /child install hop rejects/,
110
+ );
111
+ });
112
+
113
+ test('profile directoryName rejects absolute paths and parent segments', () => {
114
+ assert.throws(
115
+ () => assertSafeProfileDirectoryName('C:\\evil'),
116
+ /must be relative/,
117
+ );
118
+ assert.throws(
119
+ () => assertSafeProfileDirectoryName('..\\escape'),
120
+ /parent segments/,
121
+ );
122
+ assert.equal(assertSafeProfileDirectoryName('editor'), 'editor');
123
+ });
124
+
125
+ test('buildTargetManifestRecord records package, version, identity, root, files, skills', () => {
126
+ const managedRoot = resolve(join(tmpdir(), 'a3-manifest-root'));
127
+ const manifest = buildTargetManifestRecord({
128
+ packageName: 'claude-dev-env',
129
+ packageVersion: '9.9.9-test',
130
+ targetIdentity: 'editor',
131
+ managedRoot,
132
+ files: [join(managedRoot, 'rules', 'x.md')],
133
+ skills: ['privacy-hygiene'],
134
+ installedAt: '2026-07-30T00:00:00.000Z',
135
+ });
136
+ assert.equal(manifest.package, 'claude-dev-env');
137
+ assert.equal(manifest.version, '9.9.9-test');
138
+ assert.equal(manifest.targetIdentity, 'editor');
139
+ assert.equal(manifest.managedRoot, managedRoot);
140
+ assert.deepEqual(manifest.files, [join(managedRoot, 'rules', 'x.md')]);
141
+ assert.deepEqual(manifest.skills, ['privacy-hygiene']);
142
+ assert.equal(manifest.installedAt, '2026-07-30T00:00:00.000Z');
143
+ });
144
+
145
+ test('stripTargetSelectionFlagsFromArgv leaves install groups flags', () => {
146
+ assert.deepEqual(
147
+ stripTargetSelectionFlagsFromArgv([
148
+ '--profiles', 'editor,mel',
149
+ '--only', 'core',
150
+ '--update',
151
+ ]),
152
+ ['--only', 'core', '--update'],
153
+ );
154
+ });
155
+
156
+ test('resolveProfilesRootDirectory prefers explicit then env then home default', () => {
157
+ const homeDirectory = resolve(join(tmpdir(), 'a3-home-pr'));
158
+ assert.equal(
159
+ resolveProfilesRootDirectory({ homeDirectory, environment: {} }),
160
+ resolve(join(homeDirectory, DEFAULT_PROFILES_ROOT_DIRECTORY_NAME)),
161
+ );
162
+ const fromEnv = resolve(join(tmpdir(), 'a3-env-profiles'));
163
+ assert.equal(
164
+ resolveProfilesRootDirectory({
165
+ homeDirectory,
166
+ environment: { LLM_SETTINGS_PROFILES_ROOT: fromEnv },
167
+ }),
168
+ fromEnv,
169
+ );
170
+ });
171
+
172
+ test('repeated and selected multi-profile dry runs write one ownership manifest per target', () => {
173
+ const runRoot = mkdtempSync(join(tmpdir(), 'a3-multi-run-'));
174
+ try {
175
+ const profilesRoot = join(runRoot, 'profiles');
176
+ const homeDirectory = join(runRoot, 'home');
177
+ mkdirSync(homeDirectory, { recursive: true });
178
+ const selection = parseInstallTargetSelectionFromArgv(['--profiles', 'editor,mel']);
179
+ const allTargets = resolveInstallTargets(selection, {
180
+ homeDirectory,
181
+ profilesRoot,
182
+ directoryNameByProfileId: DIRECTORY_NAME_BY_PROFILE_ID,
183
+ });
184
+ assert.equal(allTargets.length, 2);
185
+
186
+ for (const eachTarget of allTargets) {
187
+ mkdirSync(eachTarget.managedRoot, { recursive: true });
188
+ const sampleFile = join(eachTarget.managedRoot, 'CLAUDE.md');
189
+ writeFileSync(sampleFile, `# ${eachTarget.targetIdentity}\n`, 'utf8');
190
+ const manifest = buildTargetManifestRecord({
191
+ packageName: 'claude-dev-env',
192
+ packageVersion: '0.0.0-a3',
193
+ targetIdentity: eachTarget.targetIdentity,
194
+ managedRoot: eachTarget.managedRoot,
195
+ files: [sampleFile],
196
+ skills: ['privacy-hygiene'],
197
+ });
198
+ const manifestPath = join(eachTarget.managedRoot, MANIFEST_FILE_NAME);
199
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
200
+ }
201
+
202
+ // Second pass (repeat) overwrites each target's own manifest only.
203
+ for (const eachTarget of allTargets) {
204
+ const manifestPath = join(eachTarget.managedRoot, MANIFEST_FILE_NAME);
205
+ const prior = JSON.parse(readFileSync(manifestPath, 'utf8'));
206
+ assert.equal(prior.targetIdentity, eachTarget.targetIdentity);
207
+ const refreshed = buildTargetManifestRecord({
208
+ packageName: 'claude-dev-env',
209
+ packageVersion: '0.0.0-a3-repeat',
210
+ targetIdentity: eachTarget.targetIdentity,
211
+ managedRoot: eachTarget.managedRoot,
212
+ files: prior.files,
213
+ skills: prior.skills,
214
+ });
215
+ writeFileSync(manifestPath, `${JSON.stringify(refreshed, null, 2)}\n`, 'utf8');
216
+ }
217
+
218
+ const editorManifest = JSON.parse(
219
+ readFileSync(join(profilesRoot, 'editor', MANIFEST_FILE_NAME), 'utf8'),
220
+ );
221
+ const melManifest = JSON.parse(
222
+ readFileSync(join(profilesRoot, 'mel', MANIFEST_FILE_NAME), 'utf8'),
223
+ );
224
+ assert.equal(editorManifest.targetIdentity, 'editor');
225
+ assert.equal(melManifest.targetIdentity, 'mel');
226
+ assert.equal(editorManifest.version, '0.0.0-a3-repeat');
227
+ assert.equal(melManifest.version, '0.0.0-a3-repeat');
228
+ assert.ok(!existsSync(join(profilesRoot, 'ev', MANIFEST_FILE_NAME)));
229
+ assert.notEqual(editorManifest.managedRoot, melManifest.managedRoot);
230
+ } finally {
231
+ rmSync(runRoot, { recursive: true, force: true });
232
+ }
233
+ });
234
+
235
+ test('install.mjs --help documents profile target selection', () => {
236
+ const result = spawnSync(process.execPath, [INSTALL_MODULE_PATH, '--help'], {
237
+ encoding: 'utf8',
238
+ env: process.env,
239
+ });
240
+ assert.equal(result.status, 0, result.stderr);
241
+ assert.match(result.stdout, /--profile/);
242
+ assert.match(result.stdout, /--profiles/);
243
+ assert.match(result.stdout, /--target/);
244
+ assert.match(result.stdout, /targetIdentity|target identity|per target/i);
245
+ });
246
+
247
+ test('install.mjs wires select-install-targets for multi-profile entry', () => {
248
+ const source = readFileSync(INSTALL_MODULE_PATH, 'utf8');
249
+ assert.match(source, /select-install-targets\.mjs/);
250
+ assert.match(source, /parseInstallTargetSelectionFromArgv/);
251
+ assert.match(source, /resolveInstallTargets/);
252
+ assert.match(source, /buildTargetManifestRecord|targetIdentity/);
253
+ });