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
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import Ajv from "ajv";
4
+ import addFormats from "ajv-formats";
5
+ import { CONTROL_V0_SCHEMA_ID } from "./control_v0.js";
6
+
7
+ type ValidationResult = {
8
+ ok: boolean;
9
+ errors: any[] | null | undefined;
10
+ };
11
+
12
+ async function loadControlSchema() {
13
+ const schemaPath = fileURLToPath(new URL("../schemas/mova.control_v0.schema.json", import.meta.url));
14
+ const raw = await fs.readFile(schemaPath, "utf8");
15
+ return JSON.parse(raw);
16
+ }
17
+
18
+ export async function validateControlV0Schema(control: any): Promise<ValidationResult> {
19
+ const schema = await loadControlSchema();
20
+ const ajv = new (Ajv as any)({ allErrors: true, strict: true, validateSchema: false });
21
+ (addFormats as any)(ajv);
22
+ ajv.addSchema(schema, CONTROL_V0_SCHEMA_ID);
23
+ const validate = ajv.compile(schema);
24
+ const ok = Boolean(validate(control));
25
+ return { ok, errors: validate.errors };
26
+ }
package/src/init_v0.ts CHANGED
@@ -4,6 +4,7 @@ import { getAnthropicProfileV0Files } from "./anthropic_profile_v0.js";
4
4
  import { stableStringify } from "./stable_json.js";
5
5
  import { createExportZipV0 } from "./export_zip_v0.js";
6
6
  import { writeCleanClaudeProfileScaffoldV0 } from "./claude_profile_scaffold_v0.js";
7
+ import { defaultControlV0, type ControlV0 } from "./control_v0.js";
7
8
 
8
9
  type InitResult = {
9
10
  createdFiles: string[];
@@ -11,6 +12,11 @@ type InitResult = {
11
12
  zipSha256?: string;
12
13
  };
13
14
 
15
+ type InitOptions = {
16
+ controlOverride?: ControlV0;
17
+ assetsRoot?: string;
18
+ };
19
+
14
20
  async function writeTextFile(absPath: string, content: string) {
15
21
  await fs.mkdir(path.dirname(absPath), { recursive: true });
16
22
  await fs.writeFile(absPath, content, "utf8");
@@ -21,7 +27,38 @@ async function writeJsonFile(absPath: string, obj: any) {
21
27
  await fs.writeFile(absPath, stableStringify(obj) + "\n", "utf8");
22
28
  }
23
29
 
24
- export async function initProfileV0(outRoot: string, emitZip: boolean): Promise<InitResult> {
30
+ async function copyPresetAssets(control: ControlV0, assetsRoot: string, outRoot: string): Promise<string[]> {
31
+ const created: string[] = [];
32
+ const assets = [
33
+ ...control.assets.skills,
34
+ ...control.assets.agents,
35
+ ...control.assets.commands,
36
+ ...control.assets.rules,
37
+ ...control.assets.hooks,
38
+ ...control.assets.workflows,
39
+ ...control.assets.docs,
40
+ ...control.assets.dotfiles,
41
+ ...control.assets.schemas,
42
+ ];
43
+ for (const asset of assets) {
44
+ const sourceRel = asset.source_path ?? asset.path;
45
+ const source = path.isAbsolute(sourceRel) ? sourceRel : path.join(assetsRoot, sourceRel);
46
+ const target = path.join(outRoot, asset.path);
47
+ try {
48
+ await fs.stat(source);
49
+ } catch {
50
+ continue;
51
+ }
52
+ await fs.mkdir(path.dirname(target), { recursive: true });
53
+ if (source !== target) {
54
+ await fs.copyFile(source, target);
55
+ }
56
+ created.push(asset.path.replace(/\\/g, "/"));
57
+ }
58
+ return created.sort();
59
+ }
60
+
61
+ export async function initProfileV0(outRoot: string, emitZip: boolean, options?: InitOptions): Promise<InitResult> {
25
62
  const createdFiles: string[] = [];
26
63
  await writeCleanClaudeProfileScaffoldV0(outRoot);
27
64
  const profileFiles = getAnthropicProfileV0Files();
@@ -31,6 +68,16 @@ export async function initProfileV0(outRoot: string, emitZip: boolean): Promise<
31
68
  createdFiles.push(rel);
32
69
  }
33
70
 
71
+ const controlRel = path.join("mova", "control_v0.json").replace(/\\/g, "/");
72
+ const control = options?.controlOverride ?? defaultControlV0();
73
+ await writeJsonFile(path.join(outRoot, controlRel), control);
74
+ createdFiles.push(controlRel);
75
+
76
+ if (options?.assetsRoot) {
77
+ const assetFiles = await copyPresetAssets(control, options.assetsRoot, outRoot);
78
+ createdFiles.push(...assetFiles);
79
+ }
80
+
34
81
  const movaBase = path.join(outRoot, "mova", "claude_import", "v0");
35
82
  const initManifestRel = path.join("mova", "claude_import", "v0", "init_manifest_v0.json").replace(/\\/g, "/");
36
83
 
@@ -0,0 +1,157 @@
1
+ export function getMovaObserveScriptV0(): string {
2
+ return [
3
+ "#!/usr/bin/env node",
4
+ "import fs from \"node:fs/promises\";",
5
+ "import path from \"node:path\";",
6
+ "import crypto from \"node:crypto\";",
7
+ "const KEY_RE = /(api[_-]?key|token|secret|password|authorization|bearer)/i;",
8
+ "const INLINE_SECRET_RE = /(sk-[a-zA-Z0-9]{8,})/g;",
9
+ "const PLACEHOLDER_RE = /^\\$\\{[A-Z0-9_]+(?::-?[^}]+)?\\}$/;",
10
+ "const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();",
11
+ "const args = process.argv.slice(2);",
12
+ "const readArg = (name) => {",
13
+ " const idx = args.indexOf(name);",
14
+ " if (idx === -1) return undefined;",
15
+ " return args[idx + 1];",
16
+ "};",
17
+ "const eventType = readArg(\"--event\") || process.env.CLAUDE_HOOK_EVENT || \"post_tool_use\";",
18
+ "const stdoutTailBytes = Number(readArg(\"--stdout-tail-bytes\") || process.env.MOVA_OBS_STDOUT_TAIL_BYTES || 4000);",
19
+ "const stderrTailBytes = Number(readArg(\"--stderr-tail-bytes\") || process.env.MOVA_OBS_STDERR_TAIL_BYTES || 4000);",
20
+ "const maxEventBytes = Number(readArg(\"--max-event-bytes\") || process.env.MOVA_OBS_MAX_EVENT_BYTES || 20000);",
21
+ "const tailLines = Number(readArg(\"--tail-lines\") || process.env.MOVA_OBS_TAIL_LINES || 50);",
22
+ "const outputDir = readArg(\"--output-dir\") || process.env.MOVA_OBS_OUTPUT_DIR || \".mova/episodes\";",
23
+ "const now = new Date();",
24
+ "const iso = now.toISOString();",
25
+ "const env = process.env;",
26
+ "const sessionId = env.CLAUDE_SESSION_ID || env.CLAUDE_RUN_ID;",
27
+ "async function ensureRunId(root) {",
28
+ " if (sessionId) return sessionId;",
29
+ " const currentPath = path.join(root, \".current_run_id\");",
30
+ " try {",
31
+ " const raw = await fs.readFile(currentPath, \"utf8\");",
32
+ " if (raw.trim()) return raw.trim();",
33
+ " } catch {}",
34
+ " const runId = `run_${Date.now()}_${crypto.randomBytes(4).toString(\"hex\")}`;",
35
+ " await fs.mkdir(root, { recursive: true });",
36
+ " await fs.writeFile(currentPath, runId, \"utf8\");",
37
+ " return runId;",
38
+ "}",
39
+ "function tailLinesFn(text, maxLines) {",
40
+ " const lines = text.split(/\\r?\\n/);",
41
+ " if (lines.length <= maxLines) return text;",
42
+ " return lines.slice(-maxLines).join(\"\\n\");",
43
+ "}",
44
+ "function tailBytesFn(text, maxBytes) {",
45
+ " if (!maxBytes || maxBytes <= 0) return \"\";",
46
+ " const buf = Buffer.from(text, \"utf8\");",
47
+ " if (buf.length <= maxBytes) return text;",
48
+ " return buf.slice(buf.length - maxBytes).toString(\"utf8\");",
49
+ "}",
50
+ "function trimText(text, maxBytes, maxLines) {",
51
+ " return tailBytesFn(tailLinesFn(text, maxLines), maxBytes);",
52
+ "}",
53
+ "function redactText(input) {",
54
+ " let out = input;",
55
+ " out = out.replace(INLINE_SECRET_RE, () => \"[REDACTED_TOKEN]\");",
56
+ " out = out.replace(/^([A-Z0-9_]{3,80})\\s*=\\s*(.+)$/gmi, (line, k, v) => {",
57
+ " if (!KEY_RE.test(k)) return line;",
58
+ " if (PLACEHOLDER_RE.test(String(v).trim())) return line;",
59
+ " return `${k}=[REDACTED_VALUE_LEN_${String(v).length}]`;",
60
+ " });",
61
+ " return out;",
62
+ "}",
63
+ "function stableStringify(value) {",
64
+ " if (Array.isArray(value)) return `[${value.map(stableStringify).join(\",\")}]`;",
65
+ " if (value && typeof value === \"object\") {",
66
+ " const keys = Object.keys(value).sort();",
67
+ " const parts = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`);",
68
+ " return `{${parts.join(\",\")}}`;",
69
+ " }",
70
+ " return JSON.stringify(value);",
71
+ "}",
72
+ "function sha(text) {",
73
+ " return crypto.createHash(\"sha256\").update(text, \"utf8\").digest(\"hex\");",
74
+ "}",
75
+ "async function run() {",
76
+ " const root = path.join(projectDir, outputDir);",
77
+ " const runId = await ensureRunId(root);",
78
+ " const runDir = path.join(root, runId);",
79
+ " await fs.mkdir(runDir, { recursive: true });",
80
+ " const event = {",
81
+ " ts: iso,",
82
+ " event_type: String(eventType),",
83
+ " tool_name: env.CLAUDE_TOOL_NAME || null,",
84
+ " ok: env.CLAUDE_TOOL_STATUS ? env.CLAUDE_TOOL_STATUS === \"0\" : null,",
85
+ " durations: env.CLAUDE_TOOL_DURATION_MS ? { tool_ms: Number(env.CLAUDE_TOOL_DURATION_MS) } : null,",
86
+ " paths: {",
87
+ " input: env.CLAUDE_TOOL_INPUT_FILE_PATH || null,",
88
+ " output: env.CLAUDE_TOOL_OUTPUT_FILE_PATH || null",
89
+ " },",
90
+ " stdout_tail: null,",
91
+ " stderr_tail: null,",
92
+ " mcp: { server_name: env.CLAUDE_MCP_SERVER || null },",
93
+ " hashes: { input_hash: null, output_hash: null }",
94
+ " };",
95
+ " const stdoutRaw = env.CLAUDE_TOOL_STDOUT || env.CLAUDE_TOOL_OUTPUT || \"\";",
96
+ " const stderrRaw = env.CLAUDE_TOOL_STDERR || \"\";",
97
+ " let outputHashPayload = \"\";",
98
+ " if (stdoutRaw) {",
99
+ " const trimmed = trimText(stdoutRaw, stdoutTailBytes, tailLines);",
100
+ " const redacted = redactText(trimmed);",
101
+ " event.stdout_tail = redacted;",
102
+ " outputHashPayload += redacted;",
103
+ " }",
104
+ " if (stderrRaw) {",
105
+ " const trimmed = trimText(stderrRaw, stderrTailBytes, tailLines);",
106
+ " const redacted = redactText(trimmed);",
107
+ " event.stderr_tail = redacted;",
108
+ " outputHashPayload += `\\n${redacted}`;",
109
+ " }",
110
+ " if (outputHashPayload) {",
111
+ " event.hashes.output_hash = sha(outputHashPayload);",
112
+ " }",
113
+ " const inputRaw = env.CLAUDE_TOOL_INPUT || \"\";",
114
+ " if (inputRaw) {",
115
+ " const trimmed = trimText(inputRaw, stdoutTailBytes, tailLines);",
116
+ " const redacted = redactText(trimmed);",
117
+ " event.hashes.input_hash = sha(redacted);",
118
+ " }",
119
+ " let line = stableStringify(event);",
120
+ " if (line.length > maxEventBytes) {",
121
+ " event.stdout_tail = null;",
122
+ " event.stderr_tail = null;",
123
+ " line = stableStringify(event);",
124
+ " }",
125
+ " await fs.appendFile(path.join(runDir, \"events.jsonl\"), line + \"\\n\", \"utf8\");",
126
+ " const summaryPath = path.join(runDir, \"summary.json\");",
127
+ " let summary = {",
128
+ " run_id: runId,",
129
+ " started_at: iso,",
130
+ " last_event_at: iso,",
131
+ " counts: {},",
132
+ " tools: {}",
133
+ " };",
134
+ " try {",
135
+ " const raw = await fs.readFile(summaryPath, \"utf8\");",
136
+ " summary = JSON.parse(raw);",
137
+ " } catch {}",
138
+ " summary.last_event_at = iso;",
139
+ " summary.counts[event.event_type] = (summary.counts[event.event_type] || 0) + 1;",
140
+ " if (event.tool_name) {",
141
+ " summary.tools[event.tool_name] = (summary.tools[event.tool_name] || 0) + 1;",
142
+ " }",
143
+ " await fs.writeFile(summaryPath, stableStringify(summary) + \"\\n\", \"utf8\");",
144
+ " await fs.appendFile(path.join(root, \"index.jsonl\"), stableStringify({ ts: iso, run_id: runId, event_type: event.event_type }) + \"\\n\", \"utf8\");",
145
+ " if (String(event.event_type).toLowerCase() === \"stop\") {",
146
+ " const currentPath = path.join(root, \".current_run_id\");",
147
+ " try { await fs.rm(currentPath); } catch {}",
148
+ " }",
149
+ "}",
150
+ "run().catch((err) => {",
151
+ " const msg = String(err?.message || err || \"failed\");",
152
+ " process.stdout.write(JSON.stringify({ feedback: `MOVA observe: ${msg}`, suppressOutput: true }));",
153
+ " process.exit(0);",
154
+ "});",
155
+ "",
156
+ ].join("\n");
157
+ }
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ type RunSummary = {
5
+ run_id?: string;
6
+ started_at?: string;
7
+ last_event_at?: string;
8
+ counts?: Record<string, number>;
9
+ };
10
+
11
+ async function exists(p: string): Promise<boolean> {
12
+ try {
13
+ await fs.stat(p);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ async function readJson(p: string) {
21
+ const raw = await fs.readFile(p, "utf8");
22
+ return JSON.parse(raw);
23
+ }
24
+
25
+ function sumCounts(summary: RunSummary | null): number {
26
+ if (!summary?.counts) return 0;
27
+ return Object.values(summary.counts).reduce((acc, value) => acc + Number(value || 0), 0);
28
+ }
29
+
30
+ export async function listObservabilityRuns(projectDir: string) {
31
+ const root = path.join(projectDir, ".mova", "episodes");
32
+ if (!(await exists(root))) return [];
33
+ const entries = await fs.readdir(root, { withFileTypes: true });
34
+ const runs = [];
35
+ for (const entry of entries) {
36
+ if (!entry.isDirectory()) continue;
37
+ const runId = entry.name;
38
+ const summaryPath = path.join(root, runId, "summary.json");
39
+ const summary = (await exists(summaryPath)) ? ((await readJson(summaryPath)) as RunSummary) : null;
40
+ const lastEventAt = summary?.last_event_at ?? null;
41
+ runs.push({
42
+ run_id: runId,
43
+ last_event_at: lastEventAt,
44
+ started_at: summary?.started_at ?? null,
45
+ events: sumCounts(summary),
46
+ });
47
+ }
48
+ runs.sort((a, b) => String(b.last_event_at ?? "").localeCompare(String(a.last_event_at ?? "")));
49
+ return runs;
50
+ }
51
+
52
+ export async function tailObservabilityEvents(projectDir: string, runId: string, limit = 20) {
53
+ const eventsPath = path.join(projectDir, ".mova", "episodes", runId, "events.jsonl");
54
+ if (!(await exists(eventsPath))) return [];
55
+ const raw = await fs.readFile(eventsPath, "utf8");
56
+ const lines = raw.trim().split(/\r?\n/).filter(Boolean);
57
+ return lines.slice(-limit);
58
+ }
59
+
60
+ export async function readObservabilitySummary(projectDir: string, runId: string) {
61
+ const summaryPath = path.join(projectDir, ".mova", "episodes", runId, "summary.json");
62
+ if (!(await exists(summaryPath))) return null;
63
+ return await readJson(summaryPath);
64
+ }
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ type PresetInfo = {
6
+ name: string;
7
+ root: string;
8
+ control_path: string;
9
+ assets_root: string;
10
+ };
11
+
12
+ async function exists(p: string): Promise<boolean> {
13
+ try {
14
+ await fs.stat(p);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export function getPresetsRoot(): string {
22
+ const here = path.dirname(fileURLToPath(import.meta.url));
23
+ const pkgRoot = path.resolve(here, "..");
24
+ return path.join(pkgRoot, "presets");
25
+ }
26
+
27
+ export async function listPresets(): Promise<string[]> {
28
+ const root = getPresetsRoot();
29
+ if (!(await exists(root))) return [];
30
+ const entries = await fs.readdir(root, { withFileTypes: true });
31
+ const names: string[] = [];
32
+ for (const entry of entries) {
33
+ if (!entry.isDirectory()) continue;
34
+ const controlPath = path.join(root, entry.name, "control_v0.json");
35
+ if (await exists(controlPath)) names.push(entry.name);
36
+ }
37
+ return names.sort();
38
+ }
39
+
40
+ export async function resolvePreset(name: string): Promise<PresetInfo | null> {
41
+ const root = getPresetsRoot();
42
+ const presetRoot = path.join(root, name);
43
+ const controlPath = path.join(presetRoot, "control_v0.json");
44
+ const assetsRoot = path.join(presetRoot, "assets");
45
+ if (!(await exists(controlPath))) return null;
46
+ return {
47
+ name,
48
+ root: presetRoot,
49
+ control_path: controlPath,
50
+ assets_root: assetsRoot,
51
+ };
52
+ }
53
+
54
+ export async function readPresetControlRaw(name: string): Promise<string | null> {
55
+ const preset = await resolvePreset(name);
56
+ if (!preset) return null;
57
+ return await fs.readFile(preset.control_path, "utf8");
58
+ }
package/src/redaction.ts CHANGED
@@ -8,6 +8,11 @@ export type RedactionHit = {
8
8
 
9
9
  const KEY_RE = /(api[_-]?key|token|secret|password|authorization|bearer)/i;
10
10
  const INLINE_SECRET_RE = /(sk-[a-zA-Z0-9]{8,})/g; // best‑effort
11
+ const PLACEHOLDER_RE = /^\$\{[A-Z0-9_]+(?::-?[^}]+)?\}$/;
12
+
13
+ function isPlaceholderValue(value: string): boolean {
14
+ return PLACEHOLDER_RE.test(value.trim());
15
+ }
11
16
 
12
17
  export function redactText(input: string): { redacted: string; hits: RedactionHit[] } {
13
18
  const hits: RedactionHit[] = [];
@@ -43,7 +48,7 @@ export function redactJson(obj: unknown): { redacted: unknown; hits: RedactionHi
43
48
  if (typeof x === "object") {
44
49
  const out: any = {};
45
50
  for (const [k, v] of Object.entries(x)) {
46
- if (KEY_RE.test(k) && typeof v === "string") {
51
+ if (KEY_RE.test(k) && typeof v === "string" && !isPlaceholderValue(v)) {
47
52
  hits.push({ rule_id: "json_secret_field", key: k, len: v.length });
48
53
  out[k] = `[REDACTED_VALUE_LEN_${v.length}]`;
49
54
  } else {
package/src/run_import.ts CHANGED
@@ -11,15 +11,25 @@ import { getAnthropicProfileV0Files } from "./anthropic_profile_v0.js";
11
11
  import { lintV0, type LintReportV0 } from "./lint_v0.js";
12
12
  import { stableStringify } from "./stable_json.js";
13
13
  import { createExportZipV0 } from "./export_zip_v0.js";
14
- import { buildMovaOverlayV0, buildMovaControlEntryV0, MOVA_CONTROL_ENTRY_MARKER } from "./mova_overlay_v0.js";
14
+ import { buildMovaOverlayV0, buildMovaControlEntryV0 } from "./mova_overlay_v0.js";
15
15
  import { scanInputPolicyV0 } from "./input_policy_v0.js";
16
16
  import { EvidenceWriter } from "@leryk1981/mova-core-engine";
17
17
  import { MOVA_SPEC_BINDINGS_V0 } from "./mova_spec_bindings_v0.js";
18
18
  import { writeCleanClaudeProfileScaffoldV0 } from "./claude_profile_scaffold_v0.js";
19
+ import {
20
+ controlFromSettingsV0,
21
+ controlToMcpJson,
22
+ controlToSettingsV0,
23
+ normalizeControlV0,
24
+ type ControlV0,
25
+ } from "./control_v0.js";
26
+ import { getMovaObserveScriptV0 } from "./observability_writer_v0.js";
19
27
 
20
28
  type Found = {
21
29
  claudeMdPath?: string;
22
30
  mcpJsonPath?: string;
31
+ settingsPath?: string;
32
+ controlPath?: string;
23
33
  skillFiles: string[];
24
34
  skipped: Array<{ path: string; reason: string }>;
25
35
  };
@@ -33,6 +43,10 @@ async function exists(p: string): Promise<boolean> {
33
43
  }
34
44
  }
35
45
 
46
+ function isObject(value: any): value is Record<string, any> {
47
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
48
+ }
49
+
36
50
  async function sha256File(p: string): Promise<string> {
37
51
  const buf = await fs.readFile(p);
38
52
  return crypto.createHash("sha256").update(buf).digest("hex");
@@ -68,6 +82,12 @@ async function scanProject(opts: ImportOptions): Promise<Found> {
68
82
  const mcpJson = path.join(projectDir, ".mcp.json");
69
83
  if (await exists(mcpJson)) found.mcpJsonPath = mcpJson;
70
84
 
85
+ const settingsJson = path.join(projectDir, ".claude", "settings.json");
86
+ if (await exists(settingsJson)) found.settingsPath = settingsJson;
87
+
88
+ const controlJson = path.join(projectDir, "mova", "control_v0.json");
89
+ if (await exists(controlJson)) found.controlPath = controlJson;
90
+
71
91
  const skillsRoot = path.join(projectDir, ".claude", "skills");
72
92
  if (await exists(skillsRoot)) {
73
93
  const stack = [skillsRoot];
@@ -127,10 +147,15 @@ async function loadAndRedactJson(p: string) {
127
147
  return { raw, parsed, redacted, hits };
128
148
  }
129
149
 
130
- async function ensureClaudeControlEntry(claudePath: string, block: string, marker: string) {
131
- const content = await fs.readFile(claudePath, "utf8");
132
- if (content.includes(marker)) return;
133
- await fs.writeFile(claudePath, `${block}\n${content}`, "utf8");
150
+ function updateClaudeBlock(content: string, marker: string, block: string): string {
151
+ if (content.includes(marker)) {
152
+ const idx = content.indexOf(marker);
153
+ const after = content.slice(idx);
154
+ const split = after.split("\n\n");
155
+ split[0] = block.trimEnd();
156
+ return content.slice(0, idx) + split.join("\n\n");
157
+ }
158
+ return `${block}\n${content}`;
134
159
  }
135
160
 
136
161
  function orderedObject(obj: any) {
@@ -177,26 +202,75 @@ export async function runImport(opts: ImportOptions): Promise<ImportResult> {
177
202
  return redacted;
178
203
  }
179
204
 
205
+ /** Process a JSON file – redact and record */
206
+ async function processJsonFile(rel: string, absPath: string) {
207
+ const { parsed, hits } = await loadAndRedactJson(absPath);
208
+ inputs.push({ rel, sha256: await sha256File(absPath) });
209
+ redactionHits.push(...hits);
210
+ return parsed;
211
+ }
212
+
213
+ const controlRel = "mova/control_v0.json";
214
+ let controlOutput: ControlV0 | null = null;
215
+ let controlDefaults: string[] = [];
216
+ let controlSource: "control_file" | "migrated" = "migrated";
217
+ let controlMigrationReport: any | null = null;
218
+
219
+ if (found.controlPath) {
220
+ const controlParsed = await processJsonFile(controlRel, found.controlPath);
221
+ const normalized = normalizeControlV0(controlParsed);
222
+ controlOutput = normalized.control;
223
+ controlDefaults = normalized.defaults;
224
+ controlSource = "control_file";
225
+ } else {
226
+ let settingsParsed: any | undefined;
227
+ let mcpParsed: any | undefined;
228
+ let settingsFound = false;
229
+ let mcpFound = false;
230
+
231
+ if (found.settingsPath) {
232
+ settingsParsed = await processJsonFile(".claude/settings.json", found.settingsPath);
233
+ settingsFound = true;
234
+ }
235
+
236
+ if (found.mcpJsonPath) {
237
+ mcpParsed = await processJsonFile(".mcp.json", found.mcpJsonPath);
238
+ mcpFound = true;
239
+ }
240
+
241
+ const migrated = controlFromSettingsV0(settingsParsed, mcpParsed);
242
+ controlOutput = migrated.control;
243
+ controlDefaults = migrated.defaults;
244
+ controlSource = "migrated";
245
+
246
+ controlMigrationReport = {
247
+ profile_version: "v0",
248
+ control_version: "control_v0",
249
+ project_dir: projectDir,
250
+ control_path: controlRel,
251
+ found: {
252
+ settings_json: settingsFound,
253
+ mcp_json: mcpFound,
254
+ },
255
+ sources: {
256
+ claude_md: "default",
257
+ overlay: "default",
258
+ policy: settingsFound ? "settings_json" : "default",
259
+ mcp: mcpFound ? "mcp_json" : "default",
260
+ },
261
+ defaults_used: controlDefaults,
262
+ };
263
+ }
264
+
265
+ const control = controlOutput ?? normalizeControlV0({}).control;
266
+ const mcpJsonParsed = controlToMcpJson(control);
267
+
180
268
  // CLAUDE.md
181
269
  let claudeMdRedacted = "";
182
270
  if (found.claudeMdPath) {
183
271
  claudeMdRedacted = await processTextFile("CLAUDE.md", found.claudeMdPath);
184
272
  }
185
273
 
186
- // .mcp.json
187
- let mcpJsonRedacted = "";
188
- let mcpJsonParsed: any | undefined;
189
- if (found.mcpJsonPath) {
190
- const { parsed, redacted, hits } = await loadAndRedactJson(found.mcpJsonPath);
191
- mcpJsonParsed = parsed;
192
- mcpJsonRedacted = stableStringify(redacted);
193
- redactionHits.push(...hits);
194
- inputs.push({
195
- rel: ".mcp.json",
196
- sha256: await sha256File(found.mcpJsonPath),
197
- });
198
- }
199
-
200
274
  // skill files
201
275
  const skillRedactedMap: Record<string, string> = {};
202
276
  for (const f of found.skillFiles) {
@@ -240,6 +314,14 @@ export async function runImport(opts: ImportOptions): Promise<ImportResult> {
240
314
  };
241
315
  await writeEvidenceArtifact(evidenceWriter, movaBase, "VERSION.json", versionInfo);
242
316
  await writeEvidenceArtifact(evidenceWriter, movaBase, "input_policy_report_v0.json", inputPolicy);
317
+ if (controlMigrationReport) {
318
+ await writeEvidenceArtifact(
319
+ evidenceWriter,
320
+ movaBase,
321
+ "control_migration_report_v0.json",
322
+ controlMigrationReport
323
+ );
324
+ }
243
325
  }
244
326
 
245
327
  if (opts.strict && !inputPolicy.ok) {
@@ -351,11 +433,16 @@ export async function runImport(opts: ImportOptions): Promise<ImportResult> {
351
433
 
352
434
  if (!opts.dryRun && opts.emitProfile) {
353
435
  await writeCleanClaudeProfileScaffoldV0(outRoot);
354
- if (opts.emitOverlay) {
436
+ const overlayEnabled = opts.emitOverlay && control.overlay.enable;
437
+ if (overlayEnabled) {
355
438
  const controlEntry = buildMovaControlEntryV0(overlayParams);
356
439
  const claudePath = path.join(outRoot, "CLAUDE.md");
357
440
  if (await exists(claudePath)) {
358
- await ensureClaudeControlEntry(claudePath, controlEntry, MOVA_CONTROL_ENTRY_MARKER);
441
+ if (control.claude_md.inject_control_entry) {
442
+ const raw = await fs.readFile(claudePath, "utf8");
443
+ const updated = updateClaudeBlock(raw, control.claude_md.marker, controlEntry);
444
+ await fs.writeFile(claudePath, updated, "utf8");
445
+ }
359
446
  }
360
447
  const overlayFiles = buildMovaOverlayV0(overlayParams);
361
448
  for (const [rel, content] of Object.entries(overlayFiles)) {
@@ -367,8 +454,13 @@ export async function runImport(opts: ImportOptions): Promise<ImportResult> {
367
454
  if (rel === "CLAUDE.md" || rel === ".claude/settings.json") continue;
368
455
  await writeTextFile(path.join(outRoot, rel), content);
369
456
  }
370
- if (mcpJsonParsed) {
371
- await writeJsonFile(path.join(outRoot, ".mcp.json"), mcpJsonParsed);
457
+ await writeJsonFile(path.join(outRoot, ".mcp.json"), mcpJsonParsed);
458
+ await writeJsonFile(path.join(outRoot, ".claude", "settings.json"), controlToSettingsV0(control));
459
+ await writeJsonFile(path.join(outRoot, controlRel), control);
460
+ if (control.observability.enable && control.observability.writer?.script_path) {
461
+ const scriptPath = path.join(outRoot, control.observability.writer.script_path);
462
+ await fs.mkdir(path.dirname(scriptPath), { recursive: true });
463
+ await fs.writeFile(scriptPath, getMovaObserveScriptV0(), "utf8");
372
464
  }
373
465
  for (const skill of normalizedSkills) {
374
466
  const outRel = path.join(".claude", "skills", skill.normDir, "SKILL.md");
@@ -396,9 +488,22 @@ export async function runImport(opts: ImportOptions): Promise<ImportResult> {
396
488
  })),
397
489
  };
398
490
 
491
+ const mcpServersRaw = isObject(mcpJsonParsed?.mcpServers)
492
+ ? mcpJsonParsed?.mcpServers
493
+ : mcpJsonParsed?.servers;
494
+ const mcpServersList = Array.isArray(mcpServersRaw)
495
+ ? mcpServersRaw
496
+ : isObject(mcpServersRaw)
497
+ ? Object.entries(mcpServersRaw).map(([name, server]) => ({
498
+ name,
499
+ command: typeof server?.command === "string" ? server.command : "unknown",
500
+ args: Array.isArray(server?.args) ? server.args : [],
501
+ env_keys: isObject(server?.env) ? Object.keys(server.env).sort() : [],
502
+ }))
503
+ : [];
399
504
  const mcpServers = {
400
505
  profile_version: "v0",
401
- servers: Array.isArray(mcpJsonParsed?.servers) ? mcpJsonParsed?.servers : [],
506
+ servers: mcpServersList,
402
507
  };
403
508
 
404
509
  if (!opts.dryRun) {
@@ -449,6 +554,7 @@ export async function runImport(opts: ImportOptions): Promise<ImportResult> {
449
554
  },
450
555
  };
451
556
 
557
+ const mcpSourcePresent = controlSource === "control_file" || Boolean(found.mcpJsonPath);
452
558
  const manifest = {
453
559
  tool: "mova-claude-import",
454
560
  version: "v0",
@@ -458,7 +564,7 @@ export async function runImport(opts: ImportOptions): Promise<ImportResult> {
458
564
  inputs: inputs.sort((a, b) => a.rel.localeCompare(b.rel)),
459
565
  imported: {
460
566
  claude_md: Boolean(found.claudeMdPath),
461
- mcp_json: Boolean(found.mcpJsonPath),
567
+ mcp_json: mcpSourcePresent,
462
568
  skills_count: normalizedSkills.length,
463
569
  },
464
570
  skipped: found.skipped,
@@ -498,7 +604,7 @@ export async function runImport(opts: ImportOptions): Promise<ImportResult> {
498
604
  lintReport = await lintV0({
499
605
  outRoot,
500
606
  emitProfile: opts.emitProfile,
501
- mcpExpected: Boolean(found.mcpJsonPath),
607
+ mcpExpected: mcpSourcePresent,
502
608
  });
503
609
  await writeEvidenceArtifact(evidenceWriter, movaBase, "lint_report_v0.json", lintReport);
504
610
  }