@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,11 +1,26 @@
1
+ // oxlint-disable no-await-in-loop
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
 
5
+ import { CURRENT_LAYER_LINK } from "../../runtime/audience-layer";
4
6
  import { readProjectionFile } from "../../runtime/projection";
7
+ import { realLinkTarget, realOrResolved } from "../fs-utils";
8
+ import {
9
+ links,
10
+ readManifest,
11
+ updateManifest,
12
+ withLinks,
13
+ type LinkRecord,
14
+ } from "../../runtime/runtime-document-manifest";
5
15
  import { pruneEmptyAncestors } from "../../tools/setup/utils";
6
16
 
7
17
  const CLAUDE_SKILLS_PATH = path.join(".claude", "skills");
8
18
 
19
+ /** The manifest key of a skill link: repo-relative, POSIX separators. */
20
+ function recordKey(name: string): string {
21
+ return `.claude/skills/${name}`;
22
+ }
23
+
9
24
  async function skillNames(root: string): Promise<string[]> {
10
25
  const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
11
26
  const names: string[] = [];
@@ -25,13 +40,7 @@ async function skillNames(root: string): Promise<string[]> {
25
40
 
26
41
  async function linkTarget(link: string): Promise<string | null> {
27
42
  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);
43
+ return target === null ? null : realLinkTarget(link, target);
35
44
  }
36
45
 
37
46
  async function registeredSkillRoots(
@@ -45,67 +54,143 @@ async function registeredSkillRoots(
45
54
  companionPath ?? "",
46
55
  projection?.projection.companionPath ?? "",
47
56
  ];
48
- return [
49
- ...new Set(
50
- candidates.filter(Boolean).map((candidate) => path.resolve(candidate, CLAUDE_SKILLS_PATH)),
51
- ),
52
- ];
57
+ const roots = await Promise.all(
58
+ candidates
59
+ .filter(Boolean)
60
+ .map((candidate) => realOrResolved(path.resolve(candidate, CLAUDE_SKILLS_PATH))),
61
+ );
62
+ return [...new Set(roots)];
53
63
  }
54
64
 
55
65
  async function createSkillLink(target: string, link: string): Promise<void> {
56
66
  await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
57
67
  }
58
68
 
69
+ const CLAUDE_AGENTS_PATH = path.join(".claude", "agents");
70
+
71
+ /** The manifest key of an agent link: repo-relative, POSIX separators. */
72
+ function agentKey(file: string): string {
73
+ return `.claude/agents/${file}`;
74
+ }
75
+
76
+ async function agentFiles(root: string): Promise<string[]> {
77
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
78
+ return entries
79
+ .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md"))
80
+ .map((entry) => entry.name);
81
+ }
82
+
83
+ /**
84
+ * Every link the wrap wants, by manifest key: the companion's skills, then the
85
+ * wrapped audience's skills and agents. An audience link targets the layer
86
+ * through its `current` link, so a re-rendered layer never strands it. A
87
+ * companion skill wins a name both provide.
88
+ */
89
+ async function wantedLinks(
90
+ currentRoot: string | null,
91
+ layerRoot: string | null,
92
+ ): Promise<Map<string, string>> {
93
+ const wanted = new Map<string, string>();
94
+ if (currentRoot) {
95
+ for (const name of await skillNames(currentRoot)) {
96
+ wanted.set(recordKey(name), path.join(currentRoot, name));
97
+ }
98
+ }
99
+ if (layerRoot) {
100
+ const layerSkills = path.join(layerRoot, "skills");
101
+ for (const name of await skillNames(layerSkills)) {
102
+ if (!wanted.has(recordKey(name))) wanted.set(recordKey(name), path.join(layerSkills, name));
103
+ }
104
+ const layerAgents = path.join(layerRoot, "agents");
105
+ for (const file of await agentFiles(layerAgents)) {
106
+ wanted.set(agentKey(file), path.join(layerAgents, file));
107
+ }
108
+ }
109
+ return wanted;
110
+ }
111
+
112
+ /**
113
+ * Recorded link: Mate's while it resolves to its recorded target. Unrecorded:
114
+ * a skill link is adopted only when it resolves into a registered, current or
115
+ * projected companion; an agent link never is. Records follow the filesystem,
116
+ * so a partial failure re-adopts.
117
+ */
59
118
  export async function reconcileWorkingRepoClaudeSkillLinks(
60
119
  repoPath: string,
61
120
  companionPath: string | null,
62
121
  enabled: boolean,
63
122
  registeredCompanionPaths: string[] = [],
123
+ audienceLayerDir: string | null = null,
64
124
  ): Promise<"written" | "current"> {
125
+ const repoRoot = path.resolve(repoPath);
65
126
  const currentRoot = companionPath ? path.resolve(companionPath, CLAUDE_SKILLS_PATH) : null;
127
+ const layerRoot =
128
+ audienceLayerDir && enabled ? path.join(audienceLayerDir, CURRENT_LAYER_LINK, ".claude") : null;
66
129
  const roots = await registeredSkillRoots(repoPath, companionPath, registeredCompanionPaths);
67
- const targetRoot = path.join(path.resolve(repoPath), CLAUDE_SKILLS_PATH);
68
- const existing = await fs.readdir(targetRoot, { withFileTypes: true }).catch(() => []);
69
- const sourceNames: Set<string> =
70
- currentRoot && enabled ? new Set(await skillNames(currentRoot)) : new Set<string>();
130
+ const recorded = links(await readManifest(repoPath), "claude");
131
+ const wanted = await wantedLinks(enabled ? currentRoot : null, layerRoot);
132
+ const records: Record<string, LinkRecord> = {};
71
133
  let changed = false;
72
134
 
73
- for (const entry of existing) {
74
- if (!entry.isSymbolicLink()) continue;
75
- const link = path.join(targetRoot, entry.name);
76
- if (!(await isManagedSkillLink(link, roots))) continue;
77
-
78
- const target = await linkTarget(link);
79
- if (
80
- currentRoot &&
81
- enabled &&
82
- target === path.join(currentRoot, entry.name) &&
83
- sourceNames.has(entry.name)
84
- ) {
85
- continue;
86
- }
135
+ const linkDirs = [
136
+ { dir: path.join(repoRoot, CLAUDE_SKILLS_PATH), keyOf: recordKey, adoptable: true },
137
+ { dir: path.join(repoRoot, CLAUDE_AGENTS_PATH), keyOf: agentKey, adoptable: false },
138
+ ];
139
+ for (const { dir, keyOf, adoptable } of linkDirs) {
140
+ const existing = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
141
+ for (const entry of existing) {
142
+ if (!entry.isSymbolicLink()) continue;
143
+ const link = path.join(dir, entry.name);
144
+ const target = await linkTarget(link);
145
+ if (target === null) continue;
146
+ const key = keyOf(entry.name);
147
+ const record = recorded[key];
148
+ const owned = record
149
+ ? (await realOrResolved(record.target)) === target
150
+ : adoptable && roots.some((root) => path.dirname(target) === root);
151
+ if (!owned) continue;
87
152
 
88
- await fs.unlink(link);
89
- changed = true;
90
- }
153
+ const want = wanted.get(key);
154
+ if (want && (await realOrResolved(want)) === target) {
155
+ records[key] = { runtime: "claude", target: want };
156
+ continue;
157
+ }
91
158
 
92
- if (currentRoot && enabled && sourceNames.size > 0) {
93
- await fs.mkdir(targetRoot, { recursive: true });
94
- for (const name of sourceNames) {
95
- const link = path.join(targetRoot, name);
96
- const existingLink = await fs.lstat(link).catch(() => null);
97
- if (existingLink) continue;
98
- await createSkillLink(path.join(currentRoot, name), link);
159
+ await fs.unlink(link);
99
160
  changed = true;
100
161
  }
101
162
  }
102
163
 
164
+ for (const [key, target] of wanted) {
165
+ const link = path.join(repoRoot, ...key.split("/"));
166
+ const existingLink = await fs.lstat(link).catch(() => null);
167
+ if (existingLink) continue;
168
+ await fs.mkdir(path.dirname(link), { recursive: true });
169
+ if (key.startsWith(".claude/agents/")) await fs.symlink(target, link, "file");
170
+ else await createSkillLink(target, link);
171
+ records[key] = { runtime: "claude", target };
172
+ changed = true;
173
+ }
174
+
103
175
  if (changed || !enabled) {
104
- await pruneEmptyAncestors(targetRoot, path.resolve(repoPath));
176
+ for (const { dir } of linkDirs) await pruneEmptyAncestors(dir, repoRoot);
177
+ }
178
+ if (JSON.stringify(sortedEntries(records)) !== JSON.stringify(sortedEntries(recorded))) {
179
+ await updateManifest(repoPath, (manifest) => withLinks(manifest, "claude", records));
180
+ changed = true;
105
181
  }
106
182
  return changed ? "written" : "current";
107
183
  }
108
184
 
185
+ function sortedEntries(records: Record<string, LinkRecord>): [string, LinkRecord][] {
186
+ return Object.entries(records)
187
+ .map(([key, record]): [string, LinkRecord] => [
188
+ key,
189
+ { runtime: record.runtime, target: record.target },
190
+ ])
191
+ .toSorted(([a], [b]) => a.localeCompare(b));
192
+ }
193
+
109
194
  export async function removeWorkingRepoClaudeSkillLinks(
110
195
  repoPath: string,
111
196
  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,21 @@ 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";
40
+ import {
41
+ OPENSPEC_POINTER_EXCLUDE,
42
+ OPENSPEC_POINTER_PATH,
43
+ projectStorePointer,
44
+ reconcileStorePointerExclude,
45
+ removeStorePointer,
46
+ removeStorePointerExclude,
47
+ storePointerPresent,
48
+ } from "./projection-openspec-store";
29
49
  import { buildProjection } from "./projection-record";
30
50
  import {
31
51
  CLAUDE_WORKING_SETTINGS_PATH,
@@ -41,16 +61,22 @@ import {
41
61
  CLAUDE_MCP_DOCUMENT,
42
62
  CLAUDE_SETTINGS_DOCUMENT,
43
63
  OPENCODE_CONFIG_DOCUMENT,
64
+ isExternalDocument,
44
65
  placeRuntimeDocument,
45
66
  removeRuntimeDocument,
46
- runtimeDocumentPresent,
67
+ trackedDocumentWarnings,
47
68
  } from "./projection-runtime-documents";
48
69
  import {
49
70
  upsertRepoLocalCompanionPointer,
50
71
  upsertRepoLocalLinkedRepository,
51
72
  writeRepoLocalFrameworkConfig,
52
73
  } from "./repo-local-store";
53
- import type { ProjectionEntry, ProjectionInput } from "./projection-types";
74
+ import type {
75
+ ProjectionEntry,
76
+ ProjectionInput,
77
+ ProjectionWriteReport,
78
+ ProjectionWriteState,
79
+ } from "./projection-types";
54
80
 
55
81
  const WORKSPACE_DOCUMENT_FILE = "workspace.code-workspace";
56
82
 
@@ -68,6 +94,11 @@ async function pathExists(candidatePath: string): Promise<boolean> {
68
94
  }
69
95
  }
70
96
 
97
+ /** Present when Mate recorded regions for it, not merely when the file exists. */
98
+ async function runtimeDocumentRecorded(repoPath: string, documentPath: string): Promise<boolean> {
99
+ return recordedDocuments(await readManifest(repoPath)).includes(documentPath);
100
+ }
101
+
71
102
  async function writeProjectionPairEntry(input: ProjectionInput): Promise<"written" | "current"> {
72
103
  const { companionPath, repository } = input;
73
104
  const resolvedCompanionPath = path.resolve(companionPath!);
@@ -96,6 +127,26 @@ async function writeProjectionPairEntry(input: ProjectionInput): Promise<"writte
96
127
  return "written";
97
128
  }
98
129
 
130
+ /**
131
+ * Places one rendered document and, when this pass wrote Mate's regions into a
132
+ * file inside the repository, asks Git whether it tracks that file. Only a
133
+ * write is worth the question: an unchanged document was reported when it was
134
+ * written, and a removal puts no companion path into it.
135
+ */
136
+ async function writeRuntimeDocumentEntry(
137
+ input: ProjectionInput,
138
+ documentPath: string,
139
+ ): Promise<ProjectionWriteState | ProjectionWriteReport> {
140
+ const state = await placeRuntimeDocument(input.repoPath, documentPath, input.runtimeDocuments);
141
+ const rendered = input.runtimeDocuments?.some((document) => document.path === documentPath);
142
+ if (state !== "written" || !rendered || isExternalDocument(documentPath)) return state;
143
+ const repositoryId = input.repository?.id ?? path.basename(path.resolve(input.repoPath));
144
+ return {
145
+ state,
146
+ warnings: await trackedDocumentWarnings(input.repoPath, documentPath, repositoryId),
147
+ };
148
+ }
149
+
99
150
  function tokensaveEnabled(input: ProjectionInput): boolean {
100
151
  return (input.config?.capabilities ?? []).some((capability) => capability.name === "tokensave");
101
152
  }
@@ -198,16 +249,53 @@ function buildEntries(): readonly ProjectionEntry[] {
198
249
  id: "projection-pair",
199
250
  kind: "owned",
200
251
  path: path.join(localDir, PROJECTION_YAML_FILE),
201
- scopes: ["session"],
252
+ scopes: ["session", "wrap"],
202
253
  write: async (input) =>
203
254
  input.companionPath && input.repository ? writeProjectionPairEntry(input) : "skipped",
204
255
  removal: { by: "entry", entry: "projection-root" },
205
256
  },
257
+ /**
258
+ * The OpenSpec store pointer's exclusion, then the pointer: the line is on
259
+ * disk before the file it hides. Its own line outside the managed block,
260
+ * disjoint from `capability-excludes`, so neither strips the other's.
261
+ */
262
+ {
263
+ id: "openspec-store-exclude",
264
+ kind: "merged",
265
+ path: path.join(".git", "info", "exclude"),
266
+ scopes: ["session", "wrap"],
267
+ write: async (input) =>
268
+ (await reconcileStorePointerExclude(input.repoPath)) ? "written" : "current",
269
+ degradable: true,
270
+ removal: { by: "self", remove: (input) => removeStorePointerExclude(input.repoPath) },
271
+ present: (repoPath) =>
272
+ workingRepoCapabilityExcludesPresent(repoPath, [OPENSPEC_POINTER_EXCLUDE]),
273
+ },
274
+ /**
275
+ * Degradable: without it OpenSpec still plans in the companion, it only
276
+ * names no Working Repository edit root — and the wrapper says so.
277
+ */
278
+ {
279
+ id: "openspec-store-pointer",
280
+ kind: "owned",
281
+ path: OPENSPEC_POINTER_PATH,
282
+ scopes: ["session", "wrap"],
283
+ write: async (input) =>
284
+ input.companionPath ? projectStorePointer(input.repoPath, input.companionPath) : "skipped",
285
+ degradable: true,
286
+ removal: {
287
+ by: "self",
288
+ remove: (input) => removeStorePointer(input.repoPath),
289
+ retains: "openspec-store-registration",
290
+ },
291
+ present: storePointerPresent,
292
+ },
206
293
  {
207
294
  id: "claude-skill-links",
208
295
  kind: "merged",
209
296
  path: path.join(".claude", "skills"),
210
297
  scopes: ["wrap"],
298
+ runtime: "claude",
211
299
  removeOnUnwrap: true,
212
300
  write: async (input) => {
213
301
  if (!input.config) return "skipped";
@@ -219,6 +307,7 @@ function buildEntries(): readonly ProjectionEntry[] {
219
307
  input.companionPath ?? null,
220
308
  input.config.allowedAgents?.includes("claude") ?? false,
221
309
  registeredCompanionPaths,
310
+ input.audienceLayerDir ?? null,
222
311
  );
223
312
  },
224
313
  removal: {
@@ -236,7 +325,7 @@ function buildEntries(): readonly ProjectionEntry[] {
236
325
  id: "capability-excludes",
237
326
  kind: "merged",
238
327
  path: path.join(".git", "info", "exclude"),
239
- scopes: ["launch"],
328
+ scopes: ["launch", "wrap"],
240
329
  write: async (input) => {
241
330
  if (!input.config) return "skipped";
242
331
  return (await reconcileWorkingRepoCapabilityExcludes(
@@ -259,6 +348,7 @@ function buildEntries(): readonly ProjectionEntry[] {
259
348
  kind: "merged",
260
349
  path: CLAUDE_WORKING_SETTINGS_PATH,
261
350
  scopes: ["launch", "wrap"],
351
+ runtime: "claude",
262
352
  write: async (input) =>
263
353
  input.companionPath
264
354
  ? syncWorkingRepoClaudeAdditionalDirectories(
@@ -276,14 +366,14 @@ function buildEntries(): readonly ProjectionEntry[] {
276
366
  },
277
367
  /**
278
368
  * 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.
369
+ * Its `write` is the same strip, so a launch or a wrap heals a repository
370
+ * the installer touched without waiting for a cleanup.
281
371
  */
282
372
  {
283
373
  id: "legacy-tokensave-claude-md",
284
374
  kind: "merged",
285
375
  path: LEGACY_TOKENSAVE_CLAUDE_MD_PATH,
286
- scopes: ["launch"],
376
+ scopes: ["launch", "wrap"],
287
377
  write: async (input) =>
288
378
  (await stripTokensaveClaudeMdAppend(input.repoPath)) === "removed" ? "written" : "current",
289
379
  removal: { by: "self", remove: (input) => stripTokensaveClaudeMdAppend(input.repoPath) },
@@ -336,6 +426,7 @@ function buildEntries(): readonly ProjectionEntry[] {
336
426
  kind: "merged",
337
427
  path: CLAUDE_MCP_DOCUMENT,
338
428
  scopes: ["launch", "wrap"],
429
+ runtime: "claude",
339
430
  write: async (input) =>
340
431
  (await removeRuntimeDocument(input.repoPath, CLAUDE_MCP_DOCUMENT)) === "removed"
341
432
  ? "written"
@@ -344,7 +435,7 @@ function buildEntries(): readonly ProjectionEntry[] {
344
435
  by: "self",
345
436
  remove: (input) => removeRuntimeDocument(input.repoPath, CLAUDE_MCP_DOCUMENT),
346
437
  },
347
- present: (repoPath) => runtimeDocumentPresent(repoPath, CLAUDE_MCP_DOCUMENT),
438
+ present: (repoPath) => runtimeDocumentRecorded(repoPath, CLAUDE_MCP_DOCUMENT),
348
439
  },
349
440
  ...(
350
441
  [
@@ -357,15 +448,37 @@ function buildEntries(): readonly ProjectionEntry[] {
357
448
  kind: "merged" as const,
358
449
  path: documentPath.split("/").join(path.sep),
359
450
  scopes: ["launch", "wrap"] as const,
360
- write: (input: ProjectionInput) =>
361
- placeRuntimeDocument(input.repoPath, documentPath, input.runtimeDocuments),
451
+ runtime: documentRuntime(documentPath),
452
+ write: (input: ProjectionInput) => writeRuntimeDocumentEntry(input, documentPath),
362
453
  removal: {
363
454
  by: "self" as const,
364
455
  remove: (input: { repoPath: string }) =>
365
456
  removeRuntimeDocument(input.repoPath, documentPath),
366
457
  },
367
- present: (repoPath: string) => runtimeDocumentPresent(repoPath, documentPath),
458
+ present: (repoPath: string) => runtimeDocumentRecorded(repoPath, documentPath),
368
459
  })),
460
+ /**
461
+ * After the OpenCode document, so its copies arrive once the document that
462
+ * loads them is in place. A companion that dropped OpenCode has the copies
463
+ * an earlier wrap made withdrawn; the outcome names each copy written or
464
+ * withdrawn and warns about shadowed and locally edited ones.
465
+ */
466
+ {
467
+ id: "opencode-companion-copies",
468
+ kind: "merged",
469
+ path: ".opencode",
470
+ scopes: ["wrap"],
471
+ runtime: "opencode",
472
+ removeOnUnwrap: true,
473
+ write: async (input) => {
474
+ if (!input.config || !input.companionPath) return "skipped";
475
+ return (input.config.allowedAgents?.includes("opencode") ?? false)
476
+ ? reconcileCompanionCopies(input.repoPath, input.companionPath)
477
+ : withdrawCompanionCopies(input.repoPath);
478
+ },
479
+ removal: { by: "self", remove: (input) => removeCompanionCopies(input.repoPath) },
480
+ present: companionCopiesPresent,
481
+ },
369
482
  ];
370
483
  }
371
484