@uniqbit/mate-core 0.18.3-canary.1 → 0.18.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 (127) hide show
  1. package/claude-plugin/hooks/hooks.json +10 -1
  2. package/claude-plugin/hooks/session-banner.mjs +4 -3
  3. package/claude-plugin/hooks/session-prepare.mjs +9 -0
  4. package/opencode-plugin/src/add-dir.ts +10 -76
  5. package/opencode-plugin/src/audience-layer.ts +91 -0
  6. package/opencode-plugin/src/companion.ts +70 -18
  7. package/opencode-plugin/src/launch-environment.ts +29 -0
  8. package/opencode-plugin/src/server.ts +60 -8
  9. package/package.json +3 -3
  10. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  11. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  12. package/src/cli/commands/cap/index-cmd.ts +2 -11
  13. package/src/cli/commands/companion/link.ts +16 -11
  14. package/src/cli/commands/companion/list.ts +4 -5
  15. package/src/cli/commands/companion/open.ts +4 -5
  16. package/src/cli/commands/companion/tui.ts +1 -1
  17. package/src/cli/commands/doctor.ts +87 -5
  18. package/src/cli/commands/install.ts +8 -0
  19. package/src/cli/commands/report/renderer.ts +9 -19
  20. package/src/cli/commands/session/prepare.ts +119 -0
  21. package/src/cli/commands/shared/companion-selection.ts +2 -2
  22. package/src/cli/commands/studio/reports.ts +0 -1
  23. package/src/cli/commands/studio/server.ts +6 -2
  24. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  25. package/src/cli/commands/unwrap.ts +28 -3
  26. package/src/cli/commands/wrap.ts +79 -60
  27. package/src/cli/companion-selector.tsx +1 -1
  28. package/src/cli/main.ts +17 -0
  29. package/src/cli/usage.ts +5 -2
  30. package/src/hooks/session-banner.ts +19 -10
  31. package/src/hooks/session-guidance.ts +20 -2
  32. package/src/hooks/session-prepare.ts +101 -0
  33. package/src/lib/fs-utils.ts +13 -0
  34. package/src/lib/orchestrator/adapters/base.ts +45 -9
  35. package/src/lib/orchestrator/adapters/claude.ts +241 -30
  36. package/src/lib/orchestrator/adapters/opencode.ts +72 -34
  37. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  38. package/src/lib/orchestrator/companion-hub.ts +2 -1
  39. package/src/lib/orchestrator/config-store.ts +83 -1
  40. package/src/lib/orchestrator/framework-context.ts +5 -29
  41. package/src/lib/orchestrator/launcher.ts +36 -171
  42. package/src/lib/orchestrator/openspec-store.ts +202 -0
  43. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  44. package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
  45. package/src/lib/orchestrator/projection-entries.ts +124 -11
  46. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  47. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  48. package/src/lib/orchestrator/projection-record.ts +7 -1
  49. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  50. package/src/lib/orchestrator/projection-types.ts +47 -34
  51. package/src/lib/orchestrator/repository-link.ts +473 -0
  52. package/src/lib/orchestrator/root-context.ts +6 -2
  53. package/src/lib/orchestrator/session-prepare.ts +503 -0
  54. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  55. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  56. package/src/lib/orchestrator/working-repo-projection.ts +73 -160
  57. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  58. package/src/lib/orchestrator/wrap-pass.ts +222 -0
  59. package/src/lib/runtime-directory-refresh.ts +186 -0
  60. package/src/lib/update-checker.ts +6 -0
  61. package/src/opencode/companion-hooks.ts +30 -135
  62. package/src/opencode/companion-policy.ts +26 -10
  63. package/src/opencode/index.ts +1 -7
  64. package/src/opencode/projected-guidance.ts +6 -13
  65. package/src/opencode/session-notes.ts +76 -0
  66. package/src/opencode/session-prepare.ts +139 -0
  67. package/src/opencode/tui.tsx +56 -9
  68. package/src/runtime/audience-layer.ts +84 -0
  69. package/src/runtime/claude-delivery-plan.ts +73 -0
  70. package/src/runtime/claude-hook-group.ts +21 -0
  71. package/src/runtime/companion-lock.ts +121 -0
  72. package/src/runtime/companion-sync.ts +66 -9
  73. package/src/runtime/guidance.ts +17 -0
  74. package/src/runtime/opencode-audience-layer.ts +176 -0
  75. package/src/runtime/policy.ts +14 -1
  76. package/src/runtime/projection.ts +1 -1
  77. package/src/runtime/runtime-directory.ts +43 -0
  78. package/src/runtime/runtime-document-manifest.ts +352 -0
  79. package/src/runtime/session-prepare-runner.ts +148 -0
  80. package/src/runtime/wrap-state.ts +94 -0
  81. package/src/runtime/write-atomic.ts +27 -0
  82. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  83. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  84. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  85. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  86. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  87. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  88. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  89. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  90. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  91. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  92. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  93. package/src/tools/setup/audience-layer.ts +207 -0
  94. package/src/tools/setup/audience-registrations.ts +52 -0
  95. package/src/tools/setup/capabilities/openspec.ts +9 -0
  96. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  97. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  98. package/src/tools/setup/contribution-ledger.ts +207 -0
  99. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  100. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  101. package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
  102. package/src/tools/setup/engine.ts +152 -10
  103. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  104. package/src/tools/setup/mate.ts +1 -1
  105. package/src/tools/setup/plugin.ts +19 -4
  106. package/src/tools/setup/plugins/gitignore.ts +7 -1
  107. package/src/tools/setup/providers/claude-format.ts +11 -18
  108. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  109. package/src/tools/setup/providers/claude.ts +50 -8
  110. package/src/tools/setup/providers/opencode.ts +10 -0
  111. package/src/tools/setup/registry.ts +24 -2
  112. package/src/tools/setup/runtime-documents.ts +152 -32
  113. package/src/tools/setup/surface-target.ts +13 -4
  114. package/src/tools/setup/upgrade-sweep.ts +153 -0
  115. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  116. package/src/tools/setup/working-repo-local-state.ts +91 -14
  117. package/src/tools/setup.ts +11 -8
  118. package/wrappers/bin/openspec +42 -1
  119. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  120. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  121. package/src/lib/orchestrator/companion-store.ts +0 -94
  122. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  123. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  124. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  125. package/src/templates/report-assets/README.md +0 -32
  126. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  127. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Mate hooks — PreToolUse artifact-path guardrail, SessionStart banner and companion guidance",
2
+ "description": "Mate hooks \u2014 PreToolUse artifact-path guardrail, SessionStart banner, companion guidance and session prepare",
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
@@ -28,6 +28,15 @@
28
28
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-guidance.mjs\""
29
29
  }
30
30
  ]
31
+ },
32
+ {
33
+ "hooks": [
34
+ {
35
+ "type": "command",
36
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-prepare.mjs\"",
37
+ "timeout": 45
38
+ }
39
+ ]
31
40
  }
32
41
  ]
33
42
  }
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Thin shim: hook logic lives in src/hooks/session-banner.ts, loaded through
3
- // ts-loader.mjs because the installed plugin sits inside node_modules where
4
- // node's native type stripping is disabled.
2
+ /**
3
+ * Thin shim for src/hooks/session-banner.ts, loaded through ts-loader.mjs: the installed
4
+ * plugin sits inside node_modules, where node's native type stripping is off.
5
+ */
5
6
  import "./ts-loader.mjs";
6
7
 
7
8
  const { run } = await import("../../src/hooks/session-banner.ts");
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Thin shim for src/hooks/session-prepare.ts, loaded through ts-loader.mjs: the installed
4
+ * plugin sits inside node_modules, where node's native type stripping is off.
5
+ */
6
+ import "./ts-loader.mjs";
7
+
8
+ const { run } = await import("../../src/hooks/session-prepare.ts");
9
+ process.exitCode = await run();
@@ -1,15 +1,8 @@
1
- import fs from "node:fs/promises";
2
1
  import path from "node:path";
3
2
 
4
3
  import type { Context } from "@opencode/plugin/promise/plugin";
5
4
 
6
- type CompanionSkill = {
7
- id: string;
8
- name: string;
9
- description?: string;
10
- path: string;
11
- content: string;
12
- };
5
+ import { loadSkillInfos } from "../../src/runtime/opencode-audience-layer";
13
6
 
14
7
  function companionSkillPaths(companionPath: string): string[] {
15
8
  return [
@@ -23,87 +16,28 @@ function isWithin(root: string, candidate: string): boolean {
23
16
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
24
17
  }
25
18
 
26
- function parseFrontmatter(content: string): { name?: string; description?: string } {
27
- const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
28
- if (!match) return {};
29
-
30
- const scalar = (key: string) => {
31
- const value = match[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim();
32
- return value?.replace(
33
- /^(?:"([\s\S]*)"|'([\s\S]*)')$/,
34
- (_all, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted,
35
- );
36
- };
37
-
38
- return { name: scalar("name"), description: scalar("description") };
39
- }
40
-
41
- async function findSkillFiles(directory: string): Promise<string[]> {
42
- try {
43
- const entries = await fs.readdir(directory, { withFileTypes: true });
44
- const files: string[] = [];
45
- const subdirectories: Promise<string[]>[] = [];
46
- for (const entry of entries) {
47
- const entryPath = path.join(directory, entry.name);
48
- if (entry.isFile() && entry.name === "SKILL.md") files.push(entryPath);
49
- else if (entry.isDirectory()) subdirectories.push(findSkillFiles(entryPath));
50
- }
51
- const nested = await Promise.all(subdirectories);
52
- return [...files, ...nested.flat()];
53
- } catch {
54
- return [];
55
- }
56
- }
57
-
58
- function skillID(name: string): string {
59
- return (
60
- name
61
- .toLowerCase()
62
- .replace(/[^a-z0-9_-]+/g, "-")
63
- .replace(/^-+|-+$/g, "") || "mate-skill"
64
- );
65
- }
66
-
67
- async function loadCompanionSkills(companionPath: string): Promise<CompanionSkill[]> {
68
- const files = (await Promise.all(companionSkillPaths(companionPath).map(findSkillFiles))).flat();
69
- const skills = await Promise.all(
70
- files.map(async (filePath): Promise<CompanionSkill | null> => {
71
- try {
72
- const content = await fs.readFile(filePath, "utf8");
73
- const metadata = parseFrontmatter(content);
74
- const name = metadata.name || path.basename(path.dirname(filePath));
75
- return {
76
- id: skillID(name),
77
- name,
78
- ...(metadata.description ? { description: metadata.description } : {}),
79
- path: filePath,
80
- content,
81
- };
82
- } catch {
83
- /** An unreadable companion skill must not prevent the session from loading. */
84
- return null;
85
- }
86
- }),
87
- );
88
- return skills.filter((skill) => skill !== null);
89
- }
90
-
91
19
  /**
92
20
  * Grants the session access to the companion and registers its skills. The
93
21
  * permission hook only widens access for companion resources; every other
94
22
  * evaluation keeps OpenCode's own verdict.
95
23
  */
96
- export async function registerCompanionAccess(api: Context, companionPath: string): Promise<void> {
24
+ export async function registerCompanionAccess(
25
+ api: Context,
26
+ companionPath: string,
27
+ extraRoots: readonly string[] = [],
28
+ ): Promise<void> {
97
29
  const permissionRoot = path.resolve(companionPath);
30
+ /** An audience layer's files are as readable as the companion's, for this launch only. */
31
+ const allowedRoots = [permissionRoot, ...extraRoots.map((root) => path.resolve(root))];
98
32
 
99
33
  await api.permission.hook("evaluate", (event) => {
100
- if (event.resources.some((resource) => isWithin(permissionRoot, resource))) {
34
+ if (event.resources.some((resource) => allowedRoots.some((root) => isWithin(root, resource)))) {
101
35
  event.effect = "allow";
102
36
  delete event.message;
103
37
  }
104
38
  });
105
39
 
106
- const skills = await loadCompanionSkills(permissionRoot);
40
+ const skills = await loadSkillInfos(companionSkillPaths(permissionRoot));
107
41
  if (skills.length === 0) return;
108
42
 
109
43
  await api.skill.transform((editor) => {
@@ -0,0 +1,91 @@
1
+ import type { Context } from "@opencode/plugin/promise/plugin";
2
+
3
+ import { repositoryWrappedForOpenCode } from "../../src/opencode/companion-hooks";
4
+ import { currentAudienceLayer } from "../../src/runtime/audience-layer";
5
+ import { MATE_ENV } from "../../src/runtime/env";
6
+ import { parseGuidanceContent } from "../../src/runtime/guidance";
7
+ import {
8
+ readOpenCodeAudienceLayer,
9
+ type OpenCodeLayerContents,
10
+ } from "../../src/runtime/opencode-audience-layer";
11
+ import { readWrapStateSync } from "../../src/runtime/wrap-state";
12
+ import type { LaunchEnvironment } from "./launch-environment";
13
+
14
+ export interface TrustedAudienceLayer {
15
+ root: string;
16
+ contents: OpenCodeLayerContents;
17
+ }
18
+
19
+ /**
20
+ * The audience layer named by a payload the plugin trusts. `env` is the
21
+ * launch environment `pluginLaunchEnvironment` decided on, so a masked
22
+ * payload, a bare session and the shared service all read as `{}` here and
23
+ * never apply a layer.
24
+ */
25
+ export async function readTrustedAudienceLayer(
26
+ env: LaunchEnvironment,
27
+ ): Promise<TrustedAudienceLayer | null> {
28
+ const payload = env[MATE_ENV.guidanceJson];
29
+ if (!payload?.trim()) return null;
30
+ const root = parseGuidanceContent(payload).guidance.audienceLayer;
31
+ if (!root) return null;
32
+ return { root, contents: await readOpenCodeAudienceLayer(root) };
33
+ }
34
+
35
+ /**
36
+ * The layer of the audience the session's checkout is wrapped for. The wrap is
37
+ * resolved from the session's own directory, so the shared service serves each
38
+ * checkout its own audience; the environment never selects one.
39
+ */
40
+ export async function readWrappedAudienceLayer(
41
+ directory: string,
42
+ companionPath: string,
43
+ ): Promise<TrustedAudienceLayer | null> {
44
+ const repoRoot = repositoryWrappedForOpenCode(directory);
45
+ if (!repoRoot) return null;
46
+ const audience = readWrapStateSync(repoRoot).audience;
47
+ if (!audience) return null;
48
+ const root = currentAudienceLayer(companionPath, audience);
49
+ return root ? { root, contents: await readOpenCodeAudienceLayer(root) } : null;
50
+ }
51
+
52
+ /**
53
+ * Applies a layer's MCP servers, skills and agents through the plugin API. A
54
+ * new agent id is created by updating it; its prompt goes in `system`.
55
+ */
56
+ export async function registerAudienceLayer(
57
+ api: Context,
58
+ layer: OpenCodeLayerContents,
59
+ ): Promise<void> {
60
+ if (layer.mcpServers.length > 0) {
61
+ await api.mcp.transform((editor) => {
62
+ for (const server of layer.mcpServers) editor.set(server.name, server.config as never);
63
+ });
64
+ }
65
+
66
+ if (layer.skills.length > 0) {
67
+ await api.skill.transform((editor) => {
68
+ for (const skill of layer.skills) {
69
+ if (editor.get(skill.id)) {
70
+ editor.update(skill.id, (current) => Object.assign(current, skill));
71
+ } else {
72
+ editor.add(skill as never);
73
+ }
74
+ }
75
+ });
76
+ }
77
+
78
+ if (layer.agents.length > 0) {
79
+ await api.agent.transform((editor) => {
80
+ for (const agent of layer.agents) {
81
+ editor.update(agent.id, (current) => {
82
+ const target = current as Record<string, unknown>;
83
+ target.name ??= agent.id;
84
+ if (agent.description) target.description = agent.description;
85
+ target.mode = agent.mode;
86
+ target.system = agent.system;
87
+ });
88
+ }
89
+ });
90
+ }
91
+ }
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
 
3
4
  import type { Context } from "@opencode/plugin/promise/plugin";
@@ -5,15 +6,38 @@ import { MATE_ENV } from "../../src/runtime/env";
5
6
  import { COMPANION_POLICY_MARKER, type MateGuidanceFile } from "../../src/runtime/guidance";
6
7
 
7
8
  import type { CompanionContext } from "../../src/opencode/companion-policy";
9
+ import type { LaunchEnvironment } from "./launch-environment";
8
10
  import { resolveOpenCodeGuidance } from "../../src/opencode/projected-guidance";
11
+ import {
12
+ runtimeDirectoryPath,
13
+ runtimeMateBin,
14
+ runtimeWrapperBinPath,
15
+ } from "../../src/runtime/runtime-directory";
9
16
 
10
17
  function prependPathEntry(pathValue: string | undefined, entry: string): string {
11
18
  const entries = (pathValue ?? "").split(path.delimiter).filter(Boolean);
12
19
  return [entry, ...entries.filter((value) => value !== entry)].join(path.delimiter);
13
20
  }
14
21
 
15
- function resolveWrapperBinPath(): string {
16
- return process.env.MATE_WRAPPER_BIN_PATH ?? "$MATE_WRAPPER_BIN_PATH";
22
+ /**
23
+ * The launch environment first, then the runtime directory every `mate` run
24
+ * keeps current. `null` when neither exists: guidance naming an unresolved
25
+ * placeholder would send the model to a path that is not there.
26
+ */
27
+ function resolveWrapperBinPath(env: LaunchEnvironment): string | null {
28
+ if (env.MATE_WRAPPER_BIN_PATH) return env.MATE_WRAPPER_BIN_PATH;
29
+ const runtime = runtimeWrapperBinPath();
30
+ return fs.existsSync(runtime) ? runtime : null;
31
+ }
32
+
33
+ /** The runtime-directory launcher for the Mate CLI; `null` until a `mate` run wrote it. */
34
+ export function resolveMateBin(): string | null {
35
+ const launcher = runtimeMateBin();
36
+ return fs.existsSync(launcher) ? launcher : null;
37
+ }
38
+
39
+ export function missingRuntimeDirectoryMessage(): string {
40
+ return `Mate runtime directory ${runtimeDirectoryPath()} is missing; run \`mate\` once to repair it.`;
17
41
  }
18
42
 
19
43
  function buildStartupError(details: string[]): Error {
@@ -31,14 +55,17 @@ function buildStartupError(details: string[]): Error {
31
55
  * an Unmanaged Session in a wrapped repository builds the same payload from the
32
56
  * companion the projection names, with the Capability names read live.
33
57
  */
34
- function loadGuidance(directory: string): MateGuidanceFile | null {
35
- const { guidance, errors } = resolveOpenCodeGuidance(process.env, directory);
58
+ function loadGuidance(env: LaunchEnvironment, directory: string): MateGuidanceFile | null {
59
+ const { guidance, errors } = resolveOpenCodeGuidance(env, directory);
36
60
  if (errors.length > 0) throw buildStartupError(errors);
37
61
  return guidance;
38
62
  }
39
63
 
40
- function materializeCompanionGuidance(guidance: string, context: CompanionContext): string {
41
- const wrapperBinPath = resolveWrapperBinPath();
64
+ function materializeCompanionGuidance(
65
+ guidance: string,
66
+ context: CompanionContext,
67
+ wrapperBinPath: string,
68
+ ): string {
42
69
  return guidance
43
70
  .replaceAll("$MATE_REPO_PATH", context.repositoryPath)
44
71
  .replaceAll("$MATE_ARTIFACT_PATH", context.companionPath)
@@ -46,8 +73,13 @@ function materializeCompanionGuidance(guidance: string, context: CompanionContex
46
73
  .replaceAll("$MATE_REPO_ID", context.repositoryId);
47
74
  }
48
75
 
49
- function buildSystemPrompt(context: CompanionContext, guidance: MateGuidanceFile): string[] {
50
- const lines = [materializeCompanionGuidance(guidance.companionGuidance, context)];
76
+ function buildSystemPrompt(
77
+ context: CompanionContext,
78
+ guidance: MateGuidanceFile,
79
+ wrapperBinPath: string,
80
+ layerGuidance: string,
81
+ ): string[] {
82
+ const lines = [materializeCompanionGuidance(guidance.companionGuidance, context, wrapperBinPath)];
51
83
 
52
84
  if (guidance.codebaseExplorationGuidance.trim()) {
53
85
  lines.push("", guidance.codebaseExplorationGuidance);
@@ -57,6 +89,11 @@ function buildSystemPrompt(context: CompanionContext, guidance: MateGuidanceFile
57
89
  lines.push("", `<agents.md>${context.agentsMd.trim()}</agents.md>`);
58
90
  }
59
91
 
92
+ /** The audience's guidance follows the companion's, never replaces it. */
93
+ if (layerGuidance.trim()) {
94
+ lines.push("", layerGuidance.trim());
95
+ }
96
+
60
97
  return lines;
61
98
  }
62
99
 
@@ -70,10 +107,11 @@ function readSystemText(part: unknown): string {
70
107
  function applyCompanionShellEnv(
71
108
  context: CompanionContext,
72
109
  env: Record<string, string | undefined>,
110
+ wrapperBinPath: string,
111
+ launchEnv: LaunchEnvironment,
73
112
  ): void {
74
- const wrapperBinPath = resolveWrapperBinPath();
75
113
  env.MATE_NAME = context.frameworkName;
76
- env.MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
114
+ env.MATE_VERSION = launchEnv.MATE_VERSION ?? "unknown";
77
115
  env.MATE_ARTIFACT_PATH = context.companionPath;
78
116
  env.MATE_WRAPPER_BIN_PATH = wrapperBinPath;
79
117
  if (context.repositoryPath) {
@@ -95,8 +133,7 @@ function applyCompanionShellEnv(
95
133
  env.PATH = prependPathEntry(process.env.PATH, wrapperBinPath);
96
134
  }
97
135
 
98
- function companionPathsResult(context: CompanionContext) {
99
- const wrapperBinPath = resolveWrapperBinPath();
136
+ function companionPathsResult(context: CompanionContext, wrapperBinPath: string) {
100
137
  return {
101
138
  content: JSON.stringify(
102
139
  {
@@ -142,12 +179,27 @@ function mergeCompanionSystem(system: unknown[], companion: string): string[] {
142
179
  return parts.length > 0 ? [parts.join("\n\n")] : [];
143
180
  }
144
181
 
145
- /** Registers guidance, compaction context, shell env, and `companion_paths`. */
146
- export async function registerCompanion(api: Context, context: CompanionContext): Promise<void> {
147
- const guidance = loadGuidance(api.location.directory);
182
+ /**
183
+ * Registers guidance, compaction context, shell env, and `companion_paths`.
184
+ * `env` is the launch environment the caller decided to trust; `{}` resolves
185
+ * everything from the Projection Root.
186
+ */
187
+ export async function registerCompanion(
188
+ api: Context,
189
+ context: CompanionContext,
190
+ env: LaunchEnvironment = process.env,
191
+ layerGuidance = "",
192
+ ): Promise<void> {
193
+ const guidance = loadGuidance(env, api.location.directory);
148
194
  if (!guidance) return;
195
+ /** Reported, not thrown: the guardrails registered before this must stay live. */
196
+ const wrapperBinPath = resolveWrapperBinPath(env);
197
+ if (!wrapperBinPath) {
198
+ console.error(`mate: ${missingRuntimeDirectoryMessage()}`);
199
+ return;
200
+ }
149
201
 
150
- const companion = buildSystemPrompt(context, guidance).join("\n");
202
+ const companion = buildSystemPrompt(context, guidance, wrapperBinPath, layerGuidance).join("\n");
151
203
 
152
204
  await api.session.hook("context", (event) => {
153
205
  const merged = mergeCompanionSystem(event.system, companion);
@@ -164,7 +216,7 @@ export async function registerCompanion(api: Context, context: CompanionContext)
164
216
  });
165
217
 
166
218
  await api.shell.hook("create.before", (event) => {
167
- applyCompanionShellEnv(context, event.env);
219
+ applyCompanionShellEnv(context, event.env, wrapperBinPath, env);
168
220
  });
169
221
 
170
222
  await api.tool.transform((editor) => {
@@ -173,7 +225,7 @@ export async function registerCompanion(api: Context, context: CompanionContext)
173
225
  description: `Return active ${context.frameworkName} companion framework and working repository paths.`,
174
226
  input: { type: "object", properties: {}, additionalProperties: false },
175
227
  async execute() {
176
- return companionPathsResult(context);
228
+ return companionPathsResult(context, wrapperBinPath);
177
229
  },
178
230
  });
179
231
  });
@@ -0,0 +1,29 @@
1
+ import { MATE_ENV } from "../../src/runtime/env";
2
+
3
+ export type LaunchEnvironment = Record<string, string | undefined>;
4
+
5
+ /**
6
+ * True inside OpenCode's shared background service (`opencode serve --service`),
7
+ * which hosts plugins in-process for every bare `opencode` session. Its
8
+ * environment belongs to whichever process started it, not to any session.
9
+ */
10
+ export function runsInSharedService(argv: readonly string[]): boolean {
11
+ return argv.includes("serve") && argv.includes("--service");
12
+ }
13
+
14
+ /**
15
+ * The environment the plugin may treat as a Mate launch environment: `env`
16
+ * itself for a managed launch, `{}` otherwise so every reader falls back to
17
+ * the Projection Root.
18
+ *
19
+ * Inherited `MATE_*` variables prove nothing — every shell under a Mate session
20
+ * carries them. The guidance payload does: only `mate opencode` sets it, and
21
+ * the plugin masks it in every shell it configures.
22
+ */
23
+ export function pluginLaunchEnvironment(
24
+ env: LaunchEnvironment,
25
+ argv: readonly string[],
26
+ ): LaunchEnvironment {
27
+ const managed = Boolean(env[MATE_ENV.guidanceJson]?.trim()) && !runsInSharedService(argv);
28
+ return managed ? env : {};
29
+ }
@@ -1,25 +1,77 @@
1
1
  import { Plugin } from "@opencode/plugin";
2
- import { registerCompanionHooks } from "../../src/opencode/companion-hooks";
2
+ import type { Context } from "@opencode/plugin/promise/plugin";
3
+ import {
4
+ registerCompanionHooks,
5
+ repositoryWrappedForOpenCode,
6
+ } from "../../src/opencode/companion-hooks";
3
7
  import { readContext } from "../../src/opencode/companion-policy";
8
+ import { registerSessionPrepare } from "../../src/opencode/session-prepare";
9
+ import { hasLaunchEnvironment } from "../../src/runtime/env";
10
+ import { spawnPrepareRunner } from "../../src/runtime/session-prepare-runner";
11
+ import { readWrapStateSync } from "../../src/runtime/wrap-state";
4
12
 
5
13
  import { registerCompanionAccess } from "./add-dir";
6
- import { registerCompanion } from "./companion";
14
+ import {
15
+ readTrustedAudienceLayer,
16
+ readWrappedAudienceLayer,
17
+ registerAudienceLayer,
18
+ } from "./audience-layer";
19
+ import { missingRuntimeDirectoryMessage, registerCompanion, resolveMateBin } from "./companion";
20
+ import { pluginLaunchEnvironment, type LaunchEnvironment } from "./launch-environment";
21
+
22
+ /**
23
+ * Only a location in a repository wrapped for OpenCode prepares, and only
24
+ * without a launch environment: `mate opencode` has already prepared the
25
+ * session it starts.
26
+ */
27
+ async function registerPrepare(api: Context, companionPath: string, env: LaunchEnvironment) {
28
+ if (hasLaunchEnvironment(env)) return undefined;
29
+ const repoRoot = repositoryWrappedForOpenCode(api.location.directory);
30
+ if (!repoRoot) return undefined;
31
+ const mateBin = resolveMateBin();
32
+ if (!mateBin) {
33
+ console.error(`mate: ${missingRuntimeDirectoryMessage()}`);
34
+ return undefined;
35
+ }
36
+ return registerSessionPrepare(api, {
37
+ directory: api.location.directory,
38
+ repoPath: repoRoot,
39
+ companionPath,
40
+ mateServers: readWrapStateSync(repoRoot).openCodeMcpServers,
41
+ run: spawnPrepareRunner(mateBin, repoRoot),
42
+ });
43
+ }
7
44
 
8
45
  /**
9
46
  * Mate OpenCode server plugin (OpenCode 2.x). Loaded through the package's
10
47
  * `./server` export; stays inert when no companion resolves from the launch
11
- * environment or a Projection Root above the session directory.
48
+ * environment or a Projection Root above the session directory. The launch
49
+ * environment is decided once here: outside a managed launch every reader
50
+ * gets `{}`, so an environment inherited by the shared service never counts.
12
51
  */
13
52
  export const MateOpenCodePlugin = Plugin.define({
14
53
  id: "mate-opencode-plugin",
15
54
  async setup(api) {
16
- const context = readContext(process.env, api.location.directory);
55
+ const env = pluginLaunchEnvironment(process.env, process.argv);
56
+ const context = readContext(env, api.location.directory);
17
57
  if (!context.companionPath) return;
18
58
 
19
- await registerCompanionAccess(api, context.companionPath);
20
- const cleanup = await registerCompanionHooks(api, context);
21
- await registerCompanion(api, context);
22
- return cleanup;
59
+ /**
60
+ * A layer comes from a payload this launch trusts, else from the wrap of
61
+ * the session's checkout; an inherited environment never names one.
62
+ */
63
+ const layer =
64
+ (await readTrustedAudienceLayer(env)) ??
65
+ (await readWrappedAudienceLayer(api.location.directory, context.companionPath));
66
+ await registerCompanionAccess(api, context.companionPath, layer ? [layer.root] : []);
67
+ await registerCompanionHooks(api, context, env);
68
+ await registerCompanion(api, context, env, layer?.contents.guidance);
69
+ if (layer) await registerAudienceLayer(api, layer.contents);
70
+ /** Last: the guardrails above are live while the local phase holds `setup`. */
71
+ const prepareCleanup = await registerPrepare(api, context.companionPath, env);
72
+ return async () => {
73
+ await prepareCleanup?.();
74
+ };
23
75
  },
24
76
  });
25
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.18.3-canary.1",
3
+ "version": "0.18.4",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,8 +35,8 @@
35
35
  "typecheck": "bunx tsc --noEmit -p ../../tsconfig.json",
36
36
  "lint": "oxlint src/ opencode-plugin/",
37
37
  "lint:fix": "oxlint src/ opencode-plugin/ --fix",
38
- "format": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\" \"!**/report-assets/**\"",
39
- "format:check": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\" \"!**/report-assets/**\" --check"
38
+ "format": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\"",
39
+ "format:check": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\" --check"
40
40
  },
41
41
  "dependencies": {
42
42
  "@opencode/plugin": "^2.0.23",
@@ -356,7 +356,7 @@ export async function resolveDriftedSpecs(
356
356
  message: `mate: not a canonical spec: ${outside.join(", ")}. Only specs under ${SPECS_RELATIVE_DIR}/ are spec publication targets.`,
357
357
  };
358
358
  }
359
- const driftedPaths = new Set(drifted);
359
+ const driftedPaths = new Set(drifted.map(withoutTrailingSlash));
360
360
  const notDrifted = normalized.filter((candidate) => !driftedPaths.has(candidate));
361
361
  if (notDrifted.length > 0) {
362
362
  return {