mova-claude-import 0.1.1 → 0.1.3

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 (188) hide show
  1. package/README.md +95 -29
  2. package/control_surface_exclusions_v0.json +8 -0
  3. package/dist/anthropic_profile_v0.d.ts +1 -1
  4. package/dist/anthropic_profile_v0.js +1 -0
  5. package/dist/cli.js +206 -23
  6. package/dist/control_apply_v0.d.ts +5 -1
  7. package/dist/control_apply_v0.js +125 -8
  8. package/dist/control_check_v0.d.ts +1 -0
  9. package/dist/control_check_v0.js +194 -23
  10. package/dist/control_prefill_v0.js +128 -9
  11. package/dist/control_surface_coverage_v0.d.ts +22 -0
  12. package/dist/control_surface_coverage_v0.js +128 -0
  13. package/dist/control_v0.d.ts +149 -0
  14. package/dist/control_v0.js +360 -0
  15. package/dist/control_v0_schema.d.ts +6 -0
  16. package/dist/control_v0_schema.js +19 -0
  17. package/dist/init_v0.d.ts +6 -1
  18. package/dist/init_v0.js +41 -1
  19. package/dist/observability_writer_v0.d.ts +1 -0
  20. package/dist/observability_writer_v0.js +157 -0
  21. package/dist/observe_v0.d.ts +8 -0
  22. package/dist/observe_v0.js +57 -0
  23. package/dist/presets_v0.d.ts +11 -0
  24. package/dist/presets_v0.js +52 -0
  25. package/dist/redaction.js +5 -1
  26. package/dist/run_import.js +111 -26
  27. package/docs/CLAUDE_CONTROL_SURFACE_MAP_v0.md +78 -0
  28. package/docs/OPERATOR_GUIDE_v0.md +11 -0
  29. package/fixtures/pos/basic/mova/control_v0.json +93 -0
  30. package/fixtures/pos/claude_code_demo_full/.claude/agents/code-reviewer.md +13 -0
  31. package/fixtures/pos/claude_code_demo_full/.claude/agents/github-workflow.md +10 -0
  32. package/fixtures/pos/claude_code_demo_full/.claude/commands/code-quality.md +8 -0
  33. package/fixtures/pos/claude_code_demo_full/.claude/commands/docs-sync.md +8 -0
  34. package/fixtures/pos/claude_code_demo_full/.claude/commands/onboard.md +8 -0
  35. package/fixtures/pos/claude_code_demo_full/.claude/commands/pr-review.md +10 -0
  36. package/fixtures/pos/claude_code_demo_full/.claude/commands/pr-summary.md +8 -0
  37. package/fixtures/pos/claude_code_demo_full/.claude/commands/ticket.md +10 -0
  38. package/fixtures/pos/claude_code_demo_full/.claude/hooks/skill-eval.js +13 -0
  39. package/fixtures/pos/claude_code_demo_full/.claude/hooks/skill-eval.sh +15 -0
  40. package/fixtures/pos/claude_code_demo_full/.claude/hooks/skill-rules.json +21 -0
  41. package/fixtures/pos/claude_code_demo_full/.claude/hooks/skill-rules.schema.json +24 -0
  42. package/fixtures/pos/claude_code_demo_full/.claude/rules/code-style.md +5 -0
  43. package/fixtures/pos/claude_code_demo_full/.claude/rules/security.md +5 -0
  44. package/fixtures/pos/claude_code_demo_full/.claude/settings.json +102 -0
  45. package/fixtures/pos/claude_code_demo_full/.claude/settings.md +6 -0
  46. package/fixtures/pos/claude_code_demo_full/.claude/skills/core-components/SKILL.md +9 -0
  47. package/fixtures/pos/claude_code_demo_full/.claude/skills/formik-patterns/SKILL.md +9 -0
  48. package/fixtures/pos/claude_code_demo_full/.claude/skills/graphql-schema/SKILL.md +10 -0
  49. package/fixtures/pos/claude_code_demo_full/.claude/skills/react-ui-patterns/SKILL.md +10 -0
  50. package/fixtures/pos/claude_code_demo_full/.claude/skills/systematic-debugging/SKILL.md +9 -0
  51. package/fixtures/pos/claude_code_demo_full/.claude/skills/testing-patterns/SKILL.md +10 -0
  52. package/fixtures/pos/claude_code_demo_full/.github/workflows/pr-claude-code-review.yml +14 -0
  53. package/fixtures/pos/claude_code_demo_full/.github/workflows/scheduled-claude-code-dependency-audit.yml +14 -0
  54. package/fixtures/pos/claude_code_demo_full/.github/workflows/scheduled-claude-code-docs-sync.yml +14 -0
  55. package/fixtures/pos/claude_code_demo_full/.github/workflows/scheduled-claude-code-quality.yml +14 -0
  56. package/fixtures/pos/claude_code_demo_full/.mcp.json +44 -0
  57. package/fixtures/pos/claude_code_demo_full/CLAUDE.md +28 -0
  58. package/fixtures/pos/control_basic_project/mova/control_v0.json +93 -0
  59. package/fixtures/pos/observability_basic/CLAUDE.md +3 -0
  60. package/{.tmp_test_zip/out1 → fixtures/pos/preset_safe_observable_v0}/.mcp.json +3 -3
  61. package/fixtures/pos/preset_safe_observable_v0/CLAUDE.md +3 -0
  62. package/package.json +2 -1
  63. package/presets/safe_observable_v0/assets/.claude/agents/code-reviewer.md +9 -0
  64. package/presets/safe_observable_v0/assets/.claude/commands/finish.md +9 -0
  65. package/presets/safe_observable_v0/assets/.claude/commands/start.md +9 -0
  66. package/presets/safe_observable_v0/assets/.claude/hooks/skill-eval.js +15 -0
  67. package/presets/safe_observable_v0/assets/.claude/hooks/skill-eval.sh +17 -0
  68. package/presets/safe_observable_v0/assets/.claude/hooks/skill-rules.json +26 -0
  69. package/presets/safe_observable_v0/assets/.claude/rules/code-style.md +6 -0
  70. package/presets/safe_observable_v0/assets/.claude/rules/security.md +6 -0
  71. package/presets/safe_observable_v0/assets/.claude/skills/git-workflow/SKILL.md +9 -0
  72. package/presets/safe_observable_v0/assets/.claude/skills/security-basics/SKILL.md +9 -0
  73. package/presets/safe_observable_v0/assets/.claude/skills/systematic-debugging/SKILL.md +9 -0
  74. package/presets/safe_observable_v0/assets/.claude/skills/testing-patterns/SKILL.md +9 -0
  75. package/presets/safe_observable_v0/control_v0.json +214 -0
  76. package/schemas/mova.control_v0.schema.json +252 -0
  77. package/src/anthropic_profile_v0.ts +1 -0
  78. package/src/cli.ts +194 -23
  79. package/src/control_apply_v0.ts +131 -8
  80. package/src/control_check_v0.ts +203 -23
  81. package/src/control_prefill_v0.ts +136 -8
  82. package/src/control_surface_coverage_v0.ts +164 -0
  83. package/src/control_v0.ts +808 -0
  84. package/src/control_v0_schema.ts +26 -0
  85. package/src/init_v0.ts +48 -1
  86. package/src/observability_writer_v0.ts +157 -0
  87. package/src/observe_v0.ts +64 -0
  88. package/src/presets_v0.ts +58 -0
  89. package/src/redaction.ts +6 -1
  90. package/src/run_import.ts +132 -26
  91. package/test/control_demo_full_roundtrip.test.js +92 -0
  92. package/test/control_surface_coverage_v0.test.js +36 -0
  93. package/test/control_v0_schema_validation.test.js +69 -0
  94. package/test/init_v0.test.js +9 -0
  95. package/test/observability_writer_v0.test.js +59 -0
  96. package/test/preset_safe_observable_v0.test.js +55 -0
  97. package/test/profile_v0_output.test.js +1 -0
  98. package/test/scaffold_v0_output.test.js +1 -0
  99. package/tools/control_surface_coverage_v0.mjs +27 -0
  100. package/tools/smoke_v0.mjs +33 -0
  101. package/.tmp_test_control_apply/proj/.claude/agents/example_agent.md +0 -3
  102. package/.tmp_test_control_apply/proj/.claude/commands/example_command.md +0 -3
  103. package/.tmp_test_control_apply/proj/.claude/hooks/example_hook.sh +0 -2
  104. package/.tmp_test_control_apply/proj/.claude/output-styles/example_style.md +0 -3
  105. package/.tmp_test_control_apply/proj/.claude/settings.json +0 -30
  106. package/.tmp_test_control_apply/proj/.claude/settings.local.example.json +0 -3
  107. package/.tmp_test_control_apply/proj/.mcp.json +0 -3
  108. package/.tmp_test_control_apply/proj/CLAUDE.md +0 -13
  109. package/.tmp_test_control_apply/proj/MOVA.md +0 -3
  110. package/.tmp_test_control_check/proj/.mcp.json +0 -1
  111. package/.tmp_test_control_check/proj/CLAUDE.md +0 -1
  112. package/.tmp_test_control_prefill/out1/claude_control_profile_v0.json +0 -114
  113. package/.tmp_test_control_prefill/out1/prefill_report_v0.json +0 -13
  114. package/.tmp_test_control_prefill/out2/claude_control_profile_v0.json +0 -114
  115. package/.tmp_test_control_prefill/out2/prefill_report_v0.json +0 -13
  116. package/.tmp_test_overlay/proj/.claude/skills/a.md +0 -1
  117. package/.tmp_test_overlay/proj/.mcp.json +0 -1
  118. package/.tmp_test_overlay/proj/CLAUDE.md +0 -1
  119. package/.tmp_test_profile/proj/.claude/skills/a.md +0 -1
  120. package/.tmp_test_profile/proj/.mcp.json +0 -1
  121. package/.tmp_test_profile/proj/CLAUDE.md +0 -1
  122. package/.tmp_test_scaffold_apply/proj/.claude/agents/example_agent.md +0 -3
  123. package/.tmp_test_scaffold_apply/proj/.claude/commands/example_command.md +0 -3
  124. package/.tmp_test_scaffold_apply/proj/.claude/hooks/example_hook.sh +0 -2
  125. package/.tmp_test_scaffold_apply/proj/.claude/output-styles/example_style.md +0 -3
  126. package/.tmp_test_scaffold_apply/proj/.claude/settings.json +0 -30
  127. package/.tmp_test_scaffold_apply/proj/.claude/settings.local.example.json +0 -3
  128. package/.tmp_test_scaffold_apply/proj/.mcp.json +0 -3
  129. package/.tmp_test_scaffold_apply/proj/CLAUDE.md +0 -13
  130. package/.tmp_test_scaffold_apply/proj/MOVA.md +0 -3
  131. package/.tmp_test_strict/mova/claude_import/v0/VERSION.json +0 -10
  132. package/.tmp_test_strict/mova/claude_import/v0/episode_import_run.json +0 -20
  133. package/.tmp_test_strict/mova/claude_import/v0/import_manifest.json +0 -20
  134. package/.tmp_test_strict/mova/claude_import/v0/input_policy_report_v0.json +0 -32
  135. package/.tmp_test_zip/out1/.claude/agents/example_agent.md +0 -3
  136. package/.tmp_test_zip/out1/.claude/commands/example_command.md +0 -3
  137. package/.tmp_test_zip/out1/.claude/commands/mova_context.md +0 -4
  138. package/.tmp_test_zip/out1/.claude/commands/mova_lint.md +0 -4
  139. package/.tmp_test_zip/out1/.claude/commands/mova_proof.md +0 -6
  140. package/.tmp_test_zip/out1/.claude/hooks/example_hook.sh +0 -2
  141. package/.tmp_test_zip/out1/.claude/output-styles/example_style.md +0 -3
  142. package/.tmp_test_zip/out1/.claude/settings.json +0 -30
  143. package/.tmp_test_zip/out1/.claude/settings.local.example.json +0 -3
  144. package/.tmp_test_zip/out1/.claude/skills/a/SKILL.md +0 -1
  145. package/.tmp_test_zip/out1/.claude/skills/mova-control-v0/SKILL.md +0 -11
  146. package/.tmp_test_zip/out1/.claude/skills/mova-layer-v0/SKILL.md +0 -8
  147. package/.tmp_test_zip/out1/CLAUDE.md +0 -4
  148. package/.tmp_test_zip/out1/MOVA.md +0 -10
  149. package/.tmp_test_zip/out1/export.zip +0 -0
  150. package/.tmp_test_zip/out1/mova/claude_import/v0/VERSION.json +0 -10
  151. package/.tmp_test_zip/out1/mova/claude_import/v0/contracts/instruction_profile_v0.json +0 -8
  152. package/.tmp_test_zip/out1/mova/claude_import/v0/contracts/mcp_servers_v0.json +0 -4
  153. package/.tmp_test_zip/out1/mova/claude_import/v0/contracts/skills_catalog_v0.json +0 -11
  154. package/.tmp_test_zip/out1/mova/claude_import/v0/episode_import_run.json +0 -80
  155. package/.tmp_test_zip/out1/mova/claude_import/v0/export_manifest_v0.json +0 -32
  156. package/.tmp_test_zip/out1/mova/claude_import/v0/import_manifest.json +0 -33
  157. package/.tmp_test_zip/out1/mova/claude_import/v0/input_policy_report_v0.json +0 -38
  158. package/.tmp_test_zip/out1/mova/claude_import/v0/lint_report_v0.json +0 -6
  159. package/.tmp_test_zip/out1/mova/claude_import/v0/redaction_report.json +0 -4
  160. package/.tmp_test_zip/out2/.claude/agents/example_agent.md +0 -3
  161. package/.tmp_test_zip/out2/.claude/commands/example_command.md +0 -3
  162. package/.tmp_test_zip/out2/.claude/commands/mova_context.md +0 -4
  163. package/.tmp_test_zip/out2/.claude/commands/mova_lint.md +0 -4
  164. package/.tmp_test_zip/out2/.claude/commands/mova_proof.md +0 -6
  165. package/.tmp_test_zip/out2/.claude/hooks/example_hook.sh +0 -2
  166. package/.tmp_test_zip/out2/.claude/output-styles/example_style.md +0 -3
  167. package/.tmp_test_zip/out2/.claude/settings.json +0 -30
  168. package/.tmp_test_zip/out2/.claude/settings.local.example.json +0 -3
  169. package/.tmp_test_zip/out2/.claude/skills/a/SKILL.md +0 -1
  170. package/.tmp_test_zip/out2/.claude/skills/mova-control-v0/SKILL.md +0 -11
  171. package/.tmp_test_zip/out2/.claude/skills/mova-layer-v0/SKILL.md +0 -8
  172. package/.tmp_test_zip/out2/.mcp.json +0 -3
  173. package/.tmp_test_zip/out2/CLAUDE.md +0 -4
  174. package/.tmp_test_zip/out2/MOVA.md +0 -10
  175. package/.tmp_test_zip/out2/export.zip +0 -0
  176. package/.tmp_test_zip/out2/mova/claude_import/v0/VERSION.json +0 -10
  177. package/.tmp_test_zip/out2/mova/claude_import/v0/contracts/instruction_profile_v0.json +0 -8
  178. package/.tmp_test_zip/out2/mova/claude_import/v0/contracts/mcp_servers_v0.json +0 -4
  179. package/.tmp_test_zip/out2/mova/claude_import/v0/contracts/skills_catalog_v0.json +0 -11
  180. package/.tmp_test_zip/out2/mova/claude_import/v0/episode_import_run.json +0 -80
  181. package/.tmp_test_zip/out2/mova/claude_import/v0/export_manifest_v0.json +0 -32
  182. package/.tmp_test_zip/out2/mova/claude_import/v0/import_manifest.json +0 -33
  183. package/.tmp_test_zip/out2/mova/claude_import/v0/input_policy_report_v0.json +0 -38
  184. package/.tmp_test_zip/out2/mova/claude_import/v0/lint_report_v0.json +0 -6
  185. package/.tmp_test_zip/out2/mova/claude_import/v0/redaction_report.json +0 -4
  186. package/.tmp_test_zip/proj/.claude/skills/a.md +0 -1
  187. package/.tmp_test_zip/proj/.mcp.json +0 -1
  188. package/.tmp_test_zip/proj/CLAUDE.md +0 -1
@@ -4,10 +4,18 @@ import { stableStringify } from "./stable_json.js";
4
4
  import { stableSha256 } from "./redaction.js";
5
5
  import { buildMovaControlEntryV0, MOVA_CONTROL_ENTRY_MARKER } from "./mova_overlay_v0.js";
6
6
  import { ensureClaudeControlSurfacesV0 } from "./claude_profile_scaffold_v0.js";
7
+ import { controlToMcpJson, controlToSettingsV0, normalizeControlV0 } from "./control_v0.js";
8
+ import { validateControlV0Schema } from "./control_v0_schema.js";
9
+ import { getMovaObserveScriptV0 } from "./observability_writer_v0.js";
7
10
 
8
11
  type ApplyResult = {
9
12
  run_id: string;
10
13
  report_path: string;
14
+ exit_code?: number;
15
+ };
16
+
17
+ type ApplyOptions = {
18
+ assetSourceRoot?: string;
11
19
  };
12
20
 
13
21
  async function exists(p: string): Promise<boolean> {
@@ -33,6 +41,37 @@ function computeRunId(parts: string[]): string {
33
41
  return stableSha256(parts.join("|")).slice(0, 16);
34
42
  }
35
43
 
44
+ function mergeOverlayValue(existing: any, incoming: any): any {
45
+ if (existing === undefined) return incoming;
46
+ const existingArr = Array.isArray(existing) ? existing : null;
47
+ const incomingArr = Array.isArray(incoming) ? incoming : null;
48
+ if (existingArr || incomingArr) {
49
+ const left = existingArr ?? (existing === undefined ? [] : [existing]);
50
+ const right = incomingArr ?? (incoming === undefined ? [] : [incoming]);
51
+ const seen = new Set(left.map((item) => stableStringify(item)));
52
+ const merged = left.slice();
53
+ for (const item of right) {
54
+ const key = stableStringify(item);
55
+ if (seen.has(key)) continue;
56
+ seen.add(key);
57
+ merged.push(item);
58
+ }
59
+ return merged;
60
+ }
61
+ if (existing && typeof existing === "object" && incoming && typeof incoming === "object") {
62
+ const out: Record<string, any> = { ...existing };
63
+ for (const [key, value] of Object.entries(incoming)) {
64
+ out[key] = mergeOverlayValue(out[key], value);
65
+ }
66
+ return out;
67
+ }
68
+ return existing;
69
+ }
70
+
71
+ function mergeSettingsOverlay(existing: any, incoming: any) {
72
+ return mergeOverlayValue(existing ?? {}, incoming ?? {});
73
+ }
74
+
36
75
  function updateClaude(content: string, marker: string, block: string): string {
37
76
  if (content.includes(marker)) {
38
77
  const idx = content.indexOf(marker);
@@ -48,17 +87,41 @@ export async function controlApplyV0(
48
87
  projectDir: string,
49
88
  profilePath: string,
50
89
  outDir: string,
51
- mode?: string
90
+ mode?: string,
91
+ options?: ApplyOptions
52
92
  ): Promise<ApplyResult> {
53
93
  await ensureClaudeControlSurfacesV0(projectDir);
54
94
  const profile = await readJson(profilePath);
55
- const applyMode = mode ?? profile?.apply?.default_apply_mode ?? "preview";
95
+ const isControlV0 = profile?.version === "control_v0";
96
+ const control = isControlV0 ? normalizeControlV0(profile).control : null;
97
+ if (isControlV0) {
98
+ const validation = await validateControlV0Schema(profile);
99
+ if (!validation.ok) {
100
+ const runId = computeRunId([profilePath, "invalid_schema"]);
101
+ const runBase = path.join(outDir, "mova", "claude_control", "v0", "runs", runId);
102
+ const reportPath = path.join(runBase, "control_apply_report_v0.json");
103
+ const report = {
104
+ profile_version: "v0",
105
+ run_id: runId,
106
+ project_dir: projectDir,
107
+ profile_path: profilePath,
108
+ mode: mode ?? "preview",
109
+ outcome_code: "INVALID_SCHEMA",
110
+ errors: validation.errors ?? [],
111
+ };
112
+ await writeJson(reportPath, report);
113
+ return { run_id: runId, report_path: reportPath, exit_code: 2 };
114
+ }
115
+ }
116
+ const applyMode = mode ?? (isControlV0 && control?.policy?.mode === "report_only" ? "preview" : profile?.apply?.default_apply_mode) ?? "preview";
117
+ const isOverlay = applyMode === "overlay";
118
+ const assetSourceRoot = options?.assetSourceRoot ?? projectDir;
56
119
 
57
120
  const claudePath = path.join(projectDir, "CLAUDE.md");
58
121
  const mcpPath = path.join(projectDir, ".mcp.json");
59
122
  const claudeExists = await exists(claudePath);
60
123
  const mcpExists = await exists(mcpPath);
61
- const marker = profile?.anthropic?.claude_md?.marker ?? MOVA_CONTROL_ENTRY_MARKER;
124
+ const marker = control?.claude_md?.marker ?? profile?.anthropic?.claude_md?.marker ?? MOVA_CONTROL_ENTRY_MARKER;
62
125
 
63
126
  const runId = computeRunId([profilePath, claudeExists ? "claude" : "", mcpExists ? "mcp" : "", marker, applyMode]);
64
127
  const runBase = path.join(outDir, "mova", "claude_control", "v0", "runs", runId);
@@ -75,15 +138,75 @@ export async function controlApplyV0(
75
138
  };
76
139
  const controlEntry = buildMovaControlEntryV0(overlayParams);
77
140
 
78
- const applied = { claude_md: false, mcp_json: false, settings: false };
79
- if (applyMode === "apply") {
80
- if (profile?.anthropic?.claude_md?.inject_control_entry && claudeExists) {
141
+ const applied = { claude_md: false, mcp_json: false, settings: false, assets: false, lsp: false };
142
+ if (applyMode === "apply" || applyMode === "overlay") {
143
+ if ((control?.claude_md?.inject_control_entry ?? profile?.anthropic?.claude_md?.inject_control_entry) && claudeExists) {
81
144
  const raw = await fs.readFile(claudePath, "utf8");
82
145
  const updated = updateClaude(raw, marker, controlEntry);
83
146
  await fs.writeFile(claudePath, updated, "utf8");
84
147
  applied.claude_md = true;
85
148
  }
86
- if (profile?.anthropic?.mcp?.servers && mcpExists) {
149
+ if (control) {
150
+ const settingsPath = path.join(projectDir, ".claude", "settings.json");
151
+ const settingsGenerated = controlToSettingsV0(control);
152
+ if (isOverlay && (await exists(settingsPath))) {
153
+ const current = await readJson(settingsPath);
154
+ const merged = mergeSettingsOverlay(current, settingsGenerated);
155
+ await writeJson(settingsPath, merged);
156
+ } else {
157
+ await writeJson(settingsPath, settingsGenerated);
158
+ }
159
+ applied.settings = true;
160
+
161
+ if (!(isOverlay && (await exists(mcpPath)))) {
162
+ await writeJson(path.join(projectDir, ".mcp.json"), controlToMcpJson(control));
163
+ applied.mcp_json = true;
164
+ }
165
+
166
+ const assets = [
167
+ ...control.assets.skills,
168
+ ...control.assets.agents,
169
+ ...control.assets.commands,
170
+ ...control.assets.rules,
171
+ ...control.assets.hooks,
172
+ ...control.assets.workflows,
173
+ ...control.assets.docs,
174
+ ...control.assets.dotfiles,
175
+ ...control.assets.schemas,
176
+ ];
177
+ for (const asset of assets) {
178
+ const target = path.join(projectDir, asset.path);
179
+ if (isOverlay && (await exists(target))) continue;
180
+ const sourceRel = asset.source_path ?? asset.path;
181
+ const source = path.isAbsolute(sourceRel) ? sourceRel : path.join(assetSourceRoot, sourceRel);
182
+ try {
183
+ await fs.stat(source);
184
+ } catch {
185
+ continue;
186
+ }
187
+ await fs.mkdir(path.dirname(target), { recursive: true });
188
+ if (source !== target) {
189
+ await fs.copyFile(source, target);
190
+ }
191
+ }
192
+ applied.assets = assets.length > 0;
193
+
194
+ if (control.lsp.managed && Array.isArray(control.lsp.enabled_plugins)) {
195
+ const lspPath = path.join(projectDir, control.lsp.config_path);
196
+ if (!(isOverlay && (await exists(lspPath)))) {
197
+ await writeJson(lspPath, { enabled_plugins: control.lsp.enabled_plugins });
198
+ }
199
+ applied.lsp = true;
200
+ }
201
+
202
+ if (control.observability.enable && control.observability.writer?.script_path) {
203
+ const scriptPath = path.join(projectDir, control.observability.writer.script_path);
204
+ if (!(isOverlay && (await exists(scriptPath)))) {
205
+ await fs.mkdir(path.dirname(scriptPath), { recursive: true });
206
+ await fs.writeFile(scriptPath, getMovaObserveScriptV0(), "utf8");
207
+ }
208
+ }
209
+ } else if (profile?.anthropic?.mcp?.servers && mcpExists) {
87
210
  const mcp = await readJson(mcpPath);
88
211
  const merged = { ...mcp, servers: profile.anthropic.mcp.servers };
89
212
  await fs.writeFile(mcpPath, stableStringify(merged) + "\n", "utf8");
@@ -97,7 +220,7 @@ export async function controlApplyV0(
97
220
  project_dir: projectDir,
98
221
  profile_path: profilePath,
99
222
  mode: applyMode,
100
- outcome_code: applyMode === "apply" ? "APPLIED" : "PREVIEW",
223
+ outcome_code: applyMode === "apply" || applyMode === "overlay" ? "APPLIED" : "PREVIEW",
101
224
  applied,
102
225
  };
103
226
 
@@ -3,11 +3,14 @@ import path from "node:path";
3
3
  import { stableStringify } from "./stable_json.js";
4
4
  import { stableSha256 } from "./redaction.js";
5
5
  import { buildMovaControlEntryV0, MOVA_CONTROL_ENTRY_MARKER } from "./mova_overlay_v0.js";
6
+ import { controlToMcpJson, controlToSettingsV0, normalizeControlV0 } from "./control_v0.js";
7
+ import { validateControlV0Schema } from "./control_v0_schema.js";
6
8
 
7
9
  type CheckResult = {
8
10
  run_id: string;
9
11
  plan_path: string;
10
12
  summary_path: string;
13
+ exit_code?: number;
11
14
  };
12
15
 
13
16
  async function exists(p: string): Promise<boolean> {
@@ -33,14 +36,48 @@ function computeRunId(parts: string[]): string {
33
36
  return stableSha256(parts.join("|")).slice(0, 16);
34
37
  }
35
38
 
39
+ function normalizeJson(obj: any): string {
40
+ return stableStringify(obj);
41
+ }
42
+
43
+ function isPlaceholder(value: string): boolean {
44
+ return /^\$\{[A-Z0-9_]+(?::-?[^}]+)?\}$/.test(value.trim());
45
+ }
46
+
47
+ function validateMcpEnvValues(servers: any): Array<{ server: string; key: string; value: string }> {
48
+ if (!servers || typeof servers !== "object") return [];
49
+ const issues: Array<{ server: string; key: string; value: string }> = [];
50
+ const entries = Array.isArray(servers)
51
+ ? servers.map((server, idx) => [server?.name ?? `index_${idx}`, server])
52
+ : Object.entries(servers);
53
+ for (const [name, server] of entries) {
54
+ if (!server || typeof server !== "object") continue;
55
+ const env = (server as any).env;
56
+ if (!env || typeof env !== "object") continue;
57
+ for (const [key, value] of Object.entries(env)) {
58
+ if (typeof value !== "string") continue;
59
+ if (!value.includes("${")) continue;
60
+ if (!isPlaceholder(value)) {
61
+ issues.push({ server: String(name), key, value });
62
+ }
63
+ }
64
+ }
65
+ return issues;
66
+ }
67
+
36
68
  export async function controlCheckV0(projectDir: string, profilePath: string, outDir: string): Promise<CheckResult> {
37
69
  const profile = await readJson(profilePath);
38
70
  const claudePath = path.join(projectDir, "CLAUDE.md");
39
71
  const mcpPath = path.join(projectDir, ".mcp.json");
72
+ const settingsPath = path.join(projectDir, ".claude", "settings.json");
40
73
 
41
74
  const claudeExists = await exists(claudePath);
42
75
  const mcpExists = await exists(mcpPath);
43
- const marker = profile?.anthropic?.claude_md?.marker ?? MOVA_CONTROL_ENTRY_MARKER;
76
+ const settingsExists = await exists(settingsPath);
77
+ const isControlV0 = profile?.version === "control_v0";
78
+ const marker = isControlV0
79
+ ? profile?.claude_md?.marker ?? MOVA_CONTROL_ENTRY_MARKER
80
+ : profile?.anthropic?.claude_md?.marker ?? MOVA_CONTROL_ENTRY_MARKER;
44
81
 
45
82
  const runId = computeRunId([profilePath, claudeExists ? "claude" : "", mcpExists ? "mcp" : "", marker]);
46
83
  const runBase = path.join(outDir, "mova", "claude_control", "v0", "runs", runId);
@@ -56,20 +93,148 @@ export async function controlCheckV0(projectDir: string, profilePath: string, ou
56
93
  exportManifestFile: "export_manifest_v0.json",
57
94
  };
58
95
 
59
- const actions = [];
60
- if (profile?.anthropic?.claude_md?.inject_control_entry) {
61
- actions.push({
62
- target: "CLAUDE.md",
63
- action: "insert_or_update_control_entry",
64
- marker,
65
- });
96
+ if (!isControlV0) {
97
+ const actions = [];
98
+ if (profile?.anthropic?.claude_md?.inject_control_entry) {
99
+ actions.push({
100
+ target: "CLAUDE.md",
101
+ action: "insert_or_update_control_entry",
102
+ marker,
103
+ });
104
+ }
105
+ if (profile?.anthropic?.mcp?.servers && mcpExists) {
106
+ actions.push({
107
+ target: ".mcp.json",
108
+ action: "merge_servers",
109
+ summary: "merge profile servers with project mcp.json",
110
+ });
111
+ }
112
+
113
+ const plan = {
114
+ profile_version: "v0",
115
+ run_id: runId,
116
+ project_dir: projectDir,
117
+ profile_path: profilePath,
118
+ actions,
119
+ };
120
+
121
+ const summary = {
122
+ run_id: runId,
123
+ outcome_code: "PREVIEW",
124
+ actions_count: actions.length,
125
+ control_entry_preview: profile?.anthropic?.claude_md?.inject_control_entry
126
+ ? buildMovaControlEntryV0(overlayParams)
127
+ : null,
128
+ };
129
+
130
+ const planPath = path.join(runBase, "control_plan_v0.json");
131
+ const summaryPath = path.join(runBase, "control_summary_v0.json");
132
+ await writeJson(planPath, plan);
133
+ await writeJson(summaryPath, summary);
134
+
135
+ return { run_id: runId, plan_path: planPath, summary_path: summaryPath };
136
+ }
137
+
138
+ const schemaValidation = await validateControlV0Schema(profile);
139
+ const control = normalizeControlV0(profile).control;
140
+ const planPath = path.join(runBase, "control_plan_v0.json");
141
+ const summaryPath = path.join(runBase, "control_summary_v0.json");
142
+
143
+ if (!schemaValidation.ok) {
144
+ const summary = {
145
+ run_id: runId,
146
+ outcome_code: "INVALID_SCHEMA",
147
+ errors: schemaValidation.errors ?? [],
148
+ };
149
+ await writeJson(planPath, { profile_version: "v0", run_id: runId, project_dir: projectDir, profile_path: profilePath, actions: [] });
150
+ await writeJson(summaryPath, summary);
151
+ return { run_id: runId, plan_path: planPath, summary_path: summaryPath, exit_code: 2 };
152
+ }
153
+
154
+ const mcpJsonExpected = controlToMcpJson(control);
155
+ const settingsExpected = controlToSettingsV0(control);
156
+ const missing: string[] = [];
157
+ const drift: Array<{ path: string; expected: string; actual: string }> = [];
158
+
159
+ if (!claudeExists) missing.push("CLAUDE.md");
160
+ if (!settingsExists) missing.push(".claude/settings.json");
161
+ if (!mcpExists) missing.push(".mcp.json");
162
+
163
+ if (claudeExists && control.claude_md.inject_control_entry) {
164
+ const claude = await fs.readFile(claudePath, "utf8");
165
+ if (!claude.includes(control.claude_md.marker)) {
166
+ drift.push({ path: "CLAUDE.md", expected: `marker:${control.claude_md.marker}`, actual: "missing_marker" });
167
+ }
66
168
  }
67
- if (profile?.anthropic?.mcp?.servers && mcpExists) {
68
- actions.push({
69
- target: ".mcp.json",
70
- action: "merge_servers",
71
- summary: "merge profile servers with project mcp.json",
72
- });
169
+
170
+ if (settingsExists) {
171
+ const actual = normalizeJson(await readJson(settingsPath));
172
+ const expected = normalizeJson(settingsExpected);
173
+ if (actual !== expected) {
174
+ drift.push({ path: ".claude/settings.json", expected, actual });
175
+ }
176
+ }
177
+
178
+ if (mcpExists) {
179
+ const actual = normalizeJson(await readJson(mcpPath));
180
+ const expected = normalizeJson(mcpJsonExpected);
181
+ if (actual !== expected) {
182
+ drift.push({ path: ".mcp.json", expected, actual });
183
+ }
184
+ }
185
+
186
+ if (control.lsp.managed) {
187
+ const lspPath = control.lsp.config_path;
188
+ const abs = path.join(projectDir, lspPath);
189
+ if (!(await exists(abs))) {
190
+ missing.push(lspPath);
191
+ } else {
192
+ const actual = normalizeJson(await readJson(abs));
193
+ const expected = normalizeJson({ enabled_plugins: control.lsp.enabled_plugins });
194
+ if (actual !== expected) {
195
+ drift.push({ path: lspPath, expected, actual });
196
+ }
197
+ }
198
+ }
199
+
200
+ if (control.observability.enable && control.observability.writer?.script_path) {
201
+ const obsPath = path.join(projectDir, control.observability.writer.script_path);
202
+ if (!(await exists(obsPath))) {
203
+ missing.push(control.observability.writer.script_path);
204
+ }
205
+ }
206
+
207
+ const assets = [
208
+ ...control.assets.skills,
209
+ ...control.assets.agents,
210
+ ...control.assets.commands,
211
+ ...control.assets.rules,
212
+ ...control.assets.hooks,
213
+ ...control.assets.workflows,
214
+ ...control.assets.docs,
215
+ ...control.assets.dotfiles,
216
+ ...control.assets.schemas,
217
+ ];
218
+ for (const asset of assets) {
219
+ const abs = path.join(projectDir, asset.path);
220
+ if (!(await exists(abs))) {
221
+ missing.push(asset.path);
222
+ }
223
+ }
224
+
225
+ const envIssues = validateMcpEnvValues(control.mcp.servers);
226
+
227
+ let outcome = "OK";
228
+ let exitCode = 0;
229
+ if (envIssues.length > 0) {
230
+ outcome = "INVALID_CONTROL";
231
+ exitCode = 2;
232
+ } else if (missing.length > 0) {
233
+ outcome = "MISSING_REQUIRED_FILES";
234
+ exitCode = 4;
235
+ } else if (drift.length > 0) {
236
+ outcome = "DRIFT";
237
+ exitCode = 3;
73
238
  }
74
239
 
75
240
  const plan = {
@@ -77,22 +242,37 @@ export async function controlCheckV0(projectDir: string, profilePath: string, ou
77
242
  run_id: runId,
78
243
  project_dir: projectDir,
79
244
  profile_path: profilePath,
80
- actions,
245
+ actions: [
246
+ {
247
+ target: "CLAUDE.md",
248
+ action: "ensure_control_entry",
249
+ marker: control.claude_md.marker,
250
+ },
251
+ {
252
+ target: ".claude/settings.json",
253
+ action: "overwrite",
254
+ },
255
+ {
256
+ target: ".mcp.json",
257
+ action: "overwrite",
258
+ },
259
+ ],
81
260
  };
82
261
 
83
262
  const summary = {
84
263
  run_id: runId,
85
- outcome_code: "PREVIEW",
86
- actions_count: actions.length,
87
- control_entry_preview: profile?.anthropic?.claude_md?.inject_control_entry
88
- ? buildMovaControlEntryV0(overlayParams)
89
- : null,
264
+ outcome_code: outcome,
265
+ exit_code: exitCode,
266
+ missing,
267
+ drift_count: drift.length,
268
+ invalid_mcp_env: envIssues,
269
+ control_entry_preview: control.claude_md.inject_control_entry ? buildMovaControlEntryV0(overlayParams) : null,
90
270
  };
91
271
 
92
- const planPath = path.join(runBase, "control_plan_v0.json");
93
- const summaryPath = path.join(runBase, "control_summary_v0.json");
272
+ const reportPath = path.join(runBase, "control_check_report_v0.json");
94
273
  await writeJson(planPath, plan);
95
274
  await writeJson(summaryPath, summary);
275
+ await writeJson(reportPath, { summary, drift, missing, invalid_mcp_env: envIssues });
96
276
 
97
- return { run_id: runId, plan_path: planPath, summary_path: summaryPath };
277
+ return { run_id: runId, plan_path: planPath, summary_path: summaryPath, exit_code: exitCode };
98
278
  }
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { stableStringify } from "./stable_json.js";
4
4
  import { loadControlContractsV0 } from "./control_contracts_v0.js";
5
+ import { controlFromSettingsV0, normalizeControlV0 } from "./control_v0.js";
5
6
 
6
7
  type PrefillResult = {
7
8
  profile_path: string;
@@ -17,6 +18,21 @@ async function exists(p: string): Promise<boolean> {
17
18
  }
18
19
  }
19
20
 
21
+ async function listFilesRec(dir: string): Promise<string[]> {
22
+ const out: string[] = [];
23
+ const stack = [dir];
24
+ while (stack.length) {
25
+ const current = stack.pop()!;
26
+ const entries = await fs.readdir(current, { withFileTypes: true });
27
+ for (const e of entries) {
28
+ const abs = path.join(current, e.name);
29
+ if (e.isDirectory()) stack.push(abs);
30
+ else if (e.isFile()) out.push(abs);
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
20
36
  async function readJson(p: string) {
21
37
  const raw = await fs.readFile(p, "utf8");
22
38
  return JSON.parse(raw);
@@ -34,29 +50,129 @@ export async function controlPrefillV0(projectDir: string, outDir: string): Prom
34
50
  const settingsPath = path.join(projectDir, ".claude", "settings.json");
35
51
  const settingsLocalPath = path.join(projectDir, ".claude", "settings.local.json");
36
52
 
37
- let mcpServers: any = {};
53
+ let mcpParsed: any | undefined;
38
54
  let mcpFound = false;
39
55
  if (await exists(mcpPath)) {
40
- const parsed = await readJson(mcpPath);
41
- if (Array.isArray(parsed?.servers)) {
42
- mcpServers = { servers: parsed.servers };
43
- } else if (parsed?.servers && typeof parsed.servers === "object") {
44
- mcpServers = { servers: parsed.servers };
45
- }
56
+ mcpParsed = await readJson(mcpPath);
46
57
  mcpFound = true;
47
58
  }
48
59
 
49
60
  if (template?.anthropic?.mcp) {
50
- template.anthropic.mcp.servers = mcpServers.servers ?? {};
61
+ const servers = mcpParsed?.mcpServers ?? mcpParsed?.servers ?? {};
62
+ template.anthropic.mcp.servers = servers;
63
+ }
64
+
65
+ let settingsParsed: any | undefined;
66
+ if (await exists(settingsPath)) {
67
+ settingsParsed = await readJson(settingsPath);
68
+ }
69
+
70
+ const controlDerived = controlFromSettingsV0(settingsParsed, mcpParsed);
71
+ const control = normalizeControlV0(controlDerived.control).control;
72
+
73
+ const skills: Array<{ path: string; mode: "copy_through"; source_path: string }> = [];
74
+ const skillsRoot = path.join(projectDir, ".claude", "skills");
75
+ if (await exists(skillsRoot)) {
76
+ const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
77
+ for (const entry of entries) {
78
+ if (!entry.isDirectory()) continue;
79
+ const rel = `.claude/skills/${entry.name}/SKILL.md`;
80
+ const abs = path.join(skillsRoot, entry.name, "SKILL.md");
81
+ if (await exists(abs)) {
82
+ skills.push({ path: rel, mode: "copy_through", source_path: rel });
83
+ }
84
+ }
85
+ }
86
+
87
+ const assetDirs = [
88
+ { key: "agents", root: path.join(projectDir, ".claude", "agents"), prefix: ".claude/agents" },
89
+ { key: "commands", root: path.join(projectDir, ".claude", "commands"), prefix: ".claude/commands" },
90
+ { key: "rules", root: path.join(projectDir, ".claude", "rules"), prefix: ".claude/rules" },
91
+ { key: "hooks", root: path.join(projectDir, ".claude", "hooks"), prefix: ".claude/hooks" },
92
+ ] as const;
93
+
94
+ const assetMap: Record<string, Array<{ path: string; mode: "copy_through"; source_path: string }>> = {
95
+ agents: [],
96
+ commands: [],
97
+ rules: [],
98
+ hooks: [],
99
+ workflows: [],
100
+ docs: [],
101
+ dotfiles: [],
102
+ schemas: [],
103
+ };
104
+
105
+ for (const dir of assetDirs) {
106
+ if (!(await exists(dir.root))) continue;
107
+ const files = await listFilesRec(dir.root);
108
+ for (const abs of files) {
109
+ const rel = path.relative(projectDir, abs).replace(/\\/g, "/");
110
+ assetMap[dir.key].push({ path: rel, mode: "copy_through", source_path: rel });
111
+ }
112
+ }
113
+
114
+ const docsCandidates = [
115
+ path.join(projectDir, ".claude", "settings.md"),
116
+ path.join(projectDir, ".claude", "skills", "README.md"),
117
+ ];
118
+ for (const abs of docsCandidates) {
119
+ if (await exists(abs)) {
120
+ const rel = path.relative(projectDir, abs).replace(/\\/g, "/");
121
+ assetMap.docs.push({ path: rel, mode: "copy_through", source_path: rel });
122
+ }
123
+ }
124
+
125
+ const dotfileCandidates = [path.join(projectDir, ".claude", ".gitignore")];
126
+ for (const abs of dotfileCandidates) {
127
+ if (await exists(abs)) {
128
+ const rel = path.relative(projectDir, abs).replace(/\\/g, "/");
129
+ assetMap.dotfiles.push({ path: rel, mode: "copy_through", source_path: rel });
130
+ }
131
+ }
132
+
133
+ const schemasRoot = path.join(projectDir, ".claude");
134
+ if (await exists(schemasRoot)) {
135
+ const files = await listFilesRec(schemasRoot);
136
+ for (const abs of files) {
137
+ if (!abs.endsWith(".schema.json")) continue;
138
+ const rel = path.relative(projectDir, abs).replace(/\\/g, "/");
139
+ assetMap.schemas.push({ path: rel, mode: "copy_through", source_path: rel });
140
+ }
141
+ }
142
+
143
+ const workflowsRoot = path.join(projectDir, ".github", "workflows");
144
+ if (await exists(workflowsRoot)) {
145
+ const files = await listFilesRec(workflowsRoot);
146
+ for (const abs of files) {
147
+ const rel = path.relative(projectDir, abs).replace(/\\/g, "/");
148
+ assetMap.workflows.push({ path: rel, mode: "copy_through", source_path: rel });
149
+ }
150
+ }
151
+
152
+ control.assets.skills = skills.sort((a, b) => a.path.localeCompare(b.path));
153
+ control.assets.agents = assetMap.agents.sort((a, b) => a.path.localeCompare(b.path));
154
+ control.assets.commands = assetMap.commands.sort((a, b) => a.path.localeCompare(b.path));
155
+ control.assets.rules = assetMap.rules.sort((a, b) => a.path.localeCompare(b.path));
156
+ control.assets.hooks = assetMap.hooks.sort((a, b) => a.path.localeCompare(b.path));
157
+ control.assets.workflows = assetMap.workflows.sort((a, b) => a.path.localeCompare(b.path));
158
+ control.assets.docs = assetMap.docs.sort((a, b) => a.path.localeCompare(b.path));
159
+ control.assets.dotfiles = assetMap.dotfiles.sort((a, b) => a.path.localeCompare(b.path));
160
+ control.assets.schemas = assetMap.schemas.sort((a, b) => a.path.localeCompare(b.path));
161
+
162
+ const hasSkillEval = control.assets.hooks.some((h) => h.path.endsWith("skill-eval.sh") || h.path.endsWith("skill-eval.js"));
163
+ if (hasSkillEval) {
164
+ control.skill_eval.enable = true;
51
165
  }
52
166
 
53
167
  const profilePath = path.join(outDir, "claude_control_profile_v0.json");
54
168
  const reportPath = path.join(outDir, "prefill_report_v0.json");
169
+ const controlPath = path.join(outDir, "mova", "control_v0.json");
55
170
 
56
171
  const report = {
57
172
  profile_version: "v0",
58
173
  project_dir: projectDir,
59
174
  profile_path: profilePath,
175
+ control_path: controlPath,
60
176
  found: {
61
177
  mcp_json: mcpFound,
62
178
  settings_json: await exists(settingsPath),
@@ -64,10 +180,22 @@ export async function controlPrefillV0(projectDir: string, outDir: string): Prom
64
180
  },
65
181
  applied: {
66
182
  mcp_servers: mcpFound,
183
+ assets: {
184
+ skills: control.assets.skills.length,
185
+ agents: control.assets.agents.length,
186
+ commands: control.assets.commands.length,
187
+ rules: control.assets.rules.length,
188
+ hooks: control.assets.hooks.length,
189
+ workflows: control.assets.workflows.length,
190
+ docs: control.assets.docs.length,
191
+ dotfiles: control.assets.dotfiles.length,
192
+ schemas: control.assets.schemas.length,
193
+ },
67
194
  },
68
195
  };
69
196
 
70
197
  await writeJson(profilePath, template);
198
+ await writeJson(controlPath, control);
71
199
  await writeJson(reportPath, report);
72
200
 
73
201
  return { profile_path: profilePath, report_path: reportPath };