@uniqbit/mate-core 0.15.5 → 0.16.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +2 -2
  2. package/claude-plugin/hooks/hooks.json +3 -6
  3. package/claude-plugin/hooks/session-guidance.mjs +8 -0
  4. package/claude-plugin/hooks/ts-loader.mjs +19 -0
  5. package/package.json +6 -4
  6. package/src/cli/commands/artifact/artifact.ts +19 -3
  7. package/src/cli/commands/artifact/finish/command.ts +183 -57
  8. package/src/cli/commands/artifact/finish/engine.ts +80 -91
  9. package/src/cli/commands/artifact/finish/finisher.ts +26 -33
  10. package/src/cli/commands/artifact/finish/git.ts +34 -23
  11. package/src/cli/commands/artifact/finish/index.ts +9 -3
  12. package/src/cli/commands/artifact/finish/openspec.ts +225 -97
  13. package/src/cli/commands/artifact/pending/command.ts +175 -0
  14. package/src/cli/commands/artifact/pending/discovery.ts +249 -0
  15. package/src/cli/commands/artifact/pending/index.ts +17 -0
  16. package/src/cli/commands/cap/index-cmd.ts +9 -1
  17. package/src/cli/commands/cap/index.ts +2 -6
  18. package/src/cli/commands/companion/companion.ts +5 -1
  19. package/src/cli/commands/companion/link.ts +2 -2
  20. package/src/cli/commands/companion/sync.ts +92 -0
  21. package/src/cli/commands/doctor.ts +0 -3
  22. package/src/cli/commands/launch/shared.ts +23 -5
  23. package/src/cli/commands/report/collector.ts +72 -78
  24. package/src/cli/commands/report/contract.ts +40 -1
  25. package/src/cli/commands/report/highlight.ts +27 -0
  26. package/src/cli/commands/report/index.ts +11 -18
  27. package/src/cli/commands/report/renderer.ts +199 -2
  28. package/src/cli/commands/report/types.ts +26 -1
  29. package/src/cli/commands/shared/companion-selection.ts +107 -10
  30. package/src/cli/commands/studio/areas.ts +68 -0
  31. package/src/cli/commands/studio/index.ts +69 -0
  32. package/src/cli/commands/studio/inventory.ts +55 -0
  33. package/src/cli/commands/studio/mate-inventory.ts +43 -0
  34. package/src/cli/commands/studio/openspec-cli.ts +198 -0
  35. package/src/cli/commands/studio/payload.ts +184 -0
  36. package/src/cli/commands/studio/routes.ts +2 -0
  37. package/src/cli/commands/studio/selection.ts +61 -0
  38. package/src/cli/commands/studio/server.ts +201 -0
  39. package/src/cli/commands/studio/snapshot.ts +63 -0
  40. package/src/cli/commands/studio/topology.ts +199 -0
  41. package/src/cli/commands/studio/views/client.ts +197 -0
  42. package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
  43. package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
  44. package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
  45. package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
  46. package/src/cli/commands/studio/views/document.tsx +256 -0
  47. package/src/cli/commands/studio/views/error.tsx +20 -0
  48. package/src/cli/commands/studio/views/model.ts +34 -0
  49. package/src/cli/commands/studio/views/pairings.tsx +38 -0
  50. package/src/cli/commands/studio/views/skills/index.tsx +87 -0
  51. package/src/cli/commands/studio/views/specs/index.tsx +101 -0
  52. package/src/cli/commands/studio/views/styles.ts +389 -0
  53. package/src/cli/commands/studio/views/warnings.tsx +21 -0
  54. package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
  55. package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
  56. package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
  57. package/src/cli/commands/unwrap.ts +70 -0
  58. package/src/cli/commands/wrap.ts +164 -0
  59. package/src/cli/main.ts +68 -19
  60. package/src/cli/parse-flags.ts +36 -11
  61. package/src/cli/usage.ts +12 -3
  62. package/src/framework.ts +1 -7
  63. package/src/hooks/session-banner.ts +64 -11
  64. package/src/hooks/session-guidance.ts +40 -0
  65. package/src/hooks/validate-artifact-path.ts +108 -35
  66. package/src/lib/fs-utils.ts +9 -0
  67. package/src/lib/install.ts +33 -0
  68. package/src/lib/orchestrator/adapters/base.ts +14 -125
  69. package/src/lib/orchestrator/adapters/claude.ts +0 -11
  70. package/src/lib/orchestrator/adapters/opencode.ts +2 -32
  71. package/src/lib/orchestrator/companion-git-sync.ts +94 -84
  72. package/src/lib/orchestrator/config-store.ts +2 -21
  73. package/src/lib/orchestrator/editor.ts +12 -22
  74. package/src/lib/orchestrator/framework-context.ts +17 -6
  75. package/src/lib/orchestrator/global-config-store.ts +1 -1
  76. package/src/lib/orchestrator/launcher.ts +97 -7
  77. package/src/lib/orchestrator/opencode-guidance.ts +4 -56
  78. package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
  79. package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
  80. package/src/lib/orchestrator/projection-companion-link.ts +62 -0
  81. package/src/lib/orchestrator/projection-entries.ts +377 -0
  82. package/src/lib/orchestrator/projection-record.ts +56 -0
  83. package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
  84. package/src/lib/orchestrator/projection-types.ts +169 -0
  85. package/src/lib/orchestrator/repo-local-registry.ts +37 -133
  86. package/src/lib/orchestrator/repo-local-store.ts +96 -0
  87. package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
  88. package/src/lib/orchestrator/types.ts +1 -0
  89. package/src/lib/orchestrator/working-repo-projection.ts +366 -0
  90. package/src/lib/orchestrator/workspace-inventory.ts +1 -1
  91. package/src/lib/package-paths.ts +11 -1
  92. package/src/opencode/companion-hooks.ts +89 -245
  93. package/src/opencode/companion-policy.ts +35 -10
  94. package/src/opencode/index.ts +1 -0
  95. package/src/opencode/projected-guidance.ts +56 -0
  96. package/src/opencode/tui.tsx +13 -4
  97. package/src/playbooks/companion-guidance.ts +32 -116
  98. package/src/plugins.ts +0 -1
  99. package/src/runtime/companion-git-state.ts +156 -0
  100. package/src/runtime/companion-git.ts +203 -0
  101. package/src/runtime/companion-guidance.ts +222 -0
  102. package/src/runtime/companion-sync.ts +298 -0
  103. package/src/runtime/env-names.ts +30 -0
  104. package/src/runtime/env.ts +67 -35
  105. package/src/runtime/framework.ts +10 -0
  106. package/src/runtime/freshness.ts +58 -0
  107. package/src/runtime/index.ts +104 -0
  108. package/src/runtime/install.ts +30 -0
  109. package/src/runtime/policy.ts +66 -0
  110. package/src/runtime/projected-guidance.ts +45 -0
  111. package/src/runtime/projection.ts +224 -0
  112. package/src/runtime/repo-local.ts +64 -0
  113. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
  114. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
  115. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
  116. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
  117. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
  118. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
  119. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  120. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
  121. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
  122. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
  123. package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
  124. package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
  125. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
  126. package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  127. package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  128. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
  129. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
  130. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
  131. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +147 -0
  132. package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
  133. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
  134. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
  135. package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
  136. package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
  137. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
  138. package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  139. package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  140. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
  141. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
  142. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
  143. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +147 -0
  144. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
  145. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
  146. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
  147. package/src/templates/report-assets/README.md +32 -0
  148. package/src/templates/report-assets/mermaid.LICENSE +21 -0
  149. package/src/templates/report-assets/mermaid.min.js +4376 -0
  150. package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
  151. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
  152. package/src/tools/setup/capabilities/graphify.ts +16 -7
  153. package/src/tools/setup/capabilities/openspec.ts +63 -56
  154. package/src/tools/setup/capabilities/tokensave.ts +47 -0
  155. package/src/tools/setup/engine.ts +34 -6
  156. package/src/tools/setup/mate.ts +42 -13
  157. package/src/tools/setup/plugin.ts +9 -0
  158. package/src/tools/setup/plugins/guidance.ts +11 -1
  159. package/src/tools/setup/providers/claude-format.ts +49 -4
  160. package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
  161. package/src/tools/setup/providers/claude.ts +55 -220
  162. package/src/tools/setup/providers/opencode.ts +41 -14
  163. package/src/tools/setup/runtime-documents.ts +174 -0
  164. package/src/tools/setup/surface-target.ts +50 -0
  165. package/src/tools/setup/working-repo-cleanup.ts +33 -26
  166. package/src/tools/setup/working-repo-local-state.ts +21 -1
  167. package/src/tools/setup.ts +25 -3
  168. package/wrappers/bin/graphify +57 -8
  169. package/wrappers/bin/openspec +50 -3
  170. package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
  171. package/src/cli/commands/cap/headroom.ts +0 -52
  172. package/src/cli/commands/workspace/list.ts +0 -25
  173. package/src/cli/commands/workspace/materialize.ts +0 -46
  174. package/src/cli/commands/workspace/workspace.ts +0 -22
  175. package/src/hooks/artifact-finish-nudge.ts +0 -244
  176. package/src/lib/orchestrator/headroom/proxy.ts +0 -116
  177. package/src/lib/orchestrator/workspace-materialize.ts +0 -80
  178. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
  179. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
  180. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
  181. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
  182. package/src/tools/setup/capabilities/headroom.ts +0 -57
  183. /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { PROJECTED_BANNER_FLAG } from "../../../hooks/session-banner";
5
+ import { getClaudePluginRoot } from "../../../lib/package-paths";
6
+ import type { ClaudeHookGroup } from "./claude-format";
7
+
8
+ /**
9
+ * The bundled Claude plugin's hook wiring, read as a value. A managed launch
10
+ * loads it with `--plugin-dir`; an Unmanaged Session loads no plugin dir at
11
+ * all, so the working-target settings document has to carry it instead.
12
+ *
13
+ * A managed session loads the Working Repository's settings as its `local`
14
+ * source *alongside* the plugin, so every group carried over runs twice there.
15
+ * All three survive that: the guard, whose verdict is a function of the tool
16
+ * input alone; the guidance hook, which yields nothing when a launch
17
+ * environment is present and so cannot double the prompt the launch already
18
+ * appended; and the banner, whose carried copy is marked so it defers to the
19
+ * launch's.
20
+ */
21
+
22
+ const GUARD_HOOK_SHIM = "validate-artifact-path.mjs";
23
+ const GUIDANCE_HOOK_SHIM = "session-guidance.mjs";
24
+ const BANNER_HOOK_SHIM = "session-banner.mjs";
25
+ const PLUGIN_ROOT_TOKEN = "${CLAUDE_PLUGIN_ROOT}";
26
+
27
+ interface PluginHookWiring {
28
+ hooks?: Record<string, ClaudeHookGroup[]>;
29
+ }
30
+
31
+ function readPluginHookWiring(pluginRoot: string): PluginHookWiring {
32
+ try {
33
+ const parsed = JSON.parse(
34
+ fs.readFileSync(path.join(pluginRoot, "hooks", "hooks.json"), "utf8"),
35
+ ) as unknown;
36
+ if (parsed && typeof parsed === "object") return parsed as PluginHookWiring;
37
+ } catch {
38
+ /* an installation missing its plugin assets contributes no hooks */
39
+ }
40
+ return {};
41
+ }
42
+
43
+ function resolveCommands(group: ClaudeHookGroup, pluginRoot: string): ClaudeHookGroup {
44
+ return {
45
+ ...group,
46
+ hooks: (group.hooks ?? []).map((hook) => ({
47
+ ...hook,
48
+ ...(hook.command ? { command: hook.command.split(PLUGIN_ROOT_TOKEN).join(pluginRoot) } : {}),
49
+ })),
50
+ };
51
+ }
52
+
53
+ function runsShim(shim: string): (group: ClaudeHookGroup) => boolean {
54
+ return (group) => (group.hooks ?? []).some((hook) => (hook.command ?? "").includes(shim));
55
+ }
56
+
57
+ function groupsFor(
58
+ event: string,
59
+ shim: string,
60
+ pluginRoot: string = getClaudePluginRoot(),
61
+ ): ClaudeHookGroup[] {
62
+ return (readPluginHookWiring(pluginRoot).hooks?.[event] ?? [])
63
+ .filter(runsShim(shim))
64
+ .map((group) => resolveCommands(group, pluginRoot));
65
+ }
66
+
67
+ /**
68
+ * The guard's `PreToolUse` groups with `${CLAUDE_PLUGIN_ROOT}` resolved against
69
+ * the running installation, so the command works with no launch environment.
70
+ */
71
+ export function mateGuardHookGroups(pluginRoot: string = getClaudePluginRoot()): ClaudeHookGroup[] {
72
+ return groupsFor("PreToolUse", GUARD_HOOK_SHIM, pluginRoot);
73
+ }
74
+
75
+ /**
76
+ * The guidance hook's `SessionStart` groups, resolved the same way. This is the
77
+ * channel that replaces `--append-system-prompt` where no launch supplies one:
78
+ * a settings document can declare a hook, and there is no settings key for the
79
+ * flag.
80
+ */
81
+ export function mateGuidanceHookGroups(
82
+ pluginRoot: string = getClaudePluginRoot(),
83
+ ): ClaudeHookGroup[] {
84
+ return groupsFor("SessionStart", GUIDANCE_HOOK_SHIM, pluginRoot);
85
+ }
86
+
87
+ /**
88
+ * The banner's `SessionStart` group, resolved the same way and carrying
89
+ * `PROJECTED_BANNER_FLAG`. Without the marker the carried command would be
90
+ * byte-identical to the plugin's, and a managed session — which loads both —
91
+ * would print the banner twice.
92
+ */
93
+ export function mateBannerHookGroups(
94
+ pluginRoot: string = getClaudePluginRoot(),
95
+ ): ClaudeHookGroup[] {
96
+ return groupsFor("SessionStart", BANNER_HOOK_SHIM, pluginRoot).map((group) => ({
97
+ ...group,
98
+ hooks: (group.hooks ?? []).map((hook) => ({
99
+ ...hook,
100
+ ...(hook.command ? { command: `${hook.command} ${PROJECTED_BANNER_FLAG}` } : {}),
101
+ })),
102
+ }));
103
+ }
104
+
105
+ /**
106
+ * Whether a hook group is one the working target placed. It matches a managed
107
+ * marker, so the entry that strips managed groups from a Working Repository has
108
+ * to be told to leave these alone.
109
+ */
110
+ export function isMateGuardHookGroup(group: ClaudeHookGroup): boolean {
111
+ const own = new Set(
112
+ [...mateGuardHookGroups(), ...mateGuidanceHookGroups(), ...mateBannerHookGroups()].map(
113
+ (candidate) => JSON.stringify(candidate),
114
+ ),
115
+ );
116
+ return own.has(JSON.stringify(group));
117
+ }
@@ -3,33 +3,22 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { FRAMEWORK_NAME } from "../../../framework";
6
- import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
7
6
  import type { FrameworkConfig } from "../../../lib/orchestrator/types";
8
7
  import { refreshFromTemplate, stripGuidanceBlock } from "../plugins/guidance";
9
- import {
10
- TOKENSAVE_CLAUDE_MD_MARKER,
11
- TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES,
12
- } from "../capabilities/tokensave-shared";
13
8
  import {
14
9
  instructionBlockKey,
15
10
  removeManagedBlocksForPlugin,
16
11
  upsertManagedBlock,
17
12
  } from "../context-services";
18
13
  import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "../plugin";
14
+ import { surfaceRoot } from "../surface-target";
19
15
  import { mergeDir, pruneEmptyAncestors } from "../utils";
20
- import {
21
- ensureWorkingRepoLocalExcludes,
22
- reconcileWorkingRepoCapabilityExcludes,
23
- } from "../working-repo-local-state";
24
- import {
25
- cutFromMarker,
26
- stripSectionFromFile,
27
- type RemoveHeadingSectionOptions,
28
- } from "./agent-file-sections";
16
+ import { stripSectionFromFile, type RemoveHeadingSectionOptions } from "./agent-file-sections";
29
17
  import { patchSkillTreeMarkdownFiles } from "./skill-tree";
30
18
  import {
31
19
  filterClaudeHookGroups,
32
20
  mergeClaudeHookGroups,
21
+ removeManagedHookGroups,
33
22
  readClaudeMcpConfig,
34
23
  readClaudeSettings,
35
24
  toClaudeMcpEntry,
@@ -68,19 +57,6 @@ const COMPANION_GITIGNORE_ENTRIES = [
68
57
  ".claude/state/",
69
58
  ];
70
59
 
71
- // Command substrings that mark a working-repo hook group as Mate-managed.
72
- // The mate plugin hooks (validate-artifact-path, mate-session-banner,
73
- // mate-artifact-finish.sh) now ship in the bundled Claude plugin; their
74
- // markers are retained migration-only so stale managed groups written by
75
- // earlier releases keep being stripped, and are never re-added.
76
- const MANAGED_HOOK_MARKERS = [
77
- "validate-artifact-path",
78
- "mate-session-banner",
79
- "react-doctor.sh",
80
- "mate-artifact-finish.sh",
81
- "tokensave",
82
- ];
83
-
84
60
  // Hook files earlier releases copied into the companion; the bundled Claude
85
61
  // plugin replaced them. Setup and launch sync delete stale copies.
86
62
  const LEGACY_MATE_HOOK_FILES = [
@@ -126,51 +102,6 @@ function getAllManagedPermissionEntries(companionPath: string): Set<string> {
126
102
  ]);
127
103
  }
128
104
 
129
- function isManagedHookGroup(group: ClaudeHookGroup, extraMarkers: string[] = []): boolean {
130
- return (group.hooks ?? []).some((hook) =>
131
- [...MANAGED_HOOK_MARKERS, ...extraMarkers].some((marker) =>
132
- (hook.command ?? "").includes(marker),
133
- ),
134
- );
135
- }
136
-
137
- function removeManagedHookGroups(
138
- settings: ClaudeSettings,
139
- extraMarkers: string[] = [],
140
- ): ClaudeSettings {
141
- const hooks = filterClaudeHookGroups(
142
- settings.hooks ?? {},
143
- (group) => !isManagedHookGroup(group, extraMarkers),
144
- );
145
-
146
- const next = { ...settings };
147
- if (Object.keys(hooks).length > 0) {
148
- next.hooks = hooks;
149
- } else {
150
- delete next.hooks;
151
- }
152
- return next;
153
- }
154
-
155
- async function stripTokensaveClaudeMdAppend(workingRepoPath: string): Promise<boolean> {
156
- const claudeMdPath = path.join(workingRepoPath, "CLAUDE.md");
157
- let content: string;
158
- try {
159
- content = await fs.readFile(claudeMdPath, "utf8");
160
- } catch {
161
- return false;
162
- }
163
-
164
- const stripped = cutFromMarker(content, TOKENSAVE_CLAUDE_MD_MARKER);
165
- if (stripped === content) return false;
166
- if (stripped.length > 0) {
167
- await fs.writeFile(claudeMdPath, stripped, "utf8");
168
- } else {
169
- await fs.unlink(claudeMdPath);
170
- }
171
- return true;
172
- }
173
-
174
105
  // Resolved path of the companion-owned Claude settings document. This is the
175
106
  // single source of truth for Mate-managed Claude hooks, permissions, and MCP
176
107
  // servers; it is loaded at launch via `claude --settings`.
@@ -185,11 +116,42 @@ export function getCompanionClaudeMcpConfigPath(companionPath: string): string {
185
116
  return path.join(companionPath, ".mcp.json");
186
117
  }
187
118
 
119
+ /**
120
+ * The Mate-managed `permissions.allow` entries of one pass, in emitted order.
121
+ * Rendered separately from the settings document so a working-target pass can
122
+ * obtain them without a write.
123
+ */
124
+ export function renderManagedClaudePermissionEntries(
125
+ companionPath: string,
126
+ contributions: CapabilityContributionInput[] = [],
127
+ ): string[] {
128
+ return [
129
+ ...getBaseManagedPermissionEntries(companionPath),
130
+ ...contributions
131
+ .filter((input) => input.enabled)
132
+ .flatMap((input) => input.contributions.permissionEntries ?? []),
133
+ ];
134
+ }
135
+
136
+ /** The Mate-managed `.mcp.json` server map of one pass, as a value. */
137
+ export function renderManagedClaudeMcpServers(
138
+ contributions: CapabilityContributionInput[] = [],
139
+ ): Record<string, unknown> {
140
+ const servers: Record<string, unknown> = {};
141
+ for (const input of contributions) {
142
+ if (!input.enabled) continue;
143
+ for (const descriptor of input.contributions.mcpServers ?? []) {
144
+ servers[descriptor.name] = toClaudeMcpEntry(descriptor);
145
+ }
146
+ }
147
+ return servers;
148
+ }
149
+
188
150
  // Reconcile the Mate-managed Claude settings shape (hooks, permissions, MCP
189
151
  // servers) on top of whatever already exists in the settings document. Managed
190
152
  // groups/entries always lead their arrays so the emitted shape stays stable
191
153
  // across syncs; unmanaged content is preserved untouched.
192
- function buildManagedClaudeSettings(
154
+ export function renderManagedClaudeSettings(
193
155
  existing: ClaudeSettings,
194
156
  companionPath: string,
195
157
  config: FrameworkConfig,
@@ -203,12 +165,8 @@ function buildManagedClaudeSettings(
203
165
  const declaredPermissionEntries = contributions.flatMap(
204
166
  (input) => input.contributions.permissionEntries ?? [],
205
167
  );
206
- const enabledDeclaredPermissionEntries = contributions
207
- .filter((input) => input.enabled)
208
- .flatMap((input) => input.contributions.permissionEntries ?? []);
209
-
210
- // Mate's own hooks (artifact-path guard, session banner, archive-finish
211
- // nudge) ship in the bundled Claude plugin loaded at launch; settings-sync
168
+ // Mate's own hooks (artifact-path guard, session banner, companion
169
+ // guidance) ship in the bundled Claude plugin loaded at launch; settings-sync
212
170
  // only strips their legacy managed groups (via removeManagedHookGroups) and
213
171
  // reconciles the capability hooks that remain settings-delivered.
214
172
  const hooks = removeManagedHookGroups(existing, declaredMarkers).hooks ?? {};
@@ -233,10 +191,7 @@ function buildManagedClaudeSettings(
233
191
  ...getAllManagedPermissionEntries(companionPath),
234
192
  ...declaredPermissionEntries,
235
193
  ]);
236
- const managedAllow = [
237
- ...getBaseManagedPermissionEntries(companionPath),
238
- ...enabledDeclaredPermissionEntries,
239
- ];
194
+ const managedAllow = renderManagedClaudePermissionEntries(companionPath, contributions);
240
195
  const existingPermissions = existing.permissions ?? {};
241
196
  const existingAllow = Array.isArray(existingPermissions.allow) ? existingPermissions.allow : [];
242
197
  const nextAllow = [
@@ -284,7 +239,7 @@ export async function syncCompanionClaudeSettings(
284
239
  ): Promise<void> {
285
240
  const settingsPath = getCompanionClaudeSettingsPath(companionPath);
286
241
  const existing = await readClaudeSettings(settingsPath);
287
- const settings = buildManagedClaudeSettings(existing, companionPath, config, contributions);
242
+ const settings = renderManagedClaudeSettings(existing, companionPath, config, contributions);
288
243
 
289
244
  await writeClaudeSettings(settingsPath, settings);
290
245
 
@@ -302,11 +257,13 @@ export async function reconcileClaudeContributions(
302
257
  ctx: SetupContext,
303
258
  inputs: CapabilityContributionInput[],
304
259
  ): Promise<void> {
305
- const { companionPath, config } = ctx;
260
+ /** Resolved once per pass; every write site below is relative to it. */
261
+ const root = surfaceRoot(ctx);
262
+ const { config } = ctx;
306
263
 
307
264
  if (ctx.scope === "hub") {
308
- await reconcileClaudeMcpContributions(ctx, inputs);
309
- await reconcileClaudeAgentDefinitionContributions(ctx, inputs);
265
+ await reconcileClaudeMcpContributions(root, inputs);
266
+ await reconcileClaudeAgentDefinitionContributions(root, inputs);
310
267
  return;
311
268
  }
312
269
 
@@ -314,17 +271,17 @@ export async function reconcileClaudeContributions(
314
271
  // runs while the Claude runtime is active; a deactivated runtime's pass is
315
272
  // teardown-only and must not resurrect files the provider teardown removed.
316
273
  if (ctx.activeProviders.includes("claude")) {
317
- await syncCompanionClaudeSettings(companionPath, config, inputs);
274
+ await syncCompanionClaudeSettings(root, config, inputs);
318
275
  }
319
276
 
320
- await reconcileClaudeMcpContributions(ctx, inputs);
321
- await reconcileClaudeAgentDefinitionContributions(ctx, inputs);
277
+ await reconcileClaudeMcpContributions(root, inputs);
278
+ await reconcileClaudeAgentDefinitionContributions(root, inputs);
322
279
 
323
280
  for (const input of inputs) {
324
281
  // Guidance sections are managed blocks in CLAUDE.md. Current sections are
325
282
  // upserted, then stale keys (content changes, disabled capability) are
326
283
  // swept. CLAUDE.md is Claude-exclusive, so no shared-file guard applies.
327
- const guidancePath = path.join(companionPath, "CLAUDE.md");
284
+ const guidancePath = path.join(root, "CLAUDE.md");
328
285
  const sections = input.enabled ? (input.contributions.guidanceSections ?? []) : [];
329
286
  const keepKeys = new Set<string>();
330
287
  for (const section of sections) {
@@ -337,12 +294,12 @@ export async function reconcileClaudeContributions(
337
294
  }
338
295
 
339
296
  for (const skillTree of input.contributions.skillTrees ?? []) {
340
- const skillDir = path.join(companionPath, ".claude", "skills", skillTree.name);
297
+ const skillDir = path.join(root, ".claude", "skills", skillTree.name);
341
298
  if (input.enabled) {
342
299
  await mergeDir(skillTree.sourceDir, skillDir);
343
300
  } else {
344
301
  await fs.rm(skillDir, { recursive: true, force: true });
345
- await pruneEmptyAncestors(path.join(companionPath, ".claude", "skills"), companionPath);
302
+ await pruneEmptyAncestors(path.join(root, ".claude", "skills"), root);
346
303
  }
347
304
  }
348
305
  }
@@ -350,13 +307,13 @@ export async function reconcileClaudeContributions(
350
307
 
351
308
  /** Reconcile only MCP entries without touching Claude settings or guidance. */
352
309
  async function reconcileClaudeMcpContributions(
353
- ctx: SetupContext,
310
+ root: string,
354
311
  inputs: CapabilityContributionInput[],
355
312
  ): Promise<void> {
356
313
  for (const input of inputs) {
357
314
  for (const descriptor of input.contributions.mcpServers ?? []) {
358
315
  await updateClaudeMcpServer(
359
- getCompanionClaudeMcpConfigPath(ctx.companionPath),
316
+ getCompanionClaudeMcpConfigPath(root),
360
317
  descriptor.name,
361
318
  input.enabled ? toClaudeMcpEntry(descriptor) : null,
362
319
  );
@@ -370,10 +327,10 @@ async function reconcileClaudeMcpContributions(
370
327
  * (no shared-file merge), so it needs no companion-only surface.
371
328
  */
372
329
  async function reconcileClaudeAgentDefinitionContributions(
373
- ctx: SetupContext,
330
+ root: string,
374
331
  inputs: CapabilityContributionInput[],
375
332
  ): Promise<void> {
376
- const agentsDir = path.join(ctx.companionPath, ".claude", "agents");
333
+ const agentsDir = path.join(root, ".claude", "agents");
377
334
  for (const input of inputs) {
378
335
  for (const agent of input.contributions.agentDefinitions ?? []) {
379
336
  const agentPath = path.join(agentsDir, `${agent.name}.md`);
@@ -382,7 +339,7 @@ async function reconcileClaudeAgentDefinitionContributions(
382
339
  await fs.writeFile(agentPath, agent.content, "utf8");
383
340
  } else {
384
341
  await fs.rm(agentPath, { force: true });
385
- await pruneEmptyAncestors(agentsDir, ctx.companionPath);
342
+ await pruneEmptyAncestors(agentsDir, root);
386
343
  }
387
344
  }
388
345
  }
@@ -401,128 +358,6 @@ async function syncCompanionClaudeMcpConfig(companionPath: string): Promise<void
401
358
  });
402
359
  }
403
360
 
404
- // Reconcile the working repo for a Claude launch. Mate-managed Claude settings
405
- // now live in companion settings (see `syncCompanionClaudeSettings`) and are
406
- // loaded at launch via `--settings`, so Mate no longer writes them into the
407
- // working repo. Existing Mate-managed hooks are removed during migration;
408
- // user-authored settings remain in place. This also keeps the git exclude for
409
- // that file current and strips the TokenSave CLAUDE.md append the installer may
410
- // have added.
411
- export async function syncWorkingRepoClaudeSettings(
412
- workingRepoPath: string,
413
- companionPath: string,
414
- config: FrameworkConfig,
415
- globalConfigStore = new GlobalConfigStore(),
416
- ): Promise<void> {
417
- await ensureWorkingRepoLocalExcludes(workingRepoPath);
418
- const tokensaveEnabled = (config.capabilities ?? []).some(
419
- (capability) => capability.name === "tokensave",
420
- );
421
- await reconcileWorkingRepoCapabilityExcludes(
422
- workingRepoPath,
423
- tokensaveEnabled ? TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES : [],
424
- TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES,
425
- );
426
- await syncWorkingRepoClaudeAdditionalDirectories(
427
- workingRepoPath,
428
- companionPath,
429
- globalConfigStore,
430
- );
431
- await stripTokensaveClaudeMdAppend(workingRepoPath);
432
- }
433
-
434
- async function syncWorkingRepoClaudeAdditionalDirectories(
435
- workingRepoPath: string,
436
- companionPath: string,
437
- globalConfigStore: GlobalConfigStore,
438
- ): Promise<void> {
439
- const settingsPath = path.join(workingRepoPath, ".claude", "settings.local.json");
440
- // Migrate hooks only. Existing permissions and MCP entries stay in the
441
- // working repo; additionalDirectories is reconciled below.
442
- const existing = removeManagedHookGroups(await readClaudeSettings(settingsPath));
443
- const permissions = { ...existing.permissions };
444
- const existingAdditionalDirectories = Array.isArray(permissions.additionalDirectories)
445
- ? permissions.additionalDirectories
446
- : [];
447
- const registeredCompanionPaths = new Set(
448
- (await globalConfigStore.list()).map((registeredCompanionPath) =>
449
- path.resolve(registeredCompanionPath),
450
- ),
451
- );
452
- const resolvedCompanionPath = path.resolve(companionPath);
453
- const additionalDirectories = Array.from(
454
- new Set(
455
- existingAdditionalDirectories
456
- .map((directory) => path.resolve(directory))
457
- .filter(
458
- (directory) =>
459
- !registeredCompanionPaths.has(directory) || directory === resolvedCompanionPath,
460
- )
461
- .concat(resolvedCompanionPath),
462
- ),
463
- );
464
-
465
- const settings: ClaudeSettings = {
466
- ...existing,
467
- permissions: {
468
- ...permissions,
469
- additionalDirectories,
470
- },
471
- };
472
-
473
- await writeClaudeSettings(settingsPath, settings);
474
- }
475
-
476
- export async function cleanupWorkingRepoClaudeSettings(
477
- workingRepoPath: string,
478
- registeredCompanionPaths: string[],
479
- ): Promise<boolean> {
480
- const settingsPath = path.join(workingRepoPath, ".claude", "settings.local.json");
481
- let existing: ClaudeSettings;
482
- try {
483
- const parsed = JSON.parse(await fs.readFile(settingsPath, "utf8")) as unknown;
484
- if (!parsed || typeof parsed !== "object") return stripTokensaveClaudeMdAppend(workingRepoPath);
485
- existing = parsed as ClaudeSettings;
486
- } catch (error) {
487
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
488
- return stripTokensaveClaudeMdAppend(workingRepoPath);
489
- }
490
- return false;
491
- }
492
-
493
- const settings = removeManagedHookGroups(existing);
494
- const permissions = { ...settings.permissions };
495
- const managedPaths = new Set(
496
- registeredCompanionPaths.map((candidate) => path.resolve(candidate)),
497
- );
498
- const currentDirectories = Array.isArray(permissions.additionalDirectories)
499
- ? permissions.additionalDirectories
500
- : [];
501
- const additionalDirectories = currentDirectories.filter(
502
- (candidate) => !managedPaths.has(path.resolve(candidate)),
503
- );
504
- if (additionalDirectories.length > 0) {
505
- permissions.additionalDirectories = additionalDirectories;
506
- } else {
507
- delete permissions.additionalDirectories;
508
- }
509
- if (Object.keys(permissions).length > 0) {
510
- settings.permissions = permissions;
511
- } else {
512
- delete settings.permissions;
513
- }
514
-
515
- const settingsChanged = JSON.stringify(settings) !== JSON.stringify(existing);
516
- if (settingsChanged && Object.keys(settings).length === 0) {
517
- await fs.unlink(settingsPath);
518
- await pruneEmptyAncestors(path.join(workingRepoPath, ".claude"), workingRepoPath);
519
- } else if (settingsChanged) {
520
- await writeClaudeSettings(settingsPath, settings);
521
- }
522
- const guidanceChanged = await stripTokensaveClaudeMdAppend(workingRepoPath);
523
- return settingsChanged || guidanceChanged;
524
- }
525
-
526
361
  async function configureClaude(companionPath: string): Promise<void> {
527
362
  // Mate hooks are delivered by the bundled Claude plugin at launch; nothing
528
363
  // is copied into `companion/.claude/hooks/` anymore. Stale copies from
@@ -18,6 +18,7 @@ import {
18
18
  upsertManagedBlock,
19
19
  } from "../context-services";
20
20
  import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "../plugin";
21
+ import { surfaceRoot } from "../surface-target";
21
22
  import { mergeDir, pruneEmptyAncestors } from "../utils";
22
23
  import {
23
24
  getOpenCodePluginReferences,
@@ -424,22 +425,23 @@ export async function reconcileOpenCodeContributions(
424
425
  ctx: SetupContext,
425
426
  inputs: CapabilityContributionInput[],
426
427
  ): Promise<void> {
427
- const { companionPath } = ctx;
428
+ /** Resolved once per pass; every write site below is relative to it. */
429
+ const root = surfaceRoot(ctx);
428
430
 
429
431
  if (ctx.scope === "hub") {
430
- await reconcileOpenCodeMcpContributions(ctx, inputs);
431
- await reconcileOpenCodeAgentDefinitionContributions(ctx, inputs);
432
+ await reconcileOpenCodeMcpContributions(root, inputs);
433
+ await reconcileOpenCodeAgentDefinitionContributions(root, inputs);
432
434
  return;
433
435
  }
434
436
 
435
- await reconcileOpenCodeMcpContributions(ctx, inputs);
436
- await reconcileOpenCodeAgentDefinitionContributions(ctx, inputs);
437
+ await reconcileOpenCodeMcpContributions(root, inputs);
438
+ await reconcileOpenCodeAgentDefinitionContributions(root, inputs);
437
439
 
438
440
  for (const input of inputs) {
439
441
  for (const pluginReference of input.contributions.pluginReferences ?? []) {
440
442
  const configFiles = pluginReference.configFiles ?? OPENCODE_CONTRIBUTION_CONFIG_FILES;
441
443
  for (const name of configFiles) {
442
- const configPath = path.join(companionPath, ".opencode", name);
444
+ const configPath = path.join(root, ".opencode", name);
443
445
  const { present, config } = await readOpenCodeConfig(configPath);
444
446
  if (!input.enabled && !present) continue;
445
447
  const preserved = getOpenCodePluginReferences(config).filter(
@@ -456,7 +458,7 @@ export async function reconcileOpenCodeContributions(
456
458
  // Guidance sections are managed blocks in AGENTS.md. Capability disable
457
459
  // strips them here; runtime teardown of the shared AGENTS.md is guarded in
458
460
  // the provider teardown paths (kept while another active runtime uses it).
459
- const guidancePath = path.join(companionPath, "AGENTS.md");
461
+ const guidancePath = path.join(root, "AGENTS.md");
460
462
  const sections = input.enabled ? (input.contributions.guidanceSections ?? []) : [];
461
463
  const keepKeys = new Set<string>();
462
464
  for (const section of sections) {
@@ -469,12 +471,12 @@ export async function reconcileOpenCodeContributions(
469
471
  }
470
472
 
471
473
  for (const skillTree of input.contributions.skillTrees ?? []) {
472
- const skillDir = path.join(companionPath, ".opencode", "skills", skillTree.name);
474
+ const skillDir = path.join(root, ".opencode", "skills", skillTree.name);
473
475
  if (input.enabled) {
474
476
  await mergeDir(skillTree.sourceDir, skillDir);
475
477
  } else {
476
478
  await fs.rm(skillDir, { recursive: true, force: true });
477
- await pruneEmptyAncestors(path.join(companionPath, ".opencode", "skills"), companionPath);
479
+ await pruneEmptyAncestors(path.join(root, ".opencode", "skills"), root);
478
480
  }
479
481
  }
480
482
  }
@@ -482,13 +484,13 @@ export async function reconcileOpenCodeContributions(
482
484
 
483
485
  /** Reconcile only MCP entries without touching OpenCode plugins or guidance. */
484
486
  async function reconcileOpenCodeMcpContributions(
485
- ctx: SetupContext,
487
+ root: string,
486
488
  inputs: CapabilityContributionInput[],
487
489
  ): Promise<void> {
488
490
  for (const input of inputs) {
489
491
  for (const descriptor of input.contributions.mcpServers ?? []) {
490
492
  await updateOpenCodeMcpServer(
491
- getCompanionOpenCodeConfigPath(ctx.companionPath),
493
+ getCompanionOpenCodeConfigPath(root),
492
494
  descriptor.name,
493
495
  input.enabled ? toOpenCodeMcpEntry(descriptor) : null,
494
496
  );
@@ -496,6 +498,31 @@ async function reconcileOpenCodeMcpContributions(
496
498
  }
497
499
  }
498
500
 
501
+ /** The Mate-managed `mcp` map of one pass, as a value. */
502
+ export function renderManagedOpenCodeMcpServers(
503
+ contributions: CapabilityContributionInput[] = [],
504
+ ): Record<string, unknown> {
505
+ const servers: Record<string, unknown> = {};
506
+ for (const input of contributions) {
507
+ if (!input.enabled) continue;
508
+ for (const descriptor of input.contributions.mcpServers ?? []) {
509
+ servers[descriptor.name] = toOpenCodeMcpEntry(descriptor);
510
+ }
511
+ }
512
+ return servers;
513
+ }
514
+
515
+ /**
516
+ * The Mate-managed `permission.external_directory` map, as a value. Rendered
517
+ * once for both destinations a session can reach it through — the projected
518
+ * document and the launch environment — so the two cannot drift.
519
+ */
520
+ export function renderCompanionExternalDirectoryPermissions(
521
+ companionPath: string,
522
+ ): Record<string, string> {
523
+ return { [companionPath]: "allow", [`${companionPath}/**`]: "allow" };
524
+ }
525
+
499
526
  /**
500
527
  * Reconcile declared agent definition files under `.opencode/agents/`. Runs
501
528
  * in both companion and hub scope — an agent definition is fully
@@ -503,10 +530,10 @@ async function reconcileOpenCodeMcpContributions(
503
530
  * surface.
504
531
  */
505
532
  async function reconcileOpenCodeAgentDefinitionContributions(
506
- ctx: SetupContext,
533
+ root: string,
507
534
  inputs: CapabilityContributionInput[],
508
535
  ): Promise<void> {
509
- const agentsDir = path.join(ctx.companionPath, ".opencode", "agents");
536
+ const agentsDir = path.join(root, ".opencode", "agents");
510
537
  for (const input of inputs) {
511
538
  for (const agent of input.contributions.agentDefinitions ?? []) {
512
539
  const agentPath = path.join(agentsDir, `${agent.name}.md`);
@@ -515,7 +542,7 @@ async function reconcileOpenCodeAgentDefinitionContributions(
515
542
  await fs.writeFile(agentPath, agent.content, "utf8");
516
543
  } else {
517
544
  await fs.rm(agentPath, { force: true });
518
- await pruneEmptyAncestors(agentsDir, ctx.companionPath);
545
+ await pruneEmptyAncestors(agentsDir, root);
519
546
  }
520
547
  }
521
548
  }