@uniqbit/mate-core 0.15.4-canary.8 → 0.15.4

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 (44) hide show
  1. package/claude-plugin/hooks/artifact-finish-nudge.mjs +5 -3
  2. package/claude-plugin/hooks/session-banner.mjs +5 -3
  3. package/claude-plugin/hooks/ts-loader.mjs +28 -0
  4. package/claude-plugin/hooks/validate-artifact-path.mjs +5 -3
  5. package/package.json +1 -1
  6. package/src/cli/commands/cap/openspec.ts +20 -1
  7. package/src/cli/commands/companion/hub.ts +1 -0
  8. package/src/cli/commands/plugin/install.ts +15 -3
  9. package/src/hooks/artifact-finish-nudge.ts +35 -3
  10. package/src/lib/context-mode-package.ts +5 -3
  11. package/src/lib/orchestrator/adapters/opencode.ts +10 -60
  12. package/src/lib/orchestrator/companion-git-sync.ts +67 -8
  13. package/src/lib/orchestrator/companion-hub.ts +28 -6
  14. package/src/lib/orchestrator/types.ts +2 -2
  15. package/src/lib/package-paths.ts +1 -0
  16. package/src/opencode/companion-hooks.ts +33 -12
  17. package/src/playbooks/companion-guidance.ts +1 -1
  18. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-openspec-backfill/SKILL.md +65 -0
  19. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +1 -0
  20. package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
  21. package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
  22. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3612 -0
  23. package/src/tools/setup/capabilities/context-mode.ts +57 -83
  24. package/src/tools/setup/capabilities/graphify-shared.ts +27 -0
  25. package/src/tools/setup/capabilities/graphify.ts +86 -295
  26. package/src/tools/setup/capabilities/openspec.ts +34 -1
  27. package/src/tools/setup/capabilities/react-doctor.ts +37 -37
  28. package/src/tools/setup/capabilities/rtk.ts +4 -1
  29. package/src/tools/setup/capabilities/tokensave-shared.ts +6 -0
  30. package/src/tools/setup/capabilities/tokensave.ts +51 -72
  31. package/src/tools/setup/context-services.ts +29 -0
  32. package/src/tools/setup/dynamic-plugins/hydrate.ts +15 -1
  33. package/src/tools/setup/engine.ts +68 -1
  34. package/src/tools/setup/mate.ts +10 -2
  35. package/src/tools/setup/plugin.ts +105 -0
  36. package/src/tools/setup/plugins/gitignore.ts +7 -4
  37. package/src/tools/setup/plugins/guidance.ts +1 -1
  38. package/src/tools/setup/providers/agent-file-sections.ts +78 -0
  39. package/src/tools/setup/providers/claude-format.ts +159 -0
  40. package/src/tools/setup/providers/claude.ts +281 -227
  41. package/src/tools/setup/providers/opencode-format.ts +146 -0
  42. package/src/tools/setup/providers/opencode.ts +209 -60
  43. package/src/tools/setup/providers/skill-tree.ts +38 -0
  44. package/src/tools/setup.ts +7 -56
@@ -1,4 +1,3 @@
1
- // oxlint-disable no-await-in-loop -- config files are reconciled sequentially with shared ownership state
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
 
@@ -11,18 +10,21 @@ import {
11
10
  validateContextModePackage,
12
11
  } from "../../../lib/context-mode-package";
13
12
  import { warmOpenCodePackageCache } from "../../../lib/opencode-plugin-package";
14
- import type { CapabilityPlugin, LaunchPreflightContext, SetupContext } from "../plugin";
13
+ import type {
14
+ CapabilityPlugin,
15
+ LaunchPreflightContext,
16
+ RuntimeContributionsByRuntime,
17
+ SetupContext,
18
+ } from "../plugin";
19
+ import { readClaudeMcpConfig } from "../providers/claude-format";
20
+ import { getOpenCodePluginReferences, readOpenCodeConfig } from "../providers/opencode-format";
15
21
  import { pruneEmptyAncestors } from "../utils";
16
22
 
17
- type Config = Record<string, unknown>;
18
23
  interface ContextModePluginDeps {
19
24
  installPackage?: typeof installContextModePackage;
20
25
  validatePackage?: typeof validateContextModePackage;
21
26
  warmOpenCodePackage?: typeof warmOpenCodePackageCache;
22
27
  }
23
- interface OwnershipState {
24
- opencodeConfigs: string[];
25
- }
26
28
 
27
29
  export const CONTEXT_MODE_OPENCODE_GUIDANCE = `## Context Mode
28
30
 
@@ -30,47 +32,19 @@ Use Context Mode tools for commands or tool calls whose output may be large, for
30
32
 
31
33
  Canonical Mate policy and any more specific tool-routing instructions remain authoritative. In particular, follow explicit requirements to use TokenSave for codebase exploration, Context7 for library documentation, and specialized tools for their assigned operations; Context Mode complements rather than replaces them.`;
32
34
 
33
- function getOwnershipPath(companionPath: string): string {
35
+ // Legacy ownership sidecar from before the managed-marker scheme; removed on
36
+ // sight (managed identity is `isContextModePackageReference` now).
37
+ function getLegacyOwnershipPath(companionPath: string): string {
34
38
  return path.join(companionPath, ".mate", "state", "context-mode.json");
35
39
  }
36
40
 
37
- async function readOwnership(companionPath: string): Promise<OwnershipState> {
38
- try {
39
- return JSON.parse(await fs.readFile(getOwnershipPath(companionPath), "utf8")) as OwnershipState;
40
- } catch {
41
- return { opencodeConfigs: [] };
42
- }
43
- }
44
-
45
- async function writeOwnership(companionPath: string, state: OwnershipState): Promise<void> {
46
- const statePath = getOwnershipPath(companionPath);
47
- if (state.opencodeConfigs.length === 0) {
48
- await fs.rm(statePath, { force: true });
49
- return;
50
- }
51
- await fs.mkdir(path.dirname(statePath), { recursive: true });
52
- await fs.writeFile(statePath, JSON.stringify(state, null, 2) + "\n", "utf8");
53
- }
54
-
55
- function isRecord(value: unknown): value is Config {
56
- return typeof value === "object" && value !== null && !Array.isArray(value);
41
+ // Legacy install location from before the relocate-distribution-deps-to-plugins-local
42
+ // change; self-heals on every apply the same way the ownership sidecar above does.
43
+ function getLegacyInstallDir(companionPath: string): string {
44
+ return path.join(companionPath, ".mate", "dependencies", CONTEXT_MODE_PACKAGE_NAME);
57
45
  }
58
46
 
59
- async function readConfig(filePath: string): Promise<Config> {
60
- try {
61
- const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
62
- return isRecord(parsed) ? parsed : {};
63
- } catch {
64
- return {};
65
- }
66
- }
67
-
68
- function hasContextModeMcp(config: Config): boolean {
69
- const servers = isRecord(config.mcpServers)
70
- ? config.mcpServers
71
- : isRecord(config.mcp)
72
- ? config.mcp
73
- : {};
47
+ function hasContextModeMcp(servers: Record<string, unknown>): boolean {
74
48
  return Object.entries(servers).some(
75
49
  ([name, value]) =>
76
50
  name.toLowerCase().includes("context-mode") || JSON.stringify(value).includes("context-mode"),
@@ -78,58 +52,30 @@ function hasContextModeMcp(config: Config): boolean {
78
52
  }
79
53
 
80
54
  async function assertNoMcpConflict(ctx: SetupContext, provider: "claude" | "opencode") {
55
+ const servers =
56
+ provider === "claude"
57
+ ? ((await readClaudeMcpConfig(path.join(ctx.companionPath, ".mcp.json"))).config.mcpServers ??
58
+ {})
59
+ : (((await readOpenCodeConfig(path.join(ctx.companionPath, ".opencode", "opencode.json")))
60
+ .config.mcp as Record<string, unknown>) ?? {});
81
61
  const configPath =
82
62
  provider === "claude"
83
63
  ? path.join(ctx.companionPath, ".mcp.json")
84
64
  : path.join(ctx.companionPath, ".opencode", "opencode.json");
85
- if (hasContextModeMcp(await readConfig(configPath))) {
65
+ if (hasContextModeMcp(servers)) {
86
66
  throw new Error(
87
67
  `Cannot enable context-mode for ${provider}: ${configPath} already contains a context-mode MCP registration. Remove the duplicate registration or deselect the context-mode capability; Mate did not modify it.`,
88
68
  );
89
69
  }
90
70
  }
91
71
 
92
- async function updateOpenCodeConfig(ctx: SetupContext, enabled: boolean): Promise<void> {
93
- const reference = getContextModePackageReference();
94
- const ownership = await readOwnership(ctx.companionPath);
95
- for (const name of ["opencode.json", "tui.json"]) {
96
- const configPath = path.join(ctx.companionPath, ".opencode", name);
97
- const owned = ownership.opencodeConfigs.includes(name);
98
- if (!enabled && !owned) continue;
99
- const config = await readConfig(configPath);
100
- const plugins = Array.isArray(config.plugin) ? config.plugin : [];
101
- const contextModeReferences = plugins.filter(isContextModePackageReference);
102
- if (enabled && contextModeReferences.some((entry) => entry !== reference)) {
103
- throw new Error(
104
- `Cannot enable ${reference}: ${configPath} contains user-owned context-mode reference ${contextModeReferences.join(", ")}. Mate did not modify it.`,
105
- );
106
- }
107
- const preserved = plugins.filter((entry) => !(entry === reference && owned));
108
- if (enabled) {
109
- if (!contextModeReferences.includes(reference)) {
110
- config.plugin = [...plugins, reference];
111
- ownership.opencodeConfigs.push(name);
112
- }
113
- } else if (preserved.length > 0) {
114
- config.plugin = preserved;
115
- } else {
116
- delete config.plugin;
117
- }
118
- await fs.mkdir(path.dirname(configPath), { recursive: true });
119
- await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
120
- }
121
- if (!enabled) ownership.opencodeConfigs = [];
122
- await writeOwnership(ctx.companionPath, ownership);
123
- }
124
-
125
72
  async function validateOpenCodeReferences(ctx: LaunchPreflightContext): Promise<string[]> {
126
73
  const expected = getContextModePackageReference();
127
74
  const diagnostics: string[] = [];
128
75
  for (const name of ["opencode.json", "tui.json"]) {
129
76
  const configPath = path.join(ctx.companionPath, ".opencode", name);
130
- const config = await readConfig(configPath);
131
- const plugins = Array.isArray(config.plugin) ? config.plugin : [];
132
- if (!plugins.includes(expected)) {
77
+ const { config } = await readOpenCodeConfig(configPath);
78
+ if (!getOpenCodePluginReferences(config).includes(expected)) {
133
79
  diagnostics.push(
134
80
  `Missing or stale context-mode package reference in ${configPath}; expected ${expected}.`,
135
81
  );
@@ -150,7 +96,36 @@ export function createContextModePlugin(deps: ContextModePluginDeps = {}): Capab
150
96
  defaultSelected: false,
151
97
  isEnabled: (config) =>
152
98
  (config.capabilities ?? []).some((capability) => capability.name === "context-mode"),
99
+ // The pinned OpenCode plugin reference is declared and reconciled by the
100
+ // OpenCode Runtime Surface into both config files. Any context-mode
101
+ // reference counts as Mate-managed (marker scheme), so stale pins are
102
+ // replaced and teardown removes only context-mode entries.
103
+ getRuntimeContributions(): RuntimeContributionsByRuntime {
104
+ return {
105
+ claude: {
106
+ // The context-mode Claude plugin exposes its skill and MCP tools
107
+ // under the plugin namespace; pre-seed both so routine routing
108
+ // doesn't prompt.
109
+ permissionEntries: [
110
+ "Skill(context-mode:context-mode)",
111
+ "mcp__plugin_context-mode_context-mode__*",
112
+ ],
113
+ },
114
+ opencode: {
115
+ pluginReferences: [
116
+ {
117
+ reference: getContextModePackageReference(),
118
+ isManagedReference: isContextModePackageReference,
119
+ configFiles: ["opencode.json", "tui.json"],
120
+ },
121
+ ],
122
+ },
123
+ };
124
+ },
153
125
  async apply(ctx) {
126
+ const legacyInstallDir = getLegacyInstallDir(ctx.companionPath);
127
+ await fs.rm(legacyInstallDir, { recursive: true, force: true });
128
+ await pruneEmptyAncestors(path.dirname(legacyInstallDir), ctx.companionPath);
154
129
  if (ctx.mode === "setup") {
155
130
  await installPackage(ctx.companionPath);
156
131
  } else {
@@ -159,10 +134,12 @@ export function createContextModePlugin(deps: ContextModePluginDeps = {}): Capab
159
134
  await ctx.instructions?.append(CONTEXT_MODE_OPENCODE_GUIDANCE, {
160
135
  providers: ["opencode"],
161
136
  });
137
+ await fs.rm(getLegacyOwnershipPath(ctx.companionPath), { force: true });
162
138
  },
163
139
  async teardown(ctx) {
164
140
  await fs.rm(getContextModeInstallDir(ctx.companionPath), { recursive: true, force: true });
165
141
  await pruneEmptyAncestors(getContextModeInstallDir(ctx.companionPath), ctx.companionPath);
142
+ await fs.rm(getLegacyOwnershipPath(ctx.companionPath), { force: true });
166
143
  },
167
144
  forProvider: {
168
145
  claude: {
@@ -175,7 +152,6 @@ export function createContextModePlugin(deps: ContextModePluginDeps = {}): Capab
175
152
  preflight: validateOpenCodeReferences,
176
153
  async apply(ctx) {
177
154
  await assertNoMcpConflict(ctx, "opencode");
178
- await updateOpenCodeConfig(ctx, true);
179
155
  if (ctx.mode === "setup") {
180
156
  const result = await warmOpenCodePackage(
181
157
  CONTEXT_MODE_PACKAGE_NAME,
@@ -188,9 +164,7 @@ export function createContextModePlugin(deps: ContextModePluginDeps = {}): Capab
188
164
  }
189
165
  }
190
166
  },
191
- async teardown(ctx) {
192
- await updateOpenCodeConfig(ctx, false);
193
- },
167
+ async teardown() {},
194
168
  },
195
169
  },
196
170
  };
@@ -0,0 +1,27 @@
1
+ import { FRAMEWORK_NAME } from "../../../framework";
2
+ import {
3
+ removeHeadingSection,
4
+ type RemoveHeadingSectionOptions,
5
+ } from "../providers/agent-file-sections";
6
+
7
+ // Identity of the agent-file section `graphify install` writes. Declared in a
8
+ // leaf module (capability-owned) so both the graphify capability and the
9
+ // guidance plugin can consume it without an import cycle.
10
+
11
+ // Marker names derive from the framework identity, never the invocation name.
12
+ export const GRAPHIFY_START = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:START -->`;
13
+ export const GRAPHIFY_END = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:END -->`;
14
+
15
+ export function isGraphifyHeading(line: string): boolean {
16
+ return /^#{1,6}\s+graphify\s*$/i.test(line);
17
+ }
18
+
19
+ /** Section identity passed to the Runtime Surface escape hatch. */
20
+ export function graphifySectionOptions(): RemoveHeadingSectionOptions {
21
+ return { isHeading: isGraphifyHeading, markerLines: [GRAPHIFY_START(), GRAPHIFY_END()] };
22
+ }
23
+
24
+ // Removes the graphify section from content, collapsing leftover blank lines.
25
+ export function removeGraphifySection(content: string): string {
26
+ return removeHeadingSection(content, graphifySectionOptions());
27
+ }