@aiwg/cli 2026.9.20 → 2026.9.24
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.
- package/agentic/code/providers/capability-matrix.yaml +58 -6
- package/agentic/code/providers/model-capabilities.v1.json +34 -0
- package/agentic/code/providers/model-catalog.v1.json +55 -13
- package/bin/aiwg.mjs +2 -0
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/artifacts/cli.js +23 -18
- package/dist/src/artifacts/enrichment/cli.js +1 -1
- package/dist/src/artifacts/fortemi-core-query-adapter.js +6 -2
- package/dist/src/artifacts/query-engine.js +14 -4
- package/dist/src/artifacts/views/cli.js +1 -1
- package/dist/src/audit/operator-decision.js +10 -0
- package/dist/src/auth/credential-store.js +127 -27
- package/dist/src/catalog/builtin-models.json +272 -40
- package/dist/src/catalog/cli.js +1 -1
- package/dist/src/catalog/cli.mjs +1 -1
- package/dist/src/cli/handlers/effect.js +872 -0
- package/dist/src/cli/handlers/help.js +2 -0
- package/dist/src/cli/handlers/index.js +4 -2
- package/dist/src/cli/handlers/models.js +41 -10
- package/dist/src/cli/handlers/ralph.js +1 -1
- package/dist/src/cli/handlers/sessions.js +113 -37
- package/dist/src/cli/handlers/subcommands.js +31 -0
- package/dist/src/cli/handlers/use.js +46 -9
- package/dist/src/cli/handlers/utilities.js +10 -0
- package/dist/src/cli/handlers/workspace.js +3 -1
- package/dist/src/cli/scope-resolver.js +112 -9
- package/dist/src/cli/services/deployment-verification.js +5 -0
- package/dist/src/config/aiwg-config.js +3 -0
- package/dist/src/decision/acceptance-rollout.js +93 -0
- package/dist/src/decision/acceptance.js +175 -0
- package/dist/src/decision/adapters/jev.js +234 -13
- package/dist/src/decision/adapters/llm-subagent.js +60 -12
- package/dist/src/decision/admission.js +550 -0
- package/dist/src/decision/batch-benchmark.js +47 -0
- package/dist/src/decision/batch-receipts/accounting.js +75 -0
- package/dist/src/decision/batch-receipts/export.js +40 -0
- package/dist/src/decision/batch-receipts/index.js +9 -0
- package/dist/src/decision/batch-receipts/protection.js +40 -0
- package/dist/src/decision/batch-receipts/receipt.js +44 -0
- package/dist/src/decision/batch-receipts/result-store.js +341 -0
- package/dist/src/decision/batch-receipts/store.js +362 -0
- package/dist/src/decision/batch-receipts/types.js +2 -0
- package/dist/src/decision/batch-receipts/validate.js +127 -0
- package/dist/src/decision/batch.js +68 -0
- package/dist/src/decision/calibration/index.js +4 -0
- package/dist/src/decision/calibration/receipts.js +130 -0
- package/dist/src/decision/calibration/registry.js +258 -0
- package/dist/src/decision/calibration/types.js +2 -0
- package/dist/src/decision/compile-cache/benchmark.js +62 -0
- package/dist/src/decision/compile-cache/compatibility.js +65 -0
- package/dist/src/decision/compile-cache/file-store.js +280 -0
- package/dist/src/decision/compile-cache/identity.js +26 -0
- package/dist/src/decision/compile-cache/index.js +9 -0
- package/dist/src/decision/compile-cache/prefix.js +21 -0
- package/dist/src/decision/compile-cache/records.js +86 -0
- package/dist/src/decision/compile-cache/runtime.js +68 -0
- package/dist/src/decision/compile-cache/store.js +108 -0
- package/dist/src/decision/compile-cache/types.js +2 -0
- package/dist/src/decision/context-plan.js +234 -0
- package/dist/src/decision/context-qualification.js +60 -0
- package/dist/src/decision/ensemble/aggregate.js +215 -0
- package/dist/src/decision/ensemble/contract.js +316 -0
- package/dist/src/decision/ensemble/drift.js +51 -0
- package/dist/src/decision/ensemble/index.js +6 -0
- package/dist/src/decision/ensemble/report.js +71 -0
- package/dist/src/decision/ensemble/types.js +2 -0
- package/dist/src/decision/evaluate.js +1024 -78
- package/dist/src/decision/file-lifecycle-store.js +168 -0
- package/dist/src/decision/graph-beam.js +40 -0
- package/dist/src/decision/graph-budget.js +110 -0
- package/dist/src/decision/graph-decision-bridge.js +93 -0
- package/dist/src/decision/graph-evidence.js +123 -0
- package/dist/src/decision/graph-flow-adapter.js +56 -0
- package/dist/src/decision/graph-flow.js +105 -0
- package/dist/src/decision/graph-parallel.js +79 -0
- package/dist/src/decision/graph-patterns.js +20 -0
- package/dist/src/decision/graph-run-store.js +66 -0
- package/dist/src/decision/graph-run.js +166 -0
- package/dist/src/decision/graph-skill-bridge.js +91 -0
- package/dist/src/decision/graph-templates.js +56 -0
- package/dist/src/decision/graph.js +115 -0
- package/dist/src/decision/index.js +35 -0
- package/dist/src/decision/job-accounting.js +93 -0
- package/dist/src/decision/job-contract.js +186 -0
- package/dist/src/decision/job-effects.js +95 -0
- package/dist/src/decision/job-evaluate.js +80 -0
- package/dist/src/decision/job-gateway.js +121 -0
- package/dist/src/decision/job-http.js +194 -0
- package/dist/src/decision/job-payload-store.js +258 -0
- package/dist/src/decision/job-poll-limiter.js +92 -0
- package/dist/src/decision/job-quota.js +158 -0
- package/dist/src/decision/job-runtime.js +240 -0
- package/dist/src/decision/job-scheduler.js +26 -0
- package/dist/src/decision/job-service.js +117 -0
- package/dist/src/decision/job-store.js +264 -0
- package/dist/src/decision/job-worker.js +141 -0
- package/dist/src/decision/lifecycle.js +148 -0
- package/dist/src/decision/patterns/artifacts.js +173 -0
- package/dist/src/decision/patterns/catalog.js +108 -0
- package/dist/src/decision/patterns/durable-review.js +237 -0
- package/dist/src/decision/patterns/index.js +8 -0
- package/dist/src/decision/patterns/playground.js +401 -0
- package/dist/src/decision/patterns/recorded.js +43 -0
- package/dist/src/decision/patterns/specs.js +174 -0
- package/dist/src/decision/patterns/types.js +2 -0
- package/dist/src/decision/portable-secrets.js +53 -0
- package/dist/src/decision/projection.js +210 -0
- package/dist/src/decision/qualification/capture.js +67 -0
- package/dist/src/decision/qualification/drift.js +68 -0
- package/dist/src/decision/qualification/gate-evidence.js +172 -0
- package/dist/src/decision/qualification/gates.js +83 -0
- package/dist/src/decision/qualification/index.js +12 -0
- package/dist/src/decision/qualification/load.js +282 -0
- package/dist/src/decision/qualification/manifest.js +37 -0
- package/dist/src/decision/qualification/privacy.js +41 -0
- package/dist/src/decision/qualification/quality.js +213 -0
- package/dist/src/decision/qualification/release.js +116 -0
- package/dist/src/decision/qualification/runner.js +408 -0
- package/dist/src/decision/qualification/types.js +2 -0
- package/dist/src/decision/receipts.js +30 -7
- package/dist/src/decision/result-cache/index.js +6 -0
- package/dist/src/decision/result-cache/integrity.js +28 -0
- package/dist/src/decision/result-cache/key.js +28 -0
- package/dist/src/decision/result-cache/service.js +92 -0
- package/dist/src/decision/result-cache/store.js +327 -0
- package/dist/src/decision/result-cache/types.js +3 -0
- package/dist/src/decision/review/authorization.js +110 -0
- package/dist/src/decision/review/effect-ledger-adapter.js +321 -0
- package/dist/src/decision/review/index.js +12 -0
- package/dist/src/decision/review/migrate.js +34 -0
- package/dist/src/decision/review/operator-audit.js +59 -0
- package/dist/src/decision/review/recovery-adapters.js +252 -0
- package/dist/src/decision/review/recovery.js +33 -0
- package/dist/src/decision/review/service.js +498 -0
- package/dist/src/decision/review/store.js +219 -0
- package/dist/src/decision/review/types.js +2 -0
- package/dist/src/decision/review/validate.js +205 -0
- package/dist/src/decision/scheduler.js +115 -0
- package/dist/src/decision/telemetry/context.js +42 -0
- package/dist/src/decision/telemetry/debug-sidecar.js +152 -0
- package/dist/src/decision/telemetry/exporter.js +77 -0
- package/dist/src/decision/telemetry/file-debug-backend.js +112 -0
- package/dist/src/decision/telemetry/index.js +13 -0
- package/dist/src/decision/telemetry/mapping.js +146 -0
- package/dist/src/decision/telemetry/metrics.js +164 -0
- package/dist/src/decision/telemetry/otlp-http.js +183 -0
- package/dist/src/decision/telemetry/redaction.js +93 -0
- package/dist/src/decision/telemetry/retention.js +109 -0
- package/dist/src/decision/telemetry/runtime.js +310 -0
- package/dist/src/decision/telemetry/trace.js +158 -0
- package/dist/src/decision/telemetry/types.js +3 -0
- package/dist/src/decision/validate.js +135 -5
- package/dist/src/effects/checkpoint-sinks.js +94 -0
- package/dist/src/effects/checkpoint.js +76 -0
- package/dist/src/effects/decision-links.js +54 -0
- package/dist/src/effects/errors.js +48 -0
- package/dist/src/effects/identity.js +178 -0
- package/dist/src/effects/index.js +25 -0
- package/dist/src/effects/keyring.js +160 -0
- package/dist/src/effects/keys.js +130 -0
- package/dist/src/effects/ledger.js +435 -0
- package/dist/src/effects/lifecycle.js +110 -0
- package/dist/src/effects/lock-recovery.js +269 -0
- package/dist/src/effects/reader.js +103 -0
- package/dist/src/effects/records.js +114 -0
- package/dist/src/effects/redaction.js +51 -0
- package/dist/src/effects/schema.js +73 -0
- package/dist/src/effects/store.js +239 -0
- package/dist/src/effects/types.js +49 -0
- package/dist/src/effects/verifiers/decision.js +168 -0
- package/dist/src/effects/verifiers/file.js +106 -0
- package/dist/src/effects/verifiers/git.js +206 -0
- package/dist/src/effects/verifiers/index.js +90 -0
- package/dist/src/effects/verifiers/registry.js +148 -0
- package/dist/src/effects/verifiers/review.js +123 -0
- package/dist/src/effects/verifiers/tracker.js +299 -0
- package/dist/src/effects/verifiers/types.js +21 -0
- package/dist/src/effects/verify.js +295 -0
- package/dist/src/evidence/bundle.js +2 -0
- package/dist/src/extensions/commands/definitions.js +28 -0
- package/dist/src/extensions/manifest.js +1 -0
- package/dist/src/marketplace/provenance.js +25 -27
- package/dist/src/mcp/cli.mjs +11 -1
- package/dist/src/mcp/grok-build-config.mjs +189 -0
- package/dist/src/mcp/registry.js +21 -1
- package/dist/src/mcp/registry.mjs +20 -1
- package/dist/src/models/grok-build-models.js +356 -0
- package/dist/src/models/index.js +1 -0
- package/dist/src/models/model-capabilities.v1.json +34 -0
- package/dist/src/models/model-catalog.v1.json +55 -13
- package/dist/src/models/model-discovery.js +41 -5
- package/dist/src/models/provider-policy.js +3 -2
- package/dist/src/plugin/skill-command-translator.js +1 -0
- package/dist/src/providers/capability-matrix.yaml +58 -6
- package/dist/src/providers/grok-build-acp.js +192 -0
- package/dist/src/providers/grok-build-dispatch.js +251 -0
- package/dist/src/providers/grok-build-headless.js +170 -0
- package/dist/src/providers/muse-paths.js +105 -0
- package/dist/src/providers/provider-definitions.js +93 -7
- package/dist/src/providers/provider-definitions.mjs +21 -1
- package/dist/src/providers/provider-inventory.js +3 -0
- package/dist/src/security/artifact-attestation.js +4 -8
- package/dist/src/security/signing.js +119 -0
- package/dist/src/sessions/adapters/grok-build.js +187 -0
- package/dist/src/sessions/adapters/muse.js +412 -0
- package/dist/src/sessions/contracts.js +1 -1
- package/dist/src/sessions/grok-build-cli.js +196 -0
- package/dist/src/sessions/index.js +3 -0
- package/dist/src/sessions/workspace-discovery.js +9 -5
- package/dist/src/skills/deployer.js +33 -5
- package/dist/src/smiths/context-pipeline/claude-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/context-file-safety.js +18 -0
- package/dist/src/smiths/context-pipeline/finalization.js +1 -32
- package/dist/src/smiths/context-pipeline/generator.js +12 -0
- package/dist/src/smiths/context-pipeline/index.js +1 -1
- package/dist/src/smiths/context-pipeline/managed-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/muse-bridge.js +50 -0
- package/dist/src/smiths/context-pipeline/provider-policy.js +5 -0
- package/dist/src/smiths/context-pipeline/workspace-context.js +22 -16
- package/dist/src/smiths/hook-bridge/grok-build-translator.js +64 -0
- package/dist/src/smiths/hook-bridge/index.js +3 -0
- package/dist/src/smiths/hook-bridge/shim.js +2 -0
- package/dist/src/smiths/hook-bridge/types.js +5 -0
- package/dist/src/smiths/skillsmith/collision-detector.js +1 -0
- package/dist/src/storage/protected-files.js +102 -0
- package/dist/src/tracker/forge-http.js +371 -0
- package/dist/src/tracker/project-inputs.js +41 -0
- package/package.json +2 -1
- package/schemas/decision/BatchReceipt.v1.schema.json +50 -0
- package/schemas/decision/CalibrationArtifact.v1.schema.json +76 -0
- package/schemas/decision/CalibrationCompatibility.v1.schema.json +18 -0
- package/schemas/decision/CalibrationGovernanceReceipt.v1.schema.json +44 -0
- package/schemas/decision/DecisionAdmissionEvidence.v1.schema.json +43 -0
- package/schemas/decision/DecisionBinding.schema.json +309 -0
- package/schemas/decision/DecisionBinding.v1alpha2.schema.json +369 -0
- package/schemas/decision/DecisionCacheTelemetry.v1.schema.json +20 -0
- package/schemas/decision/DecisionChampionChallenger.v1.schema.json +100 -0
- package/schemas/decision/DecisionCompileCacheEntry.v1.schema.json +35 -0
- package/schemas/decision/DecisionCompileCacheIdentity.v1.schema.json +26 -0
- package/schemas/decision/DecisionCompileCacheTombstone.v1.schema.json +31 -0
- package/schemas/decision/DecisionContextEvidence.v1.schema.json +136 -0
- package/schemas/decision/DecisionDefinition.schema.json +159 -0
- package/schemas/decision/DecisionDefinition.v1alpha2.schema.json +203 -0
- package/schemas/decision/DecisionDriftResponse.v1.schema.json +55 -0
- package/schemas/decision/DecisionEnsembleAggregate.v1.schema.json +86 -0
- package/schemas/decision/DecisionEnsembleIntegrityReport.v1.schema.json +64 -0
- package/schemas/decision/DecisionEnsemblePolicy.v1.schema.json +130 -0
- package/schemas/decision/DecisionGraph.v1.schema.json +77 -0
- package/schemas/decision/DecisionJob.v1.schema.json +71 -0
- package/schemas/decision/DecisionLifecyclePolicy.v1.schema.json +52 -0
- package/schemas/decision/DecisionProjectionPolicy.v1.schema.json +65 -0
- package/schemas/decision/DecisionProviderPrefixCompatibility.v1.schema.json +45 -0
- package/schemas/decision/DecisionProviderPrefixEvidence.v1.schema.json +17 -0
- package/schemas/decision/DecisionProviderPrefixIdentity.v1.schema.json +36 -0
- package/schemas/decision/DecisionResult.schema.json +524 -0
- package/schemas/decision/DecisionResult.v1alpha2.schema.json +619 -0
- package/schemas/decision/DecisionResultCacheEntry.v1.schema.json +53 -0
- package/schemas/decision/DecisionReview.schema.json +29 -0
- package/schemas/decision/DecisionRuleset.schema.json +313 -0
- package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +313 -0
- package/schemas/decision/DecisionTelemetryTrace.v1.schema.json +95 -0
- package/schemas/decision/RulesetResult.schema.json +721 -0
- package/schemas/decision/RulesetResult.v1alpha2.schema.json +891 -0
- package/schemas/security/pr-evidence-receipt.v1.schema.json +77 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +71 -1
- package/tools/agents/deploy-agents.mjs +21 -6
- package/tools/agents/providers/base.mjs +7 -4
- package/tools/agents/providers/codex.mjs +1 -1
- package/tools/agents/providers/grok-build.d.mts +7 -0
- package/tools/agents/providers/grok-build.mjs +249 -9
- package/tools/agents/providers/model-role.mjs +6 -3
- package/tools/agents/providers/muse-hooks.mjs +785 -0
- package/tools/agents/providers/muse-paths.mjs +112 -0
- 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
|
+
};
|