@uniqbit/mate-core 0.18.3-canary.1 → 0.18.3

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 (95) 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/companion.ts +47 -12
  5. package/opencode-plugin/src/server.ts +39 -4
  6. package/package.json +3 -3
  7. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  8. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  9. package/src/cli/commands/companion/link.ts +16 -11
  10. package/src/cli/commands/companion/list.ts +4 -5
  11. package/src/cli/commands/companion/open.ts +4 -5
  12. package/src/cli/commands/companion/tui.ts +1 -1
  13. package/src/cli/commands/doctor.ts +1 -1
  14. package/src/cli/commands/report/renderer.ts +9 -19
  15. package/src/cli/commands/session/prepare.ts +119 -0
  16. package/src/cli/commands/shared/companion-selection.ts +2 -2
  17. package/src/cli/commands/studio/reports.ts +0 -1
  18. package/src/cli/commands/studio/server.ts +6 -2
  19. package/src/cli/commands/unwrap.ts +28 -3
  20. package/src/cli/commands/wrap.ts +53 -59
  21. package/src/cli/companion-selector.tsx +1 -1
  22. package/src/cli/main.ts +17 -0
  23. package/src/cli/usage.ts +3 -1
  24. package/src/hooks/session-banner.ts +19 -10
  25. package/src/hooks/session-prepare.ts +101 -0
  26. package/src/lib/fs-utils.ts +13 -0
  27. package/src/lib/orchestrator/adapters/base.ts +43 -8
  28. package/src/lib/orchestrator/adapters/claude.ts +130 -29
  29. package/src/lib/orchestrator/adapters/opencode.ts +17 -19
  30. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  31. package/src/lib/orchestrator/companion-hub.ts +2 -1
  32. package/src/lib/orchestrator/config-store.ts +9 -0
  33. package/src/lib/orchestrator/framework-context.ts +5 -29
  34. package/src/lib/orchestrator/launcher.ts +36 -171
  35. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  36. package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
  37. package/src/lib/orchestrator/projection-entries.ts +78 -11
  38. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  39. package/src/lib/orchestrator/projection-record.ts +7 -1
  40. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  41. package/src/lib/orchestrator/projection-types.ts +38 -33
  42. package/src/lib/orchestrator/repository-link.ts +473 -0
  43. package/src/lib/orchestrator/root-context.ts +6 -2
  44. package/src/lib/orchestrator/session-prepare.ts +425 -0
  45. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  46. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  47. package/src/lib/orchestrator/working-repo-projection.ts +71 -159
  48. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  49. package/src/lib/orchestrator/wrap-pass.ts +131 -0
  50. package/src/lib/runtime-directory-refresh.ts +186 -0
  51. package/src/lib/update-checker.ts +6 -0
  52. package/src/opencode/companion-hooks.ts +24 -132
  53. package/src/opencode/companion-policy.ts +26 -10
  54. package/src/opencode/index.ts +1 -7
  55. package/src/opencode/session-notes.ts +76 -0
  56. package/src/opencode/session-prepare.ts +139 -0
  57. package/src/opencode/tui.tsx +56 -9
  58. package/src/runtime/claude-delivery-plan.ts +73 -0
  59. package/src/runtime/claude-hook-group.ts +21 -0
  60. package/src/runtime/companion-lock.ts +121 -0
  61. package/src/runtime/companion-sync.ts +66 -9
  62. package/src/runtime/runtime-directory.ts +43 -0
  63. package/src/runtime/runtime-document-manifest.ts +335 -0
  64. package/src/runtime/session-prepare-runner.ts +148 -0
  65. package/src/runtime/wrap-state.ts +90 -0
  66. package/src/runtime/write-atomic.ts +27 -0
  67. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  68. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  69. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  70. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  71. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  72. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  73. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  74. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  75. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  76. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  77. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  78. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
  79. package/src/tools/setup/engine.ts +15 -4
  80. package/src/tools/setup/mate.ts +1 -1
  81. package/src/tools/setup/providers/claude-format.ts +11 -18
  82. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  83. package/src/tools/setup/providers/claude.ts +23 -6
  84. package/src/tools/setup/runtime-documents.ts +152 -32
  85. package/src/tools/setup/working-repo-local-state.ts +82 -13
  86. package/src/tools/setup.ts +2 -2
  87. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  88. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  89. package/src/lib/orchestrator/companion-store.ts +0 -94
  90. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  91. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  92. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  93. package/src/templates/report-assets/README.md +0 -32
  94. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  95. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -1,28 +1,13 @@
1
1
  // oxlint-disable no-underscore-dangle
2
- import { syncCompanionFiles } from "../../tools/setup";
3
- import { getActiveDistribution } from "../../distribution";
4
2
  import { FRAMEWORK_NAME } from "../../framework";
5
- import type { CapabilityPlugin, LaunchPreflightContext } from "../../tools/setup/plugin";
6
- import type { LaunchAdapter, AdapterContext } from "./adapters/base";
3
+ import type { AdapterContext, LaunchAdapter } from "./adapters/base";
7
4
  import { ClaudeAdapter } from "./adapters/claude";
8
5
  import { OpenCodeAdapter } from "./adapters/opencode";
9
- import { CompanionStore } from "./companion-store";
10
- import { syncCompanionGit } from "./companion-git-sync";
6
+ import { CompanionStore } from "./repository-link";
11
7
  import { resolveForLaunch, type LaunchContext } from "./framework-context";
12
- import {
13
- firstFailure,
14
- type ProjectionEntryOutcome,
15
- type ProjectionInput,
16
- type ProjectionResult,
17
- } from "./projection-types";
18
- import {
19
- isWorkingRepositoryWrapped,
20
- project,
21
- projectWorkingRepositoryBestEffort,
22
- } from "./working-repo-projection";
8
+ import { prepareSession, type PrepareResult, type PrepareRuntime } from "./session-prepare";
23
9
  import {
24
10
  RepositoryNotSelectedError,
25
- LaunchPreflightError,
26
11
  ToolNotAllowedError,
27
12
  type FrameworkConfig,
28
13
  type LaunchRequest,
@@ -53,60 +38,27 @@ interface ResolvedLaunchState {
53
38
  }
54
39
 
55
40
  export const launcherDeps = {
56
- syncCompanionFiles,
57
- /** The Managed Projection's launch scope; the working repo is written only here. */
58
- projectWorkingRepo: (input: ProjectionInput) => project("launch", input),
59
- /**
60
- * The Projection Root a launch guarantees for itself. `resolveForLaunch` also
61
- * writes it, but only on the branch that resolved a companion from the
62
- * Working Repository; asking here makes the guarantee independent of which
63
- * branch answered. Best-effort — a launch carries its own configuration.
64
- */
65
- refreshProjectionRoot: projectWorkingRepositoryBestEffort,
66
- syncCompanionGit,
67
- isWrapped: isWorkingRepositoryWrapped,
41
+ prepareSession,
68
42
  };
69
43
 
70
- /**
71
- * A wrapped Working Repository delivers the companion to sessions the operator
72
- * starts themselves, through documents the Agent Runtime discovers by its own
73
- * upward walk. A Managed Session loads those *and* everything the launch
74
- * injects through `--settings`, `--mcp-config` and `--plugin-dir`, which
75
- * ADR-014 established can never be withdrawn. Two delivery channels that cannot
76
- * be merged must therefore not both be live for one repository, so wrapping and
77
- * launching are exclusive rather than reconciled (ADR-015, superseding
78
- * ADR-014's judgement that the duplication is bounded enough to accept).
79
- *
80
- * Raised before every write the launch itself performs: the companion Git sync,
81
- * the companion file sync, the launch scope's projection and the runtime
82
- * invocation. Companion resolution has already refreshed the Projection Root by
83
- * this point — `resolveForLaunch` writes it while resolving — but that write is
84
- * idempotent and byte-identical to the one `mate wrap` performs, so it cannot
85
- * change what the repository holds.
86
- */
87
- function wrappedRepositoryRefusal(repository: LinkedRepository): string {
88
- return [
89
- `${FRAMEWORK_NAME}: ${repository.id} is wrapped — \`${FRAMEWORK_NAME} claude\` and \`${FRAMEWORK_NAME} opencode\` do not run in a wrapped working repository.`,
90
- "Start the session yourself: `claude` or `opencode` — the wrap delivers the companion guidance.",
91
- `Or restore managed launches: \`${FRAMEWORK_NAME} unwrap\`.`,
92
- ].join("\n");
93
- }
94
-
95
- /**
96
- * The shape `projectWorkingRepositoryBestEffort` reports a failed projection
97
- * in, narrowed to the entry that failed: the launch scope writes several, so
98
- * "the projection" alone would not tell the operator what to fix.
99
- */
100
- function projectionFailure(outcome: ProjectionEntryOutcome, repository: LinkedRepository): string {
101
- const error = outcome.error ?? new Error("projection failed");
102
- return `failed to write ${outcome.path} for ${repository.id}: ${error.message}`;
44
+ /** The wrap notice is informational: `process.stderr.write`, since Bun renders `console.error` red. */
45
+ function report(result: PrepareResult): void {
46
+ const wrapped = result.steps.find((step) => step.step === "working-target");
47
+ if (wrapped?.outcome === "ok" && wrapped.detail !== "current") {
48
+ const runtime = wrapped.runtimes?.includes("claude") ? "Claude" : "OpenCode";
49
+ process.stderr.write(
50
+ `${FRAMEWORK_NAME}: wrapped ${result.repositoryId} for ${runtime}: ${wrapped.detail}\n`,
51
+ );
52
+ }
53
+ for (const warning of result.warnings) {
54
+ console.error(`${FRAMEWORK_NAME}: warning: ${warning}`);
55
+ }
103
56
  }
104
57
 
105
58
  export class FrameworkLauncher {
106
- private readonly adapters = new Map<string, LaunchAdapter>([
107
- ["claude", new ClaudeAdapter()],
108
- ["opencode", new OpenCodeAdapter()],
109
- ]);
59
+ private readonly adapters = new Map<string, LaunchAdapter>(
60
+ [new ClaudeAdapter(), new OpenCodeAdapter()].map((adapter) => [adapter.toolName, adapter]),
61
+ );
110
62
 
111
63
  async resolveLaunchPreview(request: LaunchRequest): Promise<LaunchPreview> {
112
64
  const state = await this.resolveLaunchState(request);
@@ -119,42 +71,25 @@ export class FrameworkLauncher {
119
71
  };
120
72
  }
121
73
 
74
+ /**
75
+ * Session Prepare in its attended mode, then the adapter's share: it words a
76
+ * failed prepare, plans what the launch delivers, and runs its own checks.
77
+ * Each launch's plan travels in its own context, never on the adapter.
78
+ */
122
79
  async prepare(request: LaunchRequest): Promise<PreparedLaunch> {
123
80
  const state = await this.resolveLaunchState(request);
124
- const adapterContext = this.makeAdapterContext(state);
125
81
 
126
- if (state.repository && (await launcherDeps.isWrapped(state.repository.path))) {
127
- throw new LaunchPreflightError(wrappedRepositoryRefusal(state.repository));
128
- }
129
-
130
- if (!request.skipGit && state.config.git === "auto") {
131
- await launcherDeps.syncCompanionGit(
132
- state.companionPath,
133
- state.repository?.path,
134
- request.interactiveGit ?? false,
135
- );
136
- }
137
- if (state.repository) {
138
- await launcherDeps.syncCompanionFiles(
139
- state.companionPath,
140
- state.config,
141
- state.repository.path,
142
- );
143
- } else {
144
- await launcherDeps.syncCompanionFiles(state.companionPath, state.config);
145
- }
146
- if (state.repository) {
147
- this.answerForProjection(
148
- await launcherDeps.projectWorkingRepo({
149
- repoPath: state.repository.path,
150
- companionPath: state.companionPath,
151
- config: state.config,
152
- }),
153
- state.repository,
154
- );
155
- await launcherDeps.refreshProjectionRoot(state.companionPath, state.repository);
156
- }
157
- await this.runCapabilityPreflight(state, request.tool);
82
+ const prepared = await launcherDeps.prepareSession({
83
+ runtime: request.tool as PrepareRuntime,
84
+ companionPath: state.companionPath,
85
+ repository: state.repository,
86
+ mode: "attended",
87
+ config: state.config,
88
+ skipGit: state.skipGit,
89
+ });
90
+ report(prepared);
91
+ if (!prepared.ok) throw state.adapter.launchFailure(prepared);
92
+ const adapterContext = await state.adapter.planLaunch(this.makeAdapterContext(state), prepared);
158
93
  await state.adapter.validateLaunch(adapterContext);
159
94
 
160
95
  return {
@@ -174,7 +109,7 @@ export class FrameworkLauncher {
174
109
  repositoryId,
175
110
  repository: localRepository,
176
111
  } = await this.resolveConfig(request.scope);
177
- const store = new CompanionStore(configStore, workingRepoStore);
112
+ const store = new CompanionStore(companionPath, workingRepoStore);
178
113
  const scope = request.scope ?? "working-repo";
179
114
  const repository =
180
115
  scope === "companion"
@@ -205,28 +140,6 @@ export class FrameworkLauncher {
205
140
  };
206
141
  }
207
142
 
208
- /**
209
- * `project` reports its failures rather than throwing, so the launch scope's
210
- * caller is the one that has to answer for them. A non-degradable entry is a
211
- * guardrail the session was promised — writing the companion into the
212
- * runtime's allow-list, for one — so the launch fails instead of starting
213
- * without it. A degradable entry warns in the voice
214
- * {@link projectWorkingRepositoryBestEffort} uses, and the launch proceeds.
215
- */
216
- private answerForProjection(result: ProjectionResult, repository: LinkedRepository): void {
217
- for (const outcome of result.outcomes) {
218
- if (outcome.state !== "failed" || !outcome.degradable) continue;
219
- console.error(`${FRAMEWORK_NAME}: warning: ${projectionFailure(outcome, repository)}`);
220
- }
221
-
222
- const failure = firstFailure(result.outcomes);
223
- if (failure) {
224
- throw new LaunchPreflightError(
225
- `${FRAMEWORK_NAME}: ${projectionFailure(failure, repository)}`,
226
- );
227
- }
228
- }
229
-
230
143
  private makeAdapterContext(state: ResolvedLaunchState): AdapterContext {
231
144
  return {
232
145
  repository: state.repository,
@@ -239,54 +152,6 @@ export class FrameworkLauncher {
239
152
  };
240
153
  }
241
154
 
242
- private async runCapabilityPreflight(
243
- state: ResolvedLaunchState,
244
- providerId: string,
245
- ): Promise<void> {
246
- const context: LaunchPreflightContext = {
247
- companionPath: state.companionPath,
248
- config: state.config,
249
- repository: state.repository,
250
- providerId,
251
- };
252
- const preflights = getActiveDistribution()
253
- .registry.getAll()
254
- .reduce<
255
- Array<{
256
- capability: CapabilityPlugin;
257
- preflight: NonNullable<NonNullable<CapabilityPlugin["forProvider"]>[string]["preflight"]>;
258
- }>
259
- >((entries, plugin) => {
260
- if (plugin.kind !== "capability" || !plugin.isEnabled(state.config)) return entries;
261
- const capability = plugin as CapabilityPlugin;
262
- const preflight = capability.forProvider?.[providerId]?.preflight;
263
- if (preflight) entries.push({ capability, preflight });
264
- return entries;
265
- }, []);
266
- const results = await Promise.all(
267
- preflights.map(async ({ capability, preflight }) => {
268
- try {
269
- return await preflight(context);
270
- } catch (error) {
271
- const detail = error instanceof Error ? error.message : String(error);
272
- throw new LaunchPreflightError(
273
- `Capability preflight failed for ${capability.id} on ${providerId}: ${detail}`,
274
- { cause: error },
275
- );
276
- }
277
- }),
278
- );
279
- const diagnostics = results.flat();
280
-
281
- if (diagnostics.length > 0) {
282
- throw new LaunchPreflightError(
283
- ["Capability launch preflight failed.", ...diagnostics.map((item) => `- ${item}`)].join(
284
- "\n",
285
- ),
286
- );
287
- }
288
- }
289
-
290
155
  private async resolveConfig(scope: LaunchScope = "working-repo"): Promise<LaunchContext> {
291
156
  return resolveForLaunch(process.cwd(), undefined, scope);
292
157
  }
@@ -11,6 +11,7 @@ import {
11
11
  type ClaudeSettings,
12
12
  } from "../../tools/setup/providers/claude-format";
13
13
  import { isMateGuardHookGroup } from "../../tools/setup/providers/claude-plugin-hooks";
14
+ import { readWrapState, type WrapState } from "../../runtime/wrap-state";
14
15
  import { pruneEmptyAncestors } from "../../tools/setup/utils";
15
16
  import { GlobalConfigStore } from "./global-config-store";
16
17
 
@@ -52,14 +53,21 @@ function parseSettings(raw: string | null): ClaudeSettings {
52
53
  }
53
54
 
54
55
  /**
55
- * Strips the managed hook groups earlier releases wrote here, but leaves the
56
- * guard group the `wrap` scope places: that one is managed by another entry,
57
- * and stripping it would disarm every Unmanaged Session on the next launch.
56
+ * Strips the managed hook groups earlier releases wrote here, but leaves every
57
+ * group the `wrap` scope places: those are owned by the wrap's own entry —
58
+ * Capability groups match a managed marker too — and stripping them would
59
+ * disarm every Unmanaged Session on the next launch.
58
60
  */
59
- function withoutForeignManagedHookGroups(settings: ClaudeSettings): ClaudeSettings {
61
+ function withoutForeignManagedHookGroups(
62
+ settings: ClaudeSettings,
63
+ wrap: WrapState,
64
+ ): ClaudeSettings {
60
65
  const hooks = filterClaudeHookGroups(
61
66
  settings.hooks ?? {},
62
- (group) => isMateGuardHookGroup(group) || !isManagedHookGroup(group),
67
+ (group) =>
68
+ isMateGuardHookGroup(group) ||
69
+ wrap.carriesClaudeHookGroup(group) ||
70
+ !isManagedHookGroup(group),
63
71
  );
64
72
  const next = { ...settings };
65
73
  if (Object.keys(hooks).length > 0) next.hooks = hooks;
@@ -81,7 +89,10 @@ export async function syncWorkingRepoClaudeAdditionalDirectories(
81
89
  ): Promise<"written" | "current"> {
82
90
  const target = settingsPath(repoPath);
83
91
  const raw = await readRaw(target);
84
- const existing = withoutForeignManagedHookGroups(parseSettings(raw));
92
+ const existing = withoutForeignManagedHookGroups(
93
+ parseSettings(raw),
94
+ await readWrapState(repoPath),
95
+ );
85
96
  const permissions = { ...existing.permissions };
86
97
  const existingAdditionalDirectories = Array.isArray(permissions.additionalDirectories)
87
98
  ? permissions.additionalDirectories
@@ -2,10 +2,23 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { readProjectionFile } from "../../runtime/projection";
5
+ import { realLinkTarget, realOrResolved } from "../fs-utils";
6
+ import {
7
+ links,
8
+ readManifest,
9
+ updateManifest,
10
+ withLinks,
11
+ type LinkRecord,
12
+ } from "../../runtime/runtime-document-manifest";
5
13
  import { pruneEmptyAncestors } from "../../tools/setup/utils";
6
14
 
7
15
  const CLAUDE_SKILLS_PATH = path.join(".claude", "skills");
8
16
 
17
+ /** The manifest key of a skill link: repo-relative, POSIX separators. */
18
+ function recordKey(name: string): string {
19
+ return `.claude/skills/${name}`;
20
+ }
21
+
9
22
  async function skillNames(root: string): Promise<string[]> {
10
23
  const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
11
24
  const names: string[] = [];
@@ -25,13 +38,7 @@ async function skillNames(root: string): Promise<string[]> {
25
38
 
26
39
  async function linkTarget(link: string): Promise<string | null> {
27
40
  const target = await fs.readlink(link).catch(() => null);
28
- return target === null ? null : path.resolve(path.dirname(link), target);
29
- }
30
-
31
- async function isManagedSkillLink(link: string, roots: string[]): Promise<boolean> {
32
- const target = await linkTarget(link);
33
- if (target === null) return false;
34
- return roots.some((root) => path.dirname(target) === root);
41
+ return target === null ? null : realLinkTarget(link, target);
35
42
  }
36
43
 
37
44
  async function registeredSkillRoots(
@@ -45,17 +52,23 @@ async function registeredSkillRoots(
45
52
  companionPath ?? "",
46
53
  projection?.projection.companionPath ?? "",
47
54
  ];
48
- return [
49
- ...new Set(
50
- candidates.filter(Boolean).map((candidate) => path.resolve(candidate, CLAUDE_SKILLS_PATH)),
51
- ),
52
- ];
55
+ const roots = await Promise.all(
56
+ candidates
57
+ .filter(Boolean)
58
+ .map((candidate) => realOrResolved(path.resolve(candidate, CLAUDE_SKILLS_PATH))),
59
+ );
60
+ return [...new Set(roots)];
53
61
  }
54
62
 
55
63
  async function createSkillLink(target: string, link: string): Promise<void> {
56
64
  await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
57
65
  }
58
66
 
67
+ /**
68
+ * Recorded link: Mate's while it resolves to its recorded target. Unrecorded:
69
+ * adopted only when it resolves into a registered, current or projected
70
+ * companion. Records follow the filesystem, so a partial failure re-adopts.
71
+ */
59
72
  export async function reconcileWorkingRepoClaudeSkillLinks(
60
73
  repoPath: string,
61
74
  companionPath: string | null,
@@ -65,23 +78,27 @@ export async function reconcileWorkingRepoClaudeSkillLinks(
65
78
  const currentRoot = companionPath ? path.resolve(companionPath, CLAUDE_SKILLS_PATH) : null;
66
79
  const roots = await registeredSkillRoots(repoPath, companionPath, registeredCompanionPaths);
67
80
  const targetRoot = path.join(path.resolve(repoPath), CLAUDE_SKILLS_PATH);
81
+ const recorded = links(await readManifest(repoPath), "claude");
68
82
  const existing = await fs.readdir(targetRoot, { withFileTypes: true }).catch(() => []);
69
83
  const sourceNames: Set<string> =
70
84
  currentRoot && enabled ? new Set(await skillNames(currentRoot)) : new Set<string>();
85
+ const records: Record<string, LinkRecord> = {};
71
86
  let changed = false;
72
87
 
73
88
  for (const entry of existing) {
74
89
  if (!entry.isSymbolicLink()) continue;
75
90
  const link = path.join(targetRoot, entry.name);
76
- if (!(await isManagedSkillLink(link, roots))) continue;
77
-
78
91
  const target = await linkTarget(link);
79
- if (
80
- currentRoot &&
81
- enabled &&
82
- target === path.join(currentRoot, entry.name) &&
83
- sourceNames.has(entry.name)
84
- ) {
92
+ if (target === null) continue;
93
+ const record = recorded[recordKey(entry.name)];
94
+ const owned = record
95
+ ? (await realOrResolved(record.target)) === target
96
+ : roots.some((root) => path.dirname(target) === root);
97
+ if (!owned) continue;
98
+
99
+ const wanted = currentRoot ? path.join(currentRoot, entry.name) : null;
100
+ if (wanted && sourceNames.has(entry.name) && (await realOrResolved(wanted)) === target) {
101
+ records[recordKey(entry.name)] = { runtime: "claude", target: wanted };
85
102
  continue;
86
103
  }
87
104
 
@@ -89,13 +106,15 @@ export async function reconcileWorkingRepoClaudeSkillLinks(
89
106
  changed = true;
90
107
  }
91
108
 
92
- if (currentRoot && enabled && sourceNames.size > 0) {
109
+ if (currentRoot && sourceNames.size > 0) {
93
110
  await fs.mkdir(targetRoot, { recursive: true });
94
111
  for (const name of sourceNames) {
95
112
  const link = path.join(targetRoot, name);
96
113
  const existingLink = await fs.lstat(link).catch(() => null);
97
114
  if (existingLink) continue;
98
- await createSkillLink(path.join(currentRoot, name), link);
115
+ const target = path.join(currentRoot, name);
116
+ await createSkillLink(target, link);
117
+ records[recordKey(name)] = { runtime: "claude", target };
99
118
  changed = true;
100
119
  }
101
120
  }
@@ -103,9 +122,22 @@ export async function reconcileWorkingRepoClaudeSkillLinks(
103
122
  if (changed || !enabled) {
104
123
  await pruneEmptyAncestors(targetRoot, path.resolve(repoPath));
105
124
  }
125
+ if (JSON.stringify(sortedEntries(records)) !== JSON.stringify(sortedEntries(recorded))) {
126
+ await updateManifest(repoPath, (manifest) => withLinks(manifest, "claude", records));
127
+ changed = true;
128
+ }
106
129
  return changed ? "written" : "current";
107
130
  }
108
131
 
132
+ function sortedEntries(records: Record<string, LinkRecord>): [string, LinkRecord][] {
133
+ return Object.entries(records)
134
+ .map(([key, record]): [string, LinkRecord] => [
135
+ key,
136
+ { runtime: record.runtime, target: record.target },
137
+ ])
138
+ .toSorted(([a], [b]) => a.localeCompare(b));
139
+ }
140
+
109
141
  export async function removeWorkingRepoClaudeSkillLinks(
110
142
  repoPath: string,
111
143
  registeredCompanionPaths: string[] = [],
@@ -13,6 +13,11 @@ import {
13
13
  repoLocalDirPath,
14
14
  repoLocalRegistryPath,
15
15
  } from "../../runtime/repo-local";
16
+ import {
17
+ documentRuntime,
18
+ documents as recordedDocuments,
19
+ readManifest,
20
+ } from "../../runtime/runtime-document-manifest";
16
21
  import { TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES } from "../../tools/setup/capabilities/tokensave-shared";
17
22
  import {
18
23
  ensureWorkingRepoLocalExcludes,
@@ -26,6 +31,12 @@ import {
26
31
  reconcileWorkingRepoClaudeSkillLinks,
27
32
  removeWorkingRepoClaudeSkillLinks,
28
33
  } from "./projection-claude-skills";
34
+ import {
35
+ companionCopiesPresent,
36
+ reconcileCompanionCopies,
37
+ removeCompanionCopies,
38
+ withdrawCompanionCopies,
39
+ } from "./projection-opencode-copies";
29
40
  import { buildProjection } from "./projection-record";
30
41
  import {
31
42
  CLAUDE_WORKING_SETTINGS_PATH,
@@ -41,16 +52,22 @@ import {
41
52
  CLAUDE_MCP_DOCUMENT,
42
53
  CLAUDE_SETTINGS_DOCUMENT,
43
54
  OPENCODE_CONFIG_DOCUMENT,
55
+ isExternalDocument,
44
56
  placeRuntimeDocument,
45
57
  removeRuntimeDocument,
46
- runtimeDocumentPresent,
58
+ trackedDocumentWarnings,
47
59
  } from "./projection-runtime-documents";
48
60
  import {
49
61
  upsertRepoLocalCompanionPointer,
50
62
  upsertRepoLocalLinkedRepository,
51
63
  writeRepoLocalFrameworkConfig,
52
64
  } from "./repo-local-store";
53
- import type { ProjectionEntry, ProjectionInput } from "./projection-types";
65
+ import type {
66
+ ProjectionEntry,
67
+ ProjectionInput,
68
+ ProjectionWriteReport,
69
+ ProjectionWriteState,
70
+ } from "./projection-types";
54
71
 
55
72
  const WORKSPACE_DOCUMENT_FILE = "workspace.code-workspace";
56
73
 
@@ -68,6 +85,11 @@ async function pathExists(candidatePath: string): Promise<boolean> {
68
85
  }
69
86
  }
70
87
 
88
+ /** Present when Mate recorded regions for it, not merely when the file exists. */
89
+ async function runtimeDocumentRecorded(repoPath: string, documentPath: string): Promise<boolean> {
90
+ return recordedDocuments(await readManifest(repoPath)).includes(documentPath);
91
+ }
92
+
71
93
  async function writeProjectionPairEntry(input: ProjectionInput): Promise<"written" | "current"> {
72
94
  const { companionPath, repository } = input;
73
95
  const resolvedCompanionPath = path.resolve(companionPath!);
@@ -96,6 +118,26 @@ async function writeProjectionPairEntry(input: ProjectionInput): Promise<"writte
96
118
  return "written";
97
119
  }
98
120
 
121
+ /**
122
+ * Places one rendered document and, when this pass wrote Mate's regions into a
123
+ * file inside the repository, asks Git whether it tracks that file. Only a
124
+ * write is worth the question: an unchanged document was reported when it was
125
+ * written, and a removal puts no companion path into it.
126
+ */
127
+ async function writeRuntimeDocumentEntry(
128
+ input: ProjectionInput,
129
+ documentPath: string,
130
+ ): Promise<ProjectionWriteState | ProjectionWriteReport> {
131
+ const state = await placeRuntimeDocument(input.repoPath, documentPath, input.runtimeDocuments);
132
+ const rendered = input.runtimeDocuments?.some((document) => document.path === documentPath);
133
+ if (state !== "written" || !rendered || isExternalDocument(documentPath)) return state;
134
+ const repositoryId = input.repository?.id ?? path.basename(path.resolve(input.repoPath));
135
+ return {
136
+ state,
137
+ warnings: await trackedDocumentWarnings(input.repoPath, documentPath, repositoryId),
138
+ };
139
+ }
140
+
99
141
  function tokensaveEnabled(input: ProjectionInput): boolean {
100
142
  return (input.config?.capabilities ?? []).some((capability) => capability.name === "tokensave");
101
143
  }
@@ -198,7 +240,7 @@ function buildEntries(): readonly ProjectionEntry[] {
198
240
  id: "projection-pair",
199
241
  kind: "owned",
200
242
  path: path.join(localDir, PROJECTION_YAML_FILE),
201
- scopes: ["session"],
243
+ scopes: ["session", "wrap"],
202
244
  write: async (input) =>
203
245
  input.companionPath && input.repository ? writeProjectionPairEntry(input) : "skipped",
204
246
  removal: { by: "entry", entry: "projection-root" },
@@ -208,6 +250,7 @@ function buildEntries(): readonly ProjectionEntry[] {
208
250
  kind: "merged",
209
251
  path: path.join(".claude", "skills"),
210
252
  scopes: ["wrap"],
253
+ runtime: "claude",
211
254
  removeOnUnwrap: true,
212
255
  write: async (input) => {
213
256
  if (!input.config) return "skipped";
@@ -236,7 +279,7 @@ function buildEntries(): readonly ProjectionEntry[] {
236
279
  id: "capability-excludes",
237
280
  kind: "merged",
238
281
  path: path.join(".git", "info", "exclude"),
239
- scopes: ["launch"],
282
+ scopes: ["launch", "wrap"],
240
283
  write: async (input) => {
241
284
  if (!input.config) return "skipped";
242
285
  return (await reconcileWorkingRepoCapabilityExcludes(
@@ -259,6 +302,7 @@ function buildEntries(): readonly ProjectionEntry[] {
259
302
  kind: "merged",
260
303
  path: CLAUDE_WORKING_SETTINGS_PATH,
261
304
  scopes: ["launch", "wrap"],
305
+ runtime: "claude",
262
306
  write: async (input) =>
263
307
  input.companionPath
264
308
  ? syncWorkingRepoClaudeAdditionalDirectories(
@@ -276,14 +320,14 @@ function buildEntries(): readonly ProjectionEntry[] {
276
320
  },
277
321
  /**
278
322
  * Removal-only: Mate never writes this block, an external installer does.
279
- * Its `write` is the same strip, so a launch heals a repository the
280
- * installer touched without waiting for a cleanup.
323
+ * Its `write` is the same strip, so a launch or a wrap heals a repository
324
+ * the installer touched without waiting for a cleanup.
281
325
  */
282
326
  {
283
327
  id: "legacy-tokensave-claude-md",
284
328
  kind: "merged",
285
329
  path: LEGACY_TOKENSAVE_CLAUDE_MD_PATH,
286
- scopes: ["launch"],
330
+ scopes: ["launch", "wrap"],
287
331
  write: async (input) =>
288
332
  (await stripTokensaveClaudeMdAppend(input.repoPath)) === "removed" ? "written" : "current",
289
333
  removal: { by: "self", remove: (input) => stripTokensaveClaudeMdAppend(input.repoPath) },
@@ -336,6 +380,7 @@ function buildEntries(): readonly ProjectionEntry[] {
336
380
  kind: "merged",
337
381
  path: CLAUDE_MCP_DOCUMENT,
338
382
  scopes: ["launch", "wrap"],
383
+ runtime: "claude",
339
384
  write: async (input) =>
340
385
  (await removeRuntimeDocument(input.repoPath, CLAUDE_MCP_DOCUMENT)) === "removed"
341
386
  ? "written"
@@ -344,7 +389,7 @@ function buildEntries(): readonly ProjectionEntry[] {
344
389
  by: "self",
345
390
  remove: (input) => removeRuntimeDocument(input.repoPath, CLAUDE_MCP_DOCUMENT),
346
391
  },
347
- present: (repoPath) => runtimeDocumentPresent(repoPath, CLAUDE_MCP_DOCUMENT),
392
+ present: (repoPath) => runtimeDocumentRecorded(repoPath, CLAUDE_MCP_DOCUMENT),
348
393
  },
349
394
  ...(
350
395
  [
@@ -357,15 +402,37 @@ function buildEntries(): readonly ProjectionEntry[] {
357
402
  kind: "merged" as const,
358
403
  path: documentPath.split("/").join(path.sep),
359
404
  scopes: ["launch", "wrap"] as const,
360
- write: (input: ProjectionInput) =>
361
- placeRuntimeDocument(input.repoPath, documentPath, input.runtimeDocuments),
405
+ runtime: documentRuntime(documentPath),
406
+ write: (input: ProjectionInput) => writeRuntimeDocumentEntry(input, documentPath),
362
407
  removal: {
363
408
  by: "self" as const,
364
409
  remove: (input: { repoPath: string }) =>
365
410
  removeRuntimeDocument(input.repoPath, documentPath),
366
411
  },
367
- present: (repoPath: string) => runtimeDocumentPresent(repoPath, documentPath),
412
+ present: (repoPath: string) => runtimeDocumentRecorded(repoPath, documentPath),
368
413
  })),
414
+ /**
415
+ * After the OpenCode document, so its copies arrive once the document that
416
+ * loads them is in place. A companion that dropped OpenCode has the copies
417
+ * an earlier wrap made withdrawn; the outcome names each copy written or
418
+ * withdrawn and warns about shadowed and locally edited ones.
419
+ */
420
+ {
421
+ id: "opencode-companion-copies",
422
+ kind: "merged",
423
+ path: ".opencode",
424
+ scopes: ["wrap"],
425
+ runtime: "opencode",
426
+ removeOnUnwrap: true,
427
+ write: async (input) => {
428
+ if (!input.config || !input.companionPath) return "skipped";
429
+ return (input.config.allowedAgents?.includes("opencode") ?? false)
430
+ ? reconcileCompanionCopies(input.repoPath, input.companionPath)
431
+ : withdrawCompanionCopies(input.repoPath);
432
+ },
433
+ removal: { by: "self", remove: (input) => removeCompanionCopies(input.repoPath) },
434
+ present: companionCopiesPresent,
435
+ },
369
436
  ];
370
437
  }
371
438