@aiwg/cli 2026.9.20 → 2026.9.21

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 (272) hide show
  1. package/agentic/code/providers/capability-matrix.yaml +58 -6
  2. package/agentic/code/providers/model-capabilities.v1.json +34 -0
  3. package/agentic/code/providers/model-catalog.v1.json +55 -13
  4. package/bin/aiwg.mjs +2 -0
  5. package/dist/src/api/index.d.ts +1 -0
  6. package/dist/src/api/index.js +1 -0
  7. package/dist/src/artifacts/enrichment/cli.js +1 -1
  8. package/dist/src/artifacts/views/cli.js +1 -1
  9. package/dist/src/audit/operator-decision.js +10 -0
  10. package/dist/src/auth/credential-store.js +127 -27
  11. package/dist/src/catalog/builtin-models.json +272 -40
  12. package/dist/src/catalog/cli.js +1 -1
  13. package/dist/src/catalog/cli.mjs +1 -1
  14. package/dist/src/cli/handlers/effect.js +872 -0
  15. package/dist/src/cli/handlers/help.js +2 -0
  16. package/dist/src/cli/handlers/index.js +4 -2
  17. package/dist/src/cli/handlers/models.js +41 -10
  18. package/dist/src/cli/handlers/ralph.js +1 -1
  19. package/dist/src/cli/handlers/sessions.js +113 -37
  20. package/dist/src/cli/handlers/subcommands.js +31 -0
  21. package/dist/src/cli/handlers/use.js +46 -9
  22. package/dist/src/cli/handlers/utilities.js +10 -0
  23. package/dist/src/cli/handlers/workspace.js +3 -1
  24. package/dist/src/cli/scope-resolver.js +112 -9
  25. package/dist/src/cli/services/deployment-verification.js +5 -0
  26. package/dist/src/config/aiwg-config.js +3 -0
  27. package/dist/src/decision/acceptance-rollout.js +93 -0
  28. package/dist/src/decision/acceptance.js +175 -0
  29. package/dist/src/decision/adapters/jev.js +234 -13
  30. package/dist/src/decision/adapters/llm-subagent.js +60 -12
  31. package/dist/src/decision/admission.js +550 -0
  32. package/dist/src/decision/batch-benchmark.js +47 -0
  33. package/dist/src/decision/batch-receipts/accounting.js +75 -0
  34. package/dist/src/decision/batch-receipts/export.js +40 -0
  35. package/dist/src/decision/batch-receipts/index.js +9 -0
  36. package/dist/src/decision/batch-receipts/protection.js +40 -0
  37. package/dist/src/decision/batch-receipts/receipt.js +44 -0
  38. package/dist/src/decision/batch-receipts/result-store.js +341 -0
  39. package/dist/src/decision/batch-receipts/store.js +362 -0
  40. package/dist/src/decision/batch-receipts/types.js +2 -0
  41. package/dist/src/decision/batch-receipts/validate.js +127 -0
  42. package/dist/src/decision/batch.js +68 -0
  43. package/dist/src/decision/calibration/index.js +4 -0
  44. package/dist/src/decision/calibration/receipts.js +130 -0
  45. package/dist/src/decision/calibration/registry.js +258 -0
  46. package/dist/src/decision/calibration/types.js +2 -0
  47. package/dist/src/decision/compile-cache/benchmark.js +62 -0
  48. package/dist/src/decision/compile-cache/compatibility.js +65 -0
  49. package/dist/src/decision/compile-cache/file-store.js +280 -0
  50. package/dist/src/decision/compile-cache/identity.js +26 -0
  51. package/dist/src/decision/compile-cache/index.js +9 -0
  52. package/dist/src/decision/compile-cache/prefix.js +21 -0
  53. package/dist/src/decision/compile-cache/records.js +86 -0
  54. package/dist/src/decision/compile-cache/runtime.js +68 -0
  55. package/dist/src/decision/compile-cache/store.js +108 -0
  56. package/dist/src/decision/compile-cache/types.js +2 -0
  57. package/dist/src/decision/context-plan.js +234 -0
  58. package/dist/src/decision/context-qualification.js +60 -0
  59. package/dist/src/decision/ensemble/aggregate.js +215 -0
  60. package/dist/src/decision/ensemble/contract.js +316 -0
  61. package/dist/src/decision/ensemble/drift.js +51 -0
  62. package/dist/src/decision/ensemble/index.js +6 -0
  63. package/dist/src/decision/ensemble/report.js +71 -0
  64. package/dist/src/decision/ensemble/types.js +2 -0
  65. package/dist/src/decision/evaluate.js +1024 -78
  66. package/dist/src/decision/file-lifecycle-store.js +168 -0
  67. package/dist/src/decision/graph-beam.js +40 -0
  68. package/dist/src/decision/graph-budget.js +110 -0
  69. package/dist/src/decision/graph-decision-bridge.js +93 -0
  70. package/dist/src/decision/graph-evidence.js +123 -0
  71. package/dist/src/decision/graph-flow-adapter.js +56 -0
  72. package/dist/src/decision/graph-flow.js +105 -0
  73. package/dist/src/decision/graph-parallel.js +79 -0
  74. package/dist/src/decision/graph-patterns.js +20 -0
  75. package/dist/src/decision/graph-run-store.js +66 -0
  76. package/dist/src/decision/graph-run.js +166 -0
  77. package/dist/src/decision/graph-skill-bridge.js +91 -0
  78. package/dist/src/decision/graph-templates.js +56 -0
  79. package/dist/src/decision/graph.js +115 -0
  80. package/dist/src/decision/index.js +35 -0
  81. package/dist/src/decision/job-accounting.js +93 -0
  82. package/dist/src/decision/job-contract.js +186 -0
  83. package/dist/src/decision/job-effects.js +95 -0
  84. package/dist/src/decision/job-evaluate.js +80 -0
  85. package/dist/src/decision/job-gateway.js +121 -0
  86. package/dist/src/decision/job-http.js +194 -0
  87. package/dist/src/decision/job-payload-store.js +258 -0
  88. package/dist/src/decision/job-poll-limiter.js +92 -0
  89. package/dist/src/decision/job-quota.js +158 -0
  90. package/dist/src/decision/job-runtime.js +240 -0
  91. package/dist/src/decision/job-scheduler.js +26 -0
  92. package/dist/src/decision/job-service.js +117 -0
  93. package/dist/src/decision/job-store.js +264 -0
  94. package/dist/src/decision/job-worker.js +141 -0
  95. package/dist/src/decision/lifecycle.js +148 -0
  96. package/dist/src/decision/patterns/artifacts.js +173 -0
  97. package/dist/src/decision/patterns/catalog.js +108 -0
  98. package/dist/src/decision/patterns/durable-review.js +237 -0
  99. package/dist/src/decision/patterns/index.js +8 -0
  100. package/dist/src/decision/patterns/playground.js +401 -0
  101. package/dist/src/decision/patterns/recorded.js +43 -0
  102. package/dist/src/decision/patterns/specs.js +174 -0
  103. package/dist/src/decision/patterns/types.js +2 -0
  104. package/dist/src/decision/portable-secrets.js +53 -0
  105. package/dist/src/decision/projection.js +210 -0
  106. package/dist/src/decision/qualification/capture.js +67 -0
  107. package/dist/src/decision/qualification/drift.js +68 -0
  108. package/dist/src/decision/qualification/gate-evidence.js +172 -0
  109. package/dist/src/decision/qualification/gates.js +83 -0
  110. package/dist/src/decision/qualification/index.js +12 -0
  111. package/dist/src/decision/qualification/load.js +275 -0
  112. package/dist/src/decision/qualification/manifest.js +37 -0
  113. package/dist/src/decision/qualification/privacy.js +41 -0
  114. package/dist/src/decision/qualification/quality.js +213 -0
  115. package/dist/src/decision/qualification/release.js +116 -0
  116. package/dist/src/decision/qualification/runner.js +408 -0
  117. package/dist/src/decision/qualification/types.js +2 -0
  118. package/dist/src/decision/receipts.js +30 -7
  119. package/dist/src/decision/result-cache/index.js +6 -0
  120. package/dist/src/decision/result-cache/integrity.js +28 -0
  121. package/dist/src/decision/result-cache/key.js +28 -0
  122. package/dist/src/decision/result-cache/service.js +92 -0
  123. package/dist/src/decision/result-cache/store.js +327 -0
  124. package/dist/src/decision/result-cache/types.js +3 -0
  125. package/dist/src/decision/review/authorization.js +110 -0
  126. package/dist/src/decision/review/effect-ledger-adapter.js +321 -0
  127. package/dist/src/decision/review/index.js +12 -0
  128. package/dist/src/decision/review/migrate.js +34 -0
  129. package/dist/src/decision/review/operator-audit.js +59 -0
  130. package/dist/src/decision/review/recovery-adapters.js +252 -0
  131. package/dist/src/decision/review/recovery.js +33 -0
  132. package/dist/src/decision/review/service.js +498 -0
  133. package/dist/src/decision/review/store.js +219 -0
  134. package/dist/src/decision/review/types.js +2 -0
  135. package/dist/src/decision/review/validate.js +205 -0
  136. package/dist/src/decision/scheduler.js +115 -0
  137. package/dist/src/decision/telemetry/context.js +42 -0
  138. package/dist/src/decision/telemetry/debug-sidecar.js +152 -0
  139. package/dist/src/decision/telemetry/exporter.js +77 -0
  140. package/dist/src/decision/telemetry/file-debug-backend.js +112 -0
  141. package/dist/src/decision/telemetry/index.js +13 -0
  142. package/dist/src/decision/telemetry/mapping.js +146 -0
  143. package/dist/src/decision/telemetry/metrics.js +164 -0
  144. package/dist/src/decision/telemetry/otlp-http.js +183 -0
  145. package/dist/src/decision/telemetry/redaction.js +93 -0
  146. package/dist/src/decision/telemetry/retention.js +109 -0
  147. package/dist/src/decision/telemetry/runtime.js +310 -0
  148. package/dist/src/decision/telemetry/trace.js +158 -0
  149. package/dist/src/decision/telemetry/types.js +3 -0
  150. package/dist/src/decision/validate.js +135 -5
  151. package/dist/src/effects/checkpoint-sinks.js +94 -0
  152. package/dist/src/effects/checkpoint.js +76 -0
  153. package/dist/src/effects/decision-links.js +54 -0
  154. package/dist/src/effects/errors.js +48 -0
  155. package/dist/src/effects/identity.js +178 -0
  156. package/dist/src/effects/index.js +25 -0
  157. package/dist/src/effects/keyring.js +160 -0
  158. package/dist/src/effects/keys.js +130 -0
  159. package/dist/src/effects/ledger.js +435 -0
  160. package/dist/src/effects/lifecycle.js +110 -0
  161. package/dist/src/effects/lock-recovery.js +269 -0
  162. package/dist/src/effects/reader.js +103 -0
  163. package/dist/src/effects/records.js +114 -0
  164. package/dist/src/effects/redaction.js +51 -0
  165. package/dist/src/effects/schema.js +73 -0
  166. package/dist/src/effects/store.js +239 -0
  167. package/dist/src/effects/types.js +49 -0
  168. package/dist/src/effects/verifiers/decision.js +168 -0
  169. package/dist/src/effects/verifiers/file.js +106 -0
  170. package/dist/src/effects/verifiers/git.js +206 -0
  171. package/dist/src/effects/verifiers/index.js +90 -0
  172. package/dist/src/effects/verifiers/registry.js +148 -0
  173. package/dist/src/effects/verifiers/review.js +123 -0
  174. package/dist/src/effects/verifiers/tracker.js +299 -0
  175. package/dist/src/effects/verifiers/types.js +21 -0
  176. package/dist/src/effects/verify.js +295 -0
  177. package/dist/src/evidence/bundle.js +2 -0
  178. package/dist/src/extensions/commands/definitions.js +28 -0
  179. package/dist/src/extensions/manifest.js +1 -0
  180. package/dist/src/marketplace/provenance.js +25 -27
  181. package/dist/src/mcp/cli.mjs +11 -1
  182. package/dist/src/mcp/grok-build-config.mjs +189 -0
  183. package/dist/src/mcp/registry.js +21 -1
  184. package/dist/src/mcp/registry.mjs +20 -1
  185. package/dist/src/models/grok-build-models.js +356 -0
  186. package/dist/src/models/index.js +1 -0
  187. package/dist/src/models/model-capabilities.v1.json +34 -0
  188. package/dist/src/models/model-catalog.v1.json +55 -13
  189. package/dist/src/models/model-discovery.js +41 -5
  190. package/dist/src/models/provider-policy.js +3 -2
  191. package/dist/src/plugin/skill-command-translator.js +1 -0
  192. package/dist/src/providers/capability-matrix.yaml +58 -6
  193. package/dist/src/providers/grok-build-acp.js +192 -0
  194. package/dist/src/providers/grok-build-dispatch.js +251 -0
  195. package/dist/src/providers/grok-build-headless.js +170 -0
  196. package/dist/src/providers/muse-paths.js +105 -0
  197. package/dist/src/providers/provider-definitions.js +93 -7
  198. package/dist/src/providers/provider-definitions.mjs +21 -1
  199. package/dist/src/providers/provider-inventory.js +3 -0
  200. package/dist/src/security/artifact-attestation.js +4 -8
  201. package/dist/src/security/signing.js +119 -0
  202. package/dist/src/sessions/adapters/grok-build.js +187 -0
  203. package/dist/src/sessions/adapters/muse.js +412 -0
  204. package/dist/src/sessions/contracts.js +1 -1
  205. package/dist/src/sessions/grok-build-cli.js +196 -0
  206. package/dist/src/sessions/index.js +3 -0
  207. package/dist/src/sessions/workspace-discovery.js +9 -5
  208. package/dist/src/skills/deployer.js +33 -5
  209. package/dist/src/smiths/context-pipeline/claude-hook.js +2 -0
  210. package/dist/src/smiths/context-pipeline/context-file-safety.js +18 -0
  211. package/dist/src/smiths/context-pipeline/finalization.js +1 -32
  212. package/dist/src/smiths/context-pipeline/generator.js +12 -0
  213. package/dist/src/smiths/context-pipeline/index.js +1 -1
  214. package/dist/src/smiths/context-pipeline/managed-hook.js +2 -0
  215. package/dist/src/smiths/context-pipeline/muse-bridge.js +50 -0
  216. package/dist/src/smiths/context-pipeline/provider-policy.js +5 -0
  217. package/dist/src/smiths/context-pipeline/workspace-context.js +22 -16
  218. package/dist/src/smiths/hook-bridge/grok-build-translator.js +64 -0
  219. package/dist/src/smiths/hook-bridge/index.js +3 -0
  220. package/dist/src/smiths/hook-bridge/shim.js +2 -0
  221. package/dist/src/smiths/hook-bridge/types.js +5 -0
  222. package/dist/src/smiths/skillsmith/collision-detector.js +1 -0
  223. package/dist/src/storage/protected-files.js +102 -0
  224. package/dist/src/tracker/forge-http.js +371 -0
  225. package/dist/src/tracker/project-inputs.js +41 -0
  226. package/package.json +2 -1
  227. package/schemas/decision/BatchReceipt.v1.schema.json +50 -0
  228. package/schemas/decision/CalibrationArtifact.v1.schema.json +76 -0
  229. package/schemas/decision/CalibrationCompatibility.v1.schema.json +18 -0
  230. package/schemas/decision/CalibrationGovernanceReceipt.v1.schema.json +44 -0
  231. package/schemas/decision/DecisionAdmissionEvidence.v1.schema.json +43 -0
  232. package/schemas/decision/DecisionBinding.schema.json +309 -0
  233. package/schemas/decision/DecisionBinding.v1alpha2.schema.json +369 -0
  234. package/schemas/decision/DecisionCacheTelemetry.v1.schema.json +20 -0
  235. package/schemas/decision/DecisionChampionChallenger.v1.schema.json +100 -0
  236. package/schemas/decision/DecisionCompileCacheEntry.v1.schema.json +35 -0
  237. package/schemas/decision/DecisionCompileCacheIdentity.v1.schema.json +26 -0
  238. package/schemas/decision/DecisionCompileCacheTombstone.v1.schema.json +31 -0
  239. package/schemas/decision/DecisionContextEvidence.v1.schema.json +136 -0
  240. package/schemas/decision/DecisionDefinition.schema.json +159 -0
  241. package/schemas/decision/DecisionDefinition.v1alpha2.schema.json +203 -0
  242. package/schemas/decision/DecisionDriftResponse.v1.schema.json +55 -0
  243. package/schemas/decision/DecisionEnsembleAggregate.v1.schema.json +86 -0
  244. package/schemas/decision/DecisionEnsembleIntegrityReport.v1.schema.json +64 -0
  245. package/schemas/decision/DecisionEnsemblePolicy.v1.schema.json +130 -0
  246. package/schemas/decision/DecisionGraph.v1.schema.json +77 -0
  247. package/schemas/decision/DecisionJob.v1.schema.json +71 -0
  248. package/schemas/decision/DecisionLifecyclePolicy.v1.schema.json +52 -0
  249. package/schemas/decision/DecisionProjectionPolicy.v1.schema.json +65 -0
  250. package/schemas/decision/DecisionProviderPrefixCompatibility.v1.schema.json +45 -0
  251. package/schemas/decision/DecisionProviderPrefixEvidence.v1.schema.json +17 -0
  252. package/schemas/decision/DecisionProviderPrefixIdentity.v1.schema.json +36 -0
  253. package/schemas/decision/DecisionResult.schema.json +524 -0
  254. package/schemas/decision/DecisionResult.v1alpha2.schema.json +619 -0
  255. package/schemas/decision/DecisionResultCacheEntry.v1.schema.json +53 -0
  256. package/schemas/decision/DecisionReview.schema.json +29 -0
  257. package/schemas/decision/DecisionRuleset.schema.json +313 -0
  258. package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +313 -0
  259. package/schemas/decision/DecisionTelemetryTrace.v1.schema.json +95 -0
  260. package/schemas/decision/RulesetResult.schema.json +721 -0
  261. package/schemas/decision/RulesetResult.v1alpha2.schema.json +891 -0
  262. package/schemas/security/pr-evidence-receipt.v1.schema.json +77 -0
  263. package/schemas/security/threat-assessment-report.v1.schema.json +71 -1
  264. package/tools/agents/deploy-agents.mjs +21 -6
  265. package/tools/agents/providers/base.mjs +7 -4
  266. package/tools/agents/providers/codex.mjs +1 -1
  267. package/tools/agents/providers/grok-build.d.mts +7 -0
  268. package/tools/agents/providers/grok-build.mjs +249 -9
  269. package/tools/agents/providers/model-role.mjs +6 -3
  270. package/tools/agents/providers/muse-hooks.mjs +785 -0
  271. package/tools/agents/providers/muse-paths.mjs +112 -0
  272. package/tools/agents/providers/muse.mjs +336 -0
@@ -0,0 +1,785 @@
1
+ /**
2
+ * Muse Code project hooks (.muse/hooks.json) + optional MCP settings profile
3
+ * (#228).
4
+ *
5
+ * Hooks
6
+ * -----
7
+ * Target: `<repo>/.muse/hooks.json` for AIWG-managed lifecycle bindings.
8
+ * Muse validates the file at session startup: a malformed project hook file
9
+ * contributes no handlers and warns, so this module fails closed — an
10
+ * unparseable hooks.json aborts the merge with zero writes, never a rewrite.
11
+ *
12
+ * Managed-entry tracking uses a sidecar (`.muse/.aiwg-hooks.json`) rather
13
+ * than in-band markers: Muse skips matcher groups that carry unexpected
14
+ * keys, and drops unsupported handler fields, so tagging entries inside
15
+ * hooks.json would either break the group or be silently discarded. Only
16
+ * groups recorded in the sidecar (or byte-identical to the current
17
+ * canonical set) are ever replaced or pruned; operator groups are preserved
18
+ * logically across merges. The merged file is rewritten with canonical
19
+ * 2-space formatting (the peer Factory convention); operator entries keep
20
+ * their content, never their original whitespace.
21
+ *
22
+ * **Hooks run outside the agent sandbox.** Muse executes hook commands as
23
+ * plain shell processes on the operator's machine. Project hooks additionally
24
+ * run only after the operator trusts the project folder. Every managed hook
25
+ * below ships with a stated reason and a removal path.
26
+ *
27
+ * MCP
28
+ * ---
29
+ * Optional profile behind an explicit flag only: `deployMuseMcp` is a no-op
30
+ * unless `opts.mcp === true`. It enriches the *user* settings file
31
+ * (`$XDG_CONFIG_HOME/muse/settings.json`, default `~/.config/muse/settings.json`)
32
+ * `mcp_servers` block additively: unknown keys are preserved, an existing
33
+ * `schema_version` is never downgraded (bootstrapped to `1` only when AIWG
34
+ * creates the file or repairs a key-less one — a missing schema_version makes
35
+ * every `muse` command fail with "malformed settings file"), and a timestamped
36
+ * backup is written before any mutation. Only the `aiwg` server key is ever
37
+ * touched; an operator-owned `aiwg` key that AIWG did not record is left
38
+ * alone with a warning, never clobbered.
39
+ *
40
+ * The profiled server is AIWG's own MCP surface (`aiwg mcp serve`) over
41
+ * stdio, registered with `"mode": "optional"` — a failing *required* server
42
+ * aborts the whole Muse run, and optional enrichment must never do that.
43
+ * Servers inherit the operator's process environment; pass `MUSE_SESSION_ID`
44
+ * through at runtime instead of hardcoding a session id into `env`.
45
+ *
46
+ * This module is intentionally dependency-free (node builtins +
47
+ * muse-paths.mjs only) so provider-policy-style import graphs never pull it
48
+ * in transitively.
49
+ *
50
+ * @issue #228
51
+ */
52
+
53
+ import fs from 'node:fs';
54
+ import os from 'node:os';
55
+ import path from 'node:path';
56
+ import { resolveMuseXdgSkillsDir, museXdgSkillsDirRemediation } from './muse-paths.mjs';
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Shared JSON helpers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Strip JSON comments (JSONC) before parsing. Comments are *read* tolerance
64
+ * only: merged files are written back as canonical JSON. The scanner tracks
65
+ * string literals so `//` in a URL or `/*` in a glob matcher is preserved.
66
+ */
67
+ function stripJsonComments(jsonc) {
68
+ const text = String(jsonc);
69
+ let out = '';
70
+ let i = 0;
71
+ while (i < text.length) {
72
+ const ch = text[i];
73
+ if (ch === '"') {
74
+ let j = i + 1;
75
+ while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
76
+ out += text.slice(i, j + 1);
77
+ i = j + 1;
78
+ } else if (ch === '/' && text[i + 1] === '/') {
79
+ while (i < text.length && text[i] !== '\n') i++;
80
+ } else if (ch === '/' && text[i + 1] === '*') {
81
+ const end = text.indexOf('*/', i + 2);
82
+ i = end === -1 ? text.length : end + 2;
83
+ } else {
84
+ out += ch;
85
+ i++;
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Parse a JSON/JSONC document, failing closed with a coded error.
93
+ */
94
+ function parseJsonc(raw, filePath, code) {
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(stripJsonComments(raw));
98
+ } catch (err) {
99
+ const error = new Error(
100
+ `Refusing to merge ${filePath}: not valid JSON (${err.message}). ` +
101
+ 'Fix or remove the file, then re-run. Nothing was written.',
102
+ );
103
+ error.code = code;
104
+ throw error;
105
+ }
106
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
107
+ const error = new Error(
108
+ `Refusing to merge ${filePath}: top-level JSON must be an object. Nothing was written.`,
109
+ );
110
+ error.code = code;
111
+ throw error;
112
+ }
113
+ return parsed;
114
+ }
115
+
116
+ /**
117
+ * Atomic write via tmpfile+rename so partial state never persists
118
+ * (mirrors src/extensions/claude-hooks-installer.ts).
119
+ */
120
+ function atomicWrite(filePath, content) {
121
+ const dir = path.dirname(filePath);
122
+ const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}`);
123
+ fs.writeFileSync(tmp, content, 'utf8');
124
+ try {
125
+ fs.renameSync(tmp, filePath);
126
+ } catch (err) {
127
+ try {
128
+ fs.unlinkSync(tmp);
129
+ } catch {
130
+ // best effort
131
+ }
132
+ throw err;
133
+ }
134
+ }
135
+
136
+ /** Canonical serialization: 2-space JSON with trailing newline. */
137
+ function canonicalJson(value) {
138
+ return JSON.stringify(value, null, 2) + '\n';
139
+ }
140
+
141
+ function deepEqual(a, b) {
142
+ return JSON.stringify(a) === JSON.stringify(b);
143
+ }
144
+
145
+ /**
146
+ * Minimal unified diff (LCS on lines) for dry-run previews.
147
+ */
148
+ export function unifiedDiff(beforeText, afterText, label) {
149
+ const a = String(beforeText).split('\n');
150
+ const b = String(afterText).split('\n');
151
+ const n = a.length;
152
+ const m = b.length;
153
+ // LCS table (files here are small; clarity over Hirschberg).
154
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
155
+ for (let i = n - 1; i >= 0; i--) {
156
+ for (let j = m - 1; j >= 0; j--) {
157
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
158
+ }
159
+ }
160
+ const out = [`--- ${label} (current)`, `+++ ${label} (planned)`];
161
+ let i = 0;
162
+ let j = 0;
163
+ while (i < n && j < m) {
164
+ if (a[i] === b[j]) {
165
+ out.push(` ${a[i]}`);
166
+ i++;
167
+ j++;
168
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
169
+ out.push(`-${a[i]}`);
170
+ i++;
171
+ } else {
172
+ out.push(`+${b[j]}`);
173
+ j++;
174
+ }
175
+ }
176
+ while (i < n) out.push(`-${a[i++]}`);
177
+ while (j < m) out.push(`+${b[j++]}`);
178
+ return out.join('\n');
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Hooks: managed set definition
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /** Project hooks file and its AIWG sidecar, relative to the target repo. */
186
+ export const MUSE_HOOKS_REL = path.join('.muse', 'hooks.json');
187
+ export const MUSE_HOOKS_SIDECAR_REL = path.join('.muse', '.aiwg-hooks.json');
188
+
189
+ /** Muse lifecycle events known at the time of writing (docs: dev.meta.ai muse-code/extending). */
190
+ export const MUSE_HOOK_EVENTS = [
191
+ 'SessionStart',
192
+ 'UserPromptSubmit',
193
+ 'PreToolUse',
194
+ 'PermissionRequest',
195
+ 'PostToolUse',
196
+ 'PostToolUseFailure',
197
+ 'PreLLMCall',
198
+ 'PostLLMCall',
199
+ 'PreCompact',
200
+ 'PostCompact',
201
+ 'SubagentStart',
202
+ 'SubagentStop',
203
+ 'Notification',
204
+ 'Stop',
205
+ 'SessionEnd',
206
+ ];
207
+
208
+ /**
209
+ * The AIWG-managed hook set. Each entry carries a stated reason (why this
210
+ * hook runs on the operator's machine, outside the agent sandbox) and a
211
+ * removal path (how the operator takes it back out).
212
+ *
213
+ * SessionEnd is intentionally *not* installed yet: Muse documents SessionEnd
214
+ * as observational-only (its output cannot block termination or inject
215
+ * context), and AIWG has no session-catalog note command to invoke — wiring
216
+ * a hook to a command that does not exist would fail every session end.
217
+ * When a real catalog-note command lands, it slots in here with its own
218
+ * reason + removal path.
219
+ */
220
+ export const AIWG_MANAGED_HOOKS = [
221
+ {
222
+ id: 'aiwg-session-start',
223
+ event: 'SessionStart',
224
+ // Muse matcher groups take ONLY `hooks` + optional `matcher`; no
225
+ // in-band AIWG tags (they would break the group).
226
+ //
227
+ // Muse Code 1.4.0 parses hook stdout as hook output: empty output, plain
228
+ // text, and `{"hookSpecificOutput": ...}` are accepted, but any other
229
+ // JSON key fails the hook ("unsupported `deploymentFailures` in output of
230
+ // SessionStart hook output"). `aiwg refresh --quiet` prints its JSON
231
+ // report, so stdout is discarded; the exit status still records a failed
232
+ // drift check in Muse's hook log. `--provider muse` is required because
233
+ // provider detection is fail-closed for muse and would resolve `claude`.
234
+ // Hook commands run through a shell, so the redirect is honored.
235
+ group: {
236
+ matcher: '*',
237
+ hooks: [{ type: 'command', command: 'aiwg refresh --dry-run --quiet --provider muse > /dev/null', timeout: 60 }],
238
+ },
239
+ reason:
240
+ 'Session-start drift check. Runs a read-only `aiwg refresh --dry-run --provider muse` ' +
241
+ 'and records its exit status in the Muse hook log. Dry-run by construction: it ' +
242
+ 'never mutates the repo, and its report is not injected into the session.',
243
+ removal:
244
+ 'Delete this matcher group from `.muse/hooks.json` (the one whose command is ' +
245
+ '`aiwg refresh --dry-run --quiet --provider muse > /dev/null`), or run `aiwg use --provider muse --no-hooks` ' +
246
+ 'to stop AIWG managing hooks for this project.',
247
+ },
248
+ ];
249
+
250
+ /** Canonical group payloads keyed by hook id, for merge/prune identity. */
251
+ function managedGroupsById() {
252
+ const out = new Map();
253
+ for (const hook of AIWG_MANAGED_HOOKS) out.set(hook.id, { event: hook.event, group: hook.group });
254
+ return out;
255
+ }
256
+
257
+ /**
258
+ * Stable identity for a matcher group: the event, the matcher, and the full
259
+ * (whitespace-normalized) first hook command.
260
+ *
261
+ * Full-payload equality cannot survive an operator hand-edit, and Muse
262
+ * forbids in-band AIWG tags on matcher groups (unexpected keys break the
263
+ * group). The identity key survives benign drift (timeouts, hook type) so a
264
+ * drifted managed group is repaired rather than duplicated. The whole command
265
+ * is part of the key so an operator's own `aiwg ...` hook with a different
266
+ * command is never claimed as managed: it stays operator-owned, and AIWG
267
+ * installs its canonical group alongside it.
268
+ */
269
+ function groupIdentity(event, group) {
270
+ const matcher = (group && group.matcher) || '';
271
+ const first = group && Array.isArray(group.hooks) && group.hooks[0];
272
+ const command = String((first && first.command) || '').trim().split(/\s+/).join(' ');
273
+ return `${event}\n${matcher}\n${command}`;
274
+ }
275
+
276
+ /**
277
+ * Identity keys AIWG manages: the current set plus anything recorded in the
278
+ * sidecar (covers retired hooks whose payload AIWG wrote on an older deploy).
279
+ */
280
+ function managedIdentities(byId, recorded) {
281
+ const ids = new Map(); // identity key -> hook id
282
+ for (const [id, { event, group }] of byId) ids.set(groupIdentity(event, group), id);
283
+ for (const entry of recorded.values()) {
284
+ if (entry && entry.event && entry.group) {
285
+ const key = groupIdentity(entry.event, entry.group);
286
+ if (!ids.has(key)) ids.set(key, entry.id);
287
+ }
288
+ }
289
+ return ids;
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Hooks: merge + deploy + prune
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /**
297
+ * Refuse to write through a symlinked `.muse` directory: a cloned repo could
298
+ * point it anywhere on disk.
299
+ */
300
+ function assertMuseDirNotSymlink(museDir) {
301
+ let stat;
302
+ try {
303
+ stat = fs.lstatSync(museDir);
304
+ } catch {
305
+ return;
306
+ }
307
+ if (stat.isSymbolicLink()) {
308
+ const error = new Error(
309
+ `Refusing to write Muse hooks: ${museDir} is a symlink. Replace it with a real directory, then re-run. Nothing was written.`,
310
+ );
311
+ error.code = 'MUSE_HOOKS_SYMLINK';
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * The merged document is written as canonical JSON, which drops comments and
318
+ * operator formatting. Keep a timestamped copy whenever that would lose bytes.
319
+ */
320
+ function backupIfHandEdited(filePath, doc, raw, existed) {
321
+ if (!existed || raw === canonicalJson(doc)) return null;
322
+ const backupPath = `${filePath}.aiwg-backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
323
+ fs.copyFileSync(filePath, backupPath);
324
+ return backupPath;
325
+ }
326
+
327
+ function readHooksDoc(hooksPath) {
328
+ if (!fs.existsSync(hooksPath)) return { doc: { hooks: {} }, raw: null, existed: false };
329
+ const raw = fs.readFileSync(hooksPath, 'utf8');
330
+ const doc = parseJsonc(raw, hooksPath, 'MUSE_HOOKS_UNPARSEABLE');
331
+ if (doc.hooks !== undefined && (typeof doc.hooks !== 'object' || doc.hooks === null || Array.isArray(doc.hooks))) {
332
+ const error = new Error(
333
+ `Refusing to merge ${hooksPath}: "hooks" must be an object keyed by event name. Nothing was written.`,
334
+ );
335
+ error.code = 'MUSE_HOOKS_BAD_SHAPE';
336
+ throw error;
337
+ }
338
+ for (const [event, groups] of Object.entries(doc.hooks || {})) {
339
+ if (!Array.isArray(groups)) {
340
+ const error = new Error(
341
+ `Refusing to merge ${hooksPath}: hooks["${event}"] must be an array of matcher groups. Nothing was written.`,
342
+ );
343
+ error.code = 'MUSE_HOOKS_BAD_SHAPE';
344
+ throw error;
345
+ }
346
+ }
347
+ return { doc, raw, existed: true };
348
+ }
349
+
350
+ function readHooksSidecar(sidecarPath) {
351
+ if (!fs.existsSync(sidecarPath)) return { version: 1, hooks: [] };
352
+ const raw = fs.readFileSync(sidecarPath, 'utf8');
353
+ const parsed = parseJsonc(raw, sidecarPath, 'MUSE_HOOKS_SIDECAR_UNPARSEABLE');
354
+ if (!Array.isArray(parsed.hooks)) {
355
+ const error = new Error(
356
+ `Refusing to merge hooks: sidecar ${sidecarPath} is corrupt (hooks is not an array). Nothing was written.`,
357
+ );
358
+ error.code = 'MUSE_HOOKS_SIDECAR_UNPARSEABLE';
359
+ throw error;
360
+ }
361
+ return parsed;
362
+ }
363
+
364
+ /**
365
+ * Compute the merged hooks document. Returns `{ doc, changed, plan }`
366
+ * where `plan` lists human-readable merge steps for dry-run output.
367
+ *
368
+ * Operator groups (anything not recorded as AIWG-managed and not
369
+ * byte-identical to the canonical set) are preserved untouched. Managed
370
+ * groups are replaced with the canonical payload, repairing drift.
371
+ */
372
+ export function mergeManagedHooks(existingDoc, sidecar) {
373
+ const byId = managedGroupsById();
374
+ const recorded = new Map();
375
+ for (const entry of sidecar.hooks || []) {
376
+ if (entry && typeof entry.id === 'string') recorded.set(entry.id, entry);
377
+ }
378
+ const identities = managedIdentities(byId, recorded);
379
+
380
+ // A group is AIWG-managed when its identity matches the current set or a
381
+ // sidecar-recorded payload. Returns the hook id or null.
382
+ const isManagedGroup = (event, group) => identities.get(groupIdentity(event, group)) || null;
383
+
384
+ const plan = [];
385
+ const merged = { hooks: {} };
386
+ const existingHooks = (existingDoc && existingDoc.hooks) || {};
387
+
388
+ // Preserve original event order; managed events are refreshed in place.
389
+ const desiredEvents = new Set([...AIWG_MANAGED_HOOKS.map((h) => h.event)]);
390
+ const eventOrder = [...Object.keys(existingHooks)];
391
+ for (const event of desiredEvents) {
392
+ if (!eventOrder.includes(event)) eventOrder.push(event);
393
+ }
394
+
395
+ for (const event of eventOrder) {
396
+ const groups = Array.isArray(existingHooks[event]) ? existingHooks[event] : [];
397
+ const operatorGroups = [];
398
+ const seenManagedIds = new Set();
399
+ for (const group of groups) {
400
+ const managedId = isManagedGroup(event, group);
401
+ if (managedId) {
402
+ seenManagedIds.add(managedId);
403
+ } else {
404
+ operatorGroups.push(group);
405
+ }
406
+ }
407
+ const desiredHere = AIWG_MANAGED_HOOKS.filter((h) => h.event === event);
408
+ const nextGroups = [...operatorGroups];
409
+ for (const hook of desiredHere) {
410
+ nextGroups.push(hook.group);
411
+ if (!seenManagedIds.has(hook.id)) {
412
+ plan.push(`add ${hook.event} hook "${hook.id}": ${hook.group.hooks[0].command}`);
413
+ } else if (!groups.some((g) => deepEqual(g, hook.group))) {
414
+ plan.push(`repair ${hook.event} hook "${hook.id}" (drifted from canonical)`);
415
+ }
416
+ }
417
+ // Drop previously-managed groups for hooks no longer in the set.
418
+ for (const group of groups) {
419
+ const managedId = isManagedGroup(event, group);
420
+ if (managedId && !byId.has(managedId)) {
421
+ plan.push(`prune retired managed hook "${managedId}"`);
422
+ }
423
+ }
424
+ if (nextGroups.length > 0 || event in existingHooks) {
425
+ merged.hooks[event] = nextGroups;
426
+ }
427
+ }
428
+
429
+ const changed = !deepEqual({ hooks: existingHooks }, merged);
430
+ if (plan.length === 0 && changed) plan.push('normalize hooks document');
431
+ return { doc: merged, changed, plan };
432
+ }
433
+
434
+ function buildHooksSidecar() {
435
+ return {
436
+ version: 1,
437
+ hooks: AIWG_MANAGED_HOOKS.map((h) => ({ id: h.id, event: h.event, group: h.group })),
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Deploy the AIWG-managed hook set into `<target>/.muse/hooks.json`.
443
+ *
444
+ * Additive merge: operator entries are preserved; only recorded AIWG entries
445
+ * are replaced/pruned. Fail closed on unparseable input (zero writes).
446
+ * Dry-run prints the planned diff and performs zero writes.
447
+ */
448
+ export function deployMuseHooks(targetDir, opts = {}) {
449
+ const { dryRun = false, quiet = false } = opts;
450
+ const hooksPath = path.join(targetDir, MUSE_HOOKS_REL);
451
+ const sidecarPath = path.join(targetDir, MUSE_HOOKS_SIDECAR_REL);
452
+
453
+ const { doc: existingDoc, raw: existingRaw, existed } = readHooksDoc(hooksPath);
454
+ const sidecar = readHooksSidecar(sidecarPath);
455
+ const { doc: merged, changed, plan } = mergeManagedHooks(existingDoc, sidecar);
456
+
457
+ const beforeText = existed ? existingRaw : '(no .muse/hooks.json — will be created)';
458
+ const afterText = canonicalJson(merged);
459
+ const log = quiet ? () => {} : (msg) => console.log(msg);
460
+
461
+ if (dryRun) {
462
+ log(`[dry-run] Muse hooks: ${hooksPath}`);
463
+ if (!changed) {
464
+ log('[dry-run] hooks.json already carries the canonical AIWG-managed set; no changes.');
465
+ } else {
466
+ for (const step of plan) log(`[dry-run] ${step}`);
467
+ log(unifiedDiff(beforeText, afterText, hooksPath));
468
+ }
469
+ log('[dry-run] zero writes performed.');
470
+ return { hooksPath, changed, plan, wrote: false };
471
+ }
472
+
473
+ if (!changed) {
474
+ log(`Muse hooks: ${hooksPath} already current; nothing to do.`);
475
+ return { hooksPath, changed: false, plan: [], wrote: false };
476
+ }
477
+
478
+ assertMuseDirNotSymlink(path.dirname(hooksPath));
479
+ fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
480
+ const backupPath = backupIfHandEdited(hooksPath, existingDoc, existingRaw, existed);
481
+ atomicWrite(hooksPath, afterText);
482
+ atomicWrite(sidecarPath, canonicalJson(buildHooksSidecar()));
483
+ log(`Muse hooks: merged ${plan.length} managed change(s) into ${hooksPath}`);
484
+ if (backupPath) log(` backup (comments/formatting not preserved): ${backupPath}`);
485
+ for (const step of plan) log(` ${step}`);
486
+ log(' Operator hook entries preserved; managed entries tracked in .muse/.aiwg-hooks.json.');
487
+ return { hooksPath, changed: true, plan, wrote: true };
488
+ }
489
+
490
+ /**
491
+ * Removal path: drop every AIWG-managed group recorded in the sidecar and
492
+ * delete the sidecar. Operator entries are never touched.
493
+ */
494
+ export function pruneMuseHooks(targetDir, opts = {}) {
495
+ const { dryRun = false, quiet = false } = opts;
496
+ const hooksPath = path.join(targetDir, MUSE_HOOKS_REL);
497
+ const sidecarPath = path.join(targetDir, MUSE_HOOKS_SIDECAR_REL);
498
+ const log = quiet ? () => {} : (msg) => console.log(msg);
499
+
500
+ if (!fs.existsSync(hooksPath)) {
501
+ log(`Muse hooks: no ${hooksPath}; nothing to prune.`);
502
+ return { hooksPath, pruned: false, wrote: false };
503
+ }
504
+ const { doc: existingDoc, raw: existingRaw } = readHooksDoc(hooksPath);
505
+ const sidecar = readHooksSidecar(sidecarPath);
506
+ const recorded = new Map();
507
+ for (const entry of sidecar.hooks || []) {
508
+ if (entry && typeof entry.id === 'string') recorded.set(entry.id, entry);
509
+ }
510
+ const identities = managedIdentities(managedGroupsById(), recorded);
511
+
512
+ const merged = { hooks: {} };
513
+ let pruned = 0;
514
+ for (const [event, groups] of Object.entries(existingDoc.hooks || {})) {
515
+ const kept = (Array.isArray(groups) ? groups : []).filter((group) => {
516
+ const managed = identities.has(groupIdentity(event, group));
517
+ if (managed) pruned++;
518
+ return !managed;
519
+ });
520
+ if (kept.length > 0 || event in (existingDoc.hooks || {})) merged.hooks[event] = kept;
521
+ }
522
+
523
+ if (dryRun) {
524
+ log(`[dry-run] Muse hooks prune: ${hooksPath}`);
525
+ log(`[dry-run] would remove ${pruned} managed group(s); operator entries untouched.`);
526
+ if (pruned > 0) log(unifiedDiff(existingRaw, canonicalJson(merged), hooksPath));
527
+ log('[dry-run] zero writes performed.');
528
+ return { hooksPath, pruned, wrote: false };
529
+ }
530
+
531
+ if (pruned === 0) {
532
+ log(`Muse hooks: no AIWG-managed groups found in ${hooksPath}; nothing to prune.`);
533
+ return { hooksPath, pruned: 0, wrote: false };
534
+ }
535
+ assertMuseDirNotSymlink(path.dirname(hooksPath));
536
+ const backupPath = backupIfHandEdited(hooksPath, existingDoc, existingRaw, true);
537
+ atomicWrite(hooksPath, canonicalJson(merged));
538
+ if (fs.existsSync(sidecarPath)) fs.unlinkSync(sidecarPath);
539
+ if (backupPath) log(` backup (comments/formatting not preserved): ${backupPath}`);
540
+ log(`Muse hooks: pruned ${pruned} AIWG-managed group(s) from ${hooksPath}; sidecar removed.`);
541
+ return { hooksPath, pruned, wrote: true };
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // MCP: optional settings profile
546
+ // ---------------------------------------------------------------------------
547
+
548
+ /** User settings path, resolved fail-closed from XDG metadata. */
549
+ export function assertMuseUserSettingsPath(env = process.env, userHome = os.homedir()) {
550
+ const skillsDir = resolveMuseXdgSkillsDir(env, userHome);
551
+ if (!skillsDir) {
552
+ const err = new Error(museXdgSkillsDirRemediation(env, userHome));
553
+ err.code = 'MUSE_XDG_UNSET';
554
+ throw err;
555
+ }
556
+ // <xdg>/muse/skills -> <xdg>/muse/settings.json (never ~/.muse).
557
+ return path.join(path.dirname(skillsDir), 'settings.json');
558
+ }
559
+
560
+ export const MUSE_MCP_SIDECAR_NAME = '.aiwg-mcp.json';
561
+
562
+ /**
563
+ * The optional MCP profile: AIWG's own MCP surface over stdio.
564
+ * `"mode": "optional"` is load-bearing — a failing *required* server aborts
565
+ * the whole Muse run, and optional enrichment must never do that.
566
+ */
567
+ export function aiwgMcpServerProfile() {
568
+ return {
569
+ transport: 'stdio',
570
+ command: 'aiwg',
571
+ args: ['mcp', 'serve'],
572
+ env: {},
573
+ enabled: true,
574
+ mode: 'optional',
575
+ };
576
+ }
577
+
578
+ function readSettingsDoc(settingsPath) {
579
+ if (!fs.existsSync(settingsPath)) return { doc: {}, raw: null, existed: false };
580
+ const raw = fs.readFileSync(settingsPath, 'utf8');
581
+ const doc = parseJsonc(raw, settingsPath, 'MUSE_SETTINGS_UNPARSEABLE');
582
+ return { doc, raw, existed: true };
583
+ }
584
+
585
+ function readMcpSidecar(configDir) {
586
+ const sidecarPath = path.join(configDir, MUSE_MCP_SIDECAR_NAME);
587
+ if (!fs.existsSync(sidecarPath)) return { servers: [], path: sidecarPath };
588
+ const parsed = parseJsonc(
589
+ fs.readFileSync(sidecarPath, 'utf8'),
590
+ sidecarPath,
591
+ 'MUSE_MCP_SIDECAR_UNPARSEABLE',
592
+ );
593
+ return { servers: Array.isArray(parsed.servers) ? parsed.servers : [], path: sidecarPath };
594
+ }
595
+
596
+ /**
597
+ * Compute the merged settings document for the optional MCP profile.
598
+ * Additive: unknown keys preserved; only `mcp_servers.aiwg` (when AIWG owns
599
+ * it) and `schema_version` (bootstrap/repair only) are touched.
600
+ *
601
+ * @param {object} existingDoc parsed settings document
602
+ * @param {object} opts `{ managed }` — true when the caller has established
603
+ * the `aiwg` key is AIWG-recorded (sidecar), absent, or already canonical.
604
+ * Fail closed otherwise: an operator-owned key is never overwritten here.
605
+ */
606
+ export function mergeMcpProfile(existingDoc, opts = {}) {
607
+ const { managed = false } = opts;
608
+ const plan = [];
609
+ const warnings = [];
610
+ const merged = { ...existingDoc };
611
+ const desired = aiwgMcpServerProfile();
612
+
613
+ if (merged.schema_version === undefined) {
614
+ merged.schema_version = 1;
615
+ plan.push('set schema_version: 1 (required by Muse; file had none)');
616
+ } else if (merged.schema_version !== 1) {
617
+ warnings.push(
618
+ `existing schema_version is ${JSON.stringify(merged.schema_version)} (not 1); ` +
619
+ 'leaving it untouched and merging additively.',
620
+ );
621
+ }
622
+
623
+ const rawServers = merged.mcp_servers;
624
+ if (rawServers !== undefined && (typeof rawServers !== 'object' || rawServers === null || Array.isArray(rawServers))) {
625
+ const error = new Error(
626
+ 'Refusing to merge MCP profile: "mcp_servers" must be an object keyed by server name. Nothing was written.',
627
+ );
628
+ error.code = 'MUSE_MCP_BAD_SHAPE';
629
+ throw error;
630
+ }
631
+ const servers = { ...(rawServers || {}) };
632
+
633
+ const existing = servers.aiwg;
634
+ if (existing === undefined) {
635
+ servers.aiwg = desired;
636
+ plan.push('add mcp_servers.aiwg: stdio `aiwg mcp serve` (mode: optional)');
637
+ } else if (deepEqual(existing, desired)) {
638
+ plan.push('mcp_servers.aiwg already carries the canonical AIWG profile');
639
+ } else if (managed) {
640
+ servers.aiwg = desired;
641
+ plan.push('refresh mcp_servers.aiwg to the canonical AIWG profile (drifted since last deploy)');
642
+ } else {
643
+ const error = new Error(
644
+ 'Refusing to merge MCP profile: mcp_servers.aiwg is operator-owned (not recorded as AIWG-managed). Nothing was written.',
645
+ );
646
+ error.code = 'MUSE_MCP_NOT_MANAGED';
647
+ throw error;
648
+ }
649
+
650
+ merged.mcp_servers = servers;
651
+ const changed = !deepEqual(existingDoc, merged);
652
+ return { doc: merged, changed, plan, warnings };
653
+ }
654
+
655
+ /**
656
+ * Enrich user settings with the optional AIWG MCP profile.
657
+ *
658
+ * Explicit opt-in only: no-op unless `opts.mcp === true`. Additive merge
659
+ * preserving unknown keys, timestamped backup before writing, atomic write,
660
+ * dry-run diff with zero writes, fail closed on unparseable settings.
661
+ */
662
+ export function deployMuseMcp(opts = {}) {
663
+ const { dryRun = false, quiet = false, mcp = false, env = process.env, userHome = os.homedir() } = opts;
664
+ const log = quiet ? () => {} : (msg) => console.log(msg);
665
+
666
+ if (mcp !== true) {
667
+ log('Muse MCP: profile not requested (pass --mcp to opt in); settings untouched.');
668
+ return { settingsPath: null, changed: false, wrote: false, skipped: true };
669
+ }
670
+
671
+ const settingsPath = assertMuseUserSettingsPath(env, userHome);
672
+ const configDir = path.dirname(settingsPath);
673
+ const { doc: existingDoc, raw: existingRaw, existed } = readSettingsDoc(settingsPath);
674
+ const sidecar = readMcpSidecar(configDir);
675
+ const profile = aiwgMcpServerProfile();
676
+
677
+ const existingServer = existingDoc.mcp_servers && existingDoc.mcp_servers.aiwg;
678
+ const managedByAiwg = sidecar.servers.includes('aiwg');
679
+
680
+ // Operator-owned `aiwg` server key that AIWG did not record: never clobber.
681
+ if (existingServer !== undefined && !managedByAiwg && !deepEqual(existingServer, profile)) {
682
+ const warning =
683
+ `Muse MCP: settings already define an operator-owned mcp_servers.aiwg entry; ` +
684
+ `leaving it untouched (not recorded as AIWG-managed). Rename or remove it to let AIWG manage the key.`;
685
+ log(`Warning: ${warning}`);
686
+ return { settingsPath, changed: false, wrote: false, skipped: true, warnings: [warning] };
687
+ }
688
+
689
+ const { doc: merged, changed, plan, warnings } = mergeMcpProfile(existingDoc, {
690
+ managed: managedByAiwg || existingServer === undefined || deepEqual(existingServer, profile),
691
+ });
692
+ for (const w of warnings) log(`Warning: Muse MCP: ${w}`);
693
+
694
+ const beforeText = existed ? existingRaw : '(no settings.json — will be created)';
695
+ const afterText = canonicalJson(merged);
696
+
697
+ if (!changed) {
698
+ log(`Muse MCP: ${settingsPath} already carries the AIWG profile; nothing to do.`);
699
+ return { settingsPath, changed: false, wrote: false, warnings };
700
+ }
701
+
702
+ if (dryRun) {
703
+ log(`[dry-run] Muse MCP profile: ${settingsPath}`);
704
+ for (const step of plan) log(`[dry-run] ${step}`);
705
+ if (existed) log(`[dry-run] would back up to ${settingsPath}.aiwg-backup-<timestamp>`);
706
+ log(unifiedDiff(beforeText, afterText, settingsPath));
707
+ log('[dry-run] zero writes performed.');
708
+ return { settingsPath, changed: true, plan, wrote: false, warnings };
709
+ }
710
+
711
+ fs.mkdirSync(configDir, { recursive: true });
712
+ let backupPath = null;
713
+ if (existed) {
714
+ backupPath = `${settingsPath}.aiwg-backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
715
+ fs.copyFileSync(settingsPath, backupPath);
716
+ }
717
+ atomicWrite(settingsPath, afterText);
718
+ atomicWrite(sidecar.path, canonicalJson({ version: 1, servers: ['aiwg'] }));
719
+
720
+ log(`Muse MCP: merged AIWG stdio profile into ${settingsPath}`);
721
+ for (const step of plan) log(` ${step}`);
722
+ if (backupPath) log(` backup: ${backupPath}`);
723
+ log(' Unknown settings keys preserved; only mcp_servers.aiwg (+ schema_version bootstrap) touched.');
724
+ return { settingsPath, changed: true, plan, wrote: true, backupPath, warnings };
725
+ }
726
+
727
+ /**
728
+ * Removal path: drop the AIWG-managed `aiwg` server key (only when recorded
729
+ * in the sidecar) and delete the sidecar. Operator servers are never touched.
730
+ */
731
+ export function pruneMuseMcp(opts = {}) {
732
+ const { dryRun = false, quiet = false, env = process.env, userHome = os.homedir() } = opts;
733
+ const log = quiet ? () => {} : (msg) => console.log(msg);
734
+ const settingsPath = assertMuseUserSettingsPath(env, userHome);
735
+ const configDir = path.dirname(settingsPath);
736
+
737
+ if (!fs.existsSync(settingsPath)) {
738
+ log(`Muse MCP: no ${settingsPath}; nothing to prune.`);
739
+ return { settingsPath, pruned: false, wrote: false };
740
+ }
741
+ const { doc: existingDoc, raw: existingRaw } = readSettingsDoc(settingsPath);
742
+ const sidecar = readMcpSidecar(configDir);
743
+ const servers = existingDoc.mcp_servers;
744
+ const prunable = servers && typeof servers === 'object' && sidecar.servers.includes('aiwg') && servers.aiwg !== undefined;
745
+
746
+ if (!prunable) {
747
+ log(`Muse MCP: no AIWG-managed server key in ${settingsPath}; nothing to prune.`);
748
+ return { settingsPath, pruned: false, wrote: false };
749
+ }
750
+
751
+ const merged = { ...existingDoc, mcp_servers: { ...servers } };
752
+ delete merged.mcp_servers.aiwg;
753
+
754
+ if (dryRun) {
755
+ log(`[dry-run] Muse MCP prune: ${settingsPath}`);
756
+ log('[dry-run] would remove mcp_servers.aiwg; other servers untouched.');
757
+ log(unifiedDiff(existingRaw, canonicalJson(merged), settingsPath));
758
+ log('[dry-run] zero writes performed.');
759
+ return { settingsPath, pruned: true, wrote: false };
760
+ }
761
+
762
+ const backupPath = `${settingsPath}.aiwg-backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
763
+ fs.copyFileSync(settingsPath, backupPath);
764
+ atomicWrite(settingsPath, canonicalJson(merged));
765
+ if (fs.existsSync(sidecar.path)) fs.unlinkSync(sidecar.path);
766
+ log(`Muse MCP: removed mcp_servers.aiwg from ${settingsPath} (backup: ${backupPath}).`);
767
+ return { settingsPath, pruned: true, wrote: true, backupPath };
768
+ }
769
+
770
+ export default {
771
+ MUSE_HOOKS_REL,
772
+ MUSE_HOOKS_SIDECAR_REL,
773
+ MUSE_HOOK_EVENTS,
774
+ AIWG_MANAGED_HOOKS,
775
+ MUSE_MCP_SIDECAR_NAME,
776
+ unifiedDiff,
777
+ mergeManagedHooks,
778
+ deployMuseHooks,
779
+ pruneMuseHooks,
780
+ assertMuseUserSettingsPath,
781
+ aiwgMcpServerProfile,
782
+ mergeMcpProfile,
783
+ deployMuseMcp,
784
+ pruneMuseMcp,
785
+ };