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

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 (186) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +2 -2
  2. package/claude-plugin/hooks/hooks.json +3 -6
  3. package/claude-plugin/hooks/session-guidance.mjs +8 -0
  4. package/claude-plugin/hooks/ts-loader.mjs +19 -0
  5. package/package.json +7 -5
  6. package/src/cli/commands/artifact/artifact.ts +19 -3
  7. package/src/cli/commands/artifact/finish/command.ts +183 -57
  8. package/src/cli/commands/artifact/finish/engine.ts +80 -91
  9. package/src/cli/commands/artifact/finish/finisher.ts +26 -33
  10. package/src/cli/commands/artifact/finish/git.ts +34 -23
  11. package/src/cli/commands/artifact/finish/index.ts +9 -3
  12. package/src/cli/commands/artifact/finish/openspec.ts +225 -97
  13. package/src/cli/commands/artifact/pending/command.ts +175 -0
  14. package/src/cli/commands/artifact/pending/discovery.ts +249 -0
  15. package/src/cli/commands/artifact/pending/index.ts +17 -0
  16. package/src/cli/commands/cap/index-cmd.ts +9 -1
  17. package/src/cli/commands/cap/index.ts +2 -6
  18. package/src/cli/commands/cap/tokensave.ts +4 -4
  19. package/src/cli/commands/companion/companion.ts +5 -1
  20. package/src/cli/commands/companion/link.ts +2 -2
  21. package/src/cli/commands/companion/sync.ts +92 -0
  22. package/src/cli/commands/doctor.ts +0 -3
  23. package/src/cli/commands/launch/shared.ts +23 -5
  24. package/src/cli/commands/report/collector.ts +72 -78
  25. package/src/cli/commands/report/contract.ts +40 -1
  26. package/src/cli/commands/report/highlight.ts +27 -0
  27. package/src/cli/commands/report/index.ts +11 -18
  28. package/src/cli/commands/report/renderer.ts +199 -2
  29. package/src/cli/commands/report/types.ts +26 -1
  30. package/src/cli/commands/shared/companion-selection.ts +107 -10
  31. package/src/cli/commands/studio/areas.ts +68 -0
  32. package/src/cli/commands/studio/index.ts +69 -0
  33. package/src/cli/commands/studio/inventory.ts +55 -0
  34. package/src/cli/commands/studio/mate-inventory.ts +43 -0
  35. package/src/cli/commands/studio/openspec-cli.ts +198 -0
  36. package/src/cli/commands/studio/payload.ts +184 -0
  37. package/src/cli/commands/studio/routes.ts +2 -0
  38. package/src/cli/commands/studio/selection.ts +61 -0
  39. package/src/cli/commands/studio/server.ts +201 -0
  40. package/src/cli/commands/studio/snapshot.ts +63 -0
  41. package/src/cli/commands/studio/topology.ts +199 -0
  42. package/src/cli/commands/studio/views/client.ts +197 -0
  43. package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
  44. package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
  45. package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
  46. package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
  47. package/src/cli/commands/studio/views/document.tsx +256 -0
  48. package/src/cli/commands/studio/views/error.tsx +20 -0
  49. package/src/cli/commands/studio/views/model.ts +34 -0
  50. package/src/cli/commands/studio/views/pairings.tsx +38 -0
  51. package/src/cli/commands/studio/views/skills/index.tsx +87 -0
  52. package/src/cli/commands/studio/views/specs/index.tsx +101 -0
  53. package/src/cli/commands/studio/views/styles.ts +389 -0
  54. package/src/cli/commands/studio/views/warnings.tsx +21 -0
  55. package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
  56. package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
  57. package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
  58. package/src/cli/commands/unwrap.ts +70 -0
  59. package/src/cli/commands/wrap.ts +164 -0
  60. package/src/cli/main.ts +68 -19
  61. package/src/cli/parse-flags.ts +36 -11
  62. package/src/cli/usage.ts +12 -3
  63. package/src/framework.ts +1 -7
  64. package/src/hooks/session-banner.ts +64 -11
  65. package/src/hooks/session-guidance.ts +40 -0
  66. package/src/hooks/validate-artifact-path.ts +108 -35
  67. package/src/lib/fs-utils.ts +9 -0
  68. package/src/lib/install.ts +33 -0
  69. package/src/lib/orchestrator/adapters/base.ts +14 -125
  70. package/src/lib/orchestrator/adapters/claude.ts +0 -11
  71. package/src/lib/orchestrator/adapters/opencode.ts +2 -32
  72. package/src/lib/orchestrator/companion-git-sync.ts +94 -84
  73. package/src/lib/orchestrator/config-store.ts +2 -21
  74. package/src/lib/orchestrator/editor.ts +12 -22
  75. package/src/lib/orchestrator/framework-context.ts +17 -6
  76. package/src/lib/orchestrator/global-config-store.ts +1 -1
  77. package/src/lib/orchestrator/launcher.ts +97 -7
  78. package/src/lib/orchestrator/opencode-guidance.ts +4 -56
  79. package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
  80. package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
  81. package/src/lib/orchestrator/projection-companion-link.ts +62 -0
  82. package/src/lib/orchestrator/projection-entries.ts +377 -0
  83. package/src/lib/orchestrator/projection-record.ts +56 -0
  84. package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
  85. package/src/lib/orchestrator/projection-types.ts +169 -0
  86. package/src/lib/orchestrator/repo-local-registry.ts +37 -133
  87. package/src/lib/orchestrator/repo-local-store.ts +96 -0
  88. package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
  89. package/src/lib/orchestrator/types.ts +1 -0
  90. package/src/lib/orchestrator/working-repo-projection.ts +366 -0
  91. package/src/lib/orchestrator/workspace-inventory.ts +1 -1
  92. package/src/lib/package-paths.ts +11 -1
  93. package/src/lib/public-npm.ts +2 -1
  94. package/src/lib/update-checker.ts +15 -9
  95. package/src/opencode/companion-hooks.ts +89 -245
  96. package/src/opencode/companion-policy.ts +35 -10
  97. package/src/opencode/index.ts +1 -0
  98. package/src/opencode/projected-guidance.ts +56 -0
  99. package/src/opencode/tui.tsx +13 -4
  100. package/src/playbooks/companion-guidance.ts +32 -116
  101. package/src/plugins.ts +0 -1
  102. package/src/runtime/companion-git-state.ts +156 -0
  103. package/src/runtime/companion-git.ts +203 -0
  104. package/src/runtime/companion-guidance.ts +222 -0
  105. package/src/runtime/companion-sync.ts +298 -0
  106. package/src/runtime/env-names.ts +30 -0
  107. package/src/runtime/env.ts +67 -35
  108. package/src/runtime/framework.ts +10 -0
  109. package/src/runtime/freshness.ts +58 -0
  110. package/src/runtime/index.ts +104 -0
  111. package/src/runtime/install.ts +30 -0
  112. package/src/runtime/policy.ts +66 -0
  113. package/src/runtime/projected-guidance.ts +45 -0
  114. package/src/runtime/projection.ts +224 -0
  115. package/src/runtime/repo-local.ts +64 -0
  116. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
  117. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
  118. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
  119. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
  120. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
  121. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
  122. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  123. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
  124. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
  125. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
  126. package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
  127. package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
  128. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
  129. package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  130. package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  131. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
  132. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
  133. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
  134. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +156 -0
  135. package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
  136. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
  137. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
  138. package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
  139. package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
  140. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
  141. package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  142. package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  143. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
  144. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
  145. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
  146. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +156 -0
  147. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
  148. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
  149. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
  150. package/src/templates/report-assets/README.md +32 -0
  151. package/src/templates/report-assets/mermaid.LICENSE +21 -0
  152. package/src/templates/report-assets/mermaid.min.js +4376 -0
  153. package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
  154. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
  155. package/src/tools/setup/capabilities/graphify.ts +16 -7
  156. package/src/tools/setup/capabilities/openspec.ts +63 -56
  157. package/src/tools/setup/capabilities/tokensave.ts +116 -2
  158. package/src/tools/setup/engine.ts +34 -6
  159. package/src/tools/setup/mate.ts +42 -13
  160. package/src/tools/setup/plugin.ts +9 -0
  161. package/src/tools/setup/plugins/guidance.ts +11 -1
  162. package/src/tools/setup/providers/claude-format.ts +49 -4
  163. package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
  164. package/src/tools/setup/providers/claude.ts +55 -220
  165. package/src/tools/setup/providers/opencode.ts +41 -14
  166. package/src/tools/setup/runtime-documents.ts +174 -0
  167. package/src/tools/setup/surface-target.ts +50 -0
  168. package/src/tools/setup/working-repo-cleanup.ts +33 -26
  169. package/src/tools/setup/working-repo-local-state.ts +21 -1
  170. package/src/tools/setup.ts +25 -3
  171. package/wrappers/bin/graphify +57 -8
  172. package/wrappers/bin/openspec +50 -3
  173. package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
  174. package/src/cli/commands/cap/headroom.ts +0 -52
  175. package/src/cli/commands/workspace/list.ts +0 -25
  176. package/src/cli/commands/workspace/materialize.ts +0 -46
  177. package/src/cli/commands/workspace/workspace.ts +0 -22
  178. package/src/hooks/artifact-finish-nudge.ts +0 -244
  179. package/src/lib/orchestrator/headroom/proxy.ts +0 -116
  180. package/src/lib/orchestrator/workspace-materialize.ts +0 -80
  181. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
  182. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
  183. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
  184. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
  185. package/src/tools/setup/capabilities/headroom.ts +0 -57
  186. /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
@@ -0,0 +1,424 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { repoLocalDirPath } from "../../runtime/repo-local";
7
+ import { pruneEmptyAncestors } from "../../tools/setup/utils";
8
+ import type { ManagedRegion, RenderedRuntimeDocument } from "./projection-types";
9
+
10
+ /**
11
+ * Places the documents an Agent Runtime discovers by its own upward walk from
12
+ * the current directory. A Runtime Surface renders one as a value; this module
13
+ * writes it, records the regions it wrote at the Projection Root, and removes
14
+ * exactly those again — which is what makes a working-target document swept by
15
+ * `mate working cleanup` without cleanup holding a list of them.
16
+ */
17
+
18
+ const MANIFEST_FILE = "runtime-documents.json";
19
+
20
+ /**
21
+ * The three destinations, repo-relative with POSIX separators. Declared here
22
+ * rather than beside the renderers so the entry catalogue names a destination
23
+ * without reaching into an Agent Runtime's Runtime Surface.
24
+ */
25
+ export const CLAUDE_SETTINGS_DOCUMENT = ".claude/settings.local.json";
26
+ export const CLAUDE_MCP_DOCUMENT = ".mcp.json";
27
+ export const OPENCODE_CONFIG_DOCUMENT = ".opencode/opencode.json";
28
+
29
+ /**
30
+ * The one destination outside the Working Repository. Claude Code has no
31
+ * project file that declares a pre-approved MCP server: `.mcp.json` servers sit
32
+ * "pending approval" until a human accepts them in a session, and the settings
33
+ * document can only enable a server `.mcp.json` already declares. Local scope —
34
+ * `projects[<repo>].mcpServers` in the user's `~/.claude.json`, what
35
+ * `claude mcp add --scope local` writes — is the only channel that is live on
36
+ * first use.
37
+ *
38
+ * Written `~`-prefixed rather than resolved so the manifest recording it stays
39
+ * machine-independent, and so an external target is identifiable as one.
40
+ */
41
+ export const CLAUDE_LOCAL_CONFIG_DOCUMENT = "~/.claude.json";
42
+
43
+ /**
44
+ * A document Mate does not own the surroundings of. Mate's regions are stripped
45
+ * from one exactly as from any other, but it is never deleted when emptied and
46
+ * no directory around it is ever pruned — the file is the user's, and everything
47
+ * else in it belongs to Claude Code.
48
+ */
49
+ export function isExternalDocument(documentPath: string): boolean {
50
+ return documentPath.startsWith("~/");
51
+ }
52
+
53
+ /**
54
+ * Injectable so a test never writes to the real `~/.claude.json`. An external
55
+ * target is the one destination outside a temporary fixture, so it is also the
56
+ * one that has to be overridable for the suite to stay hermetic.
57
+ */
58
+ export const runtimeDocumentDeps = {
59
+ homeDir: (): string => os.homedir(),
60
+ };
61
+
62
+ interface Manifest {
63
+ documents: Record<string, ManagedRegion[]>;
64
+ }
65
+
66
+ function manifestPath(repoPath: string): string {
67
+ return path.join(repoLocalDirPath(repoPath), MANIFEST_FILE);
68
+ }
69
+
70
+ function documentPathIn(repoPath: string, documentPath: string): string {
71
+ if (isExternalDocument(documentPath)) {
72
+ return path.join(runtimeDocumentDeps.homeDir(), ...documentPath.slice("~/".length).split("/"));
73
+ }
74
+ return path.join(path.resolve(repoPath), ...documentPath.split("/"));
75
+ }
76
+
77
+ function isRecord(value: unknown): value is Record<string, unknown> {
78
+ return typeof value === "object" && value !== null && !Array.isArray(value);
79
+ }
80
+
81
+ function serialize(value: unknown): string {
82
+ return `${JSON.stringify(value, null, 2)}\n`;
83
+ }
84
+
85
+ /**
86
+ * `null` means the file is not there, and nothing else does. A read that failed
87
+ * for any other reason — a permission the user revoked, a path that turned out
88
+ * to be a directory — is not an absent document, and answering `null` to one
89
+ * would have the caller write a fresh document over whatever it could not read.
90
+ */
91
+ async function readRaw(filePath: string): Promise<string | null> {
92
+ try {
93
+ return await fs.readFile(filePath, "utf8");
94
+ } catch (error) {
95
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ function parseObject(raw: string | null): Record<string, unknown> {
101
+ if (raw === null) return {};
102
+ try {
103
+ const parsed = JSON.parse(raw) as unknown;
104
+ if (isRecord(parsed)) return parsed;
105
+ } catch {
106
+ /* unparseable — start from an empty object */
107
+ }
108
+ return {};
109
+ }
110
+
111
+ /**
112
+ * The same read for a document Mate does not own, where an unparseable one is
113
+ * refused rather than treated as empty. `parseObject`'s fallback is right for
114
+ * the manifest — Mate wrote it and can rebuild it — and ruinous here: a torn
115
+ * read of `~/.claude.json` would be written back as the handful of keys Mate
116
+ * contributes, taking Claude Code's auth, every project's history and its MCP
117
+ * approvals with it, silently and without a backup. Throwing leaves the file
118
+ * as it was and hands the entry catch in `project()` a failed outcome to
119
+ * report, which is the answer an operator can act on.
120
+ */
121
+ function parseDocument(raw: string | null, target: string): Record<string, unknown> {
122
+ if (raw === null) return {};
123
+ let parsed: unknown;
124
+ try {
125
+ parsed = JSON.parse(raw) as unknown;
126
+ } catch (error) {
127
+ throw new Error(`${target} is not valid JSON; refusing to rewrite it`, { cause: error });
128
+ }
129
+ if (!isRecord(parsed)) throw new Error(`${target} is not a JSON object; refusing to rewrite it`);
130
+ return parsed;
131
+ }
132
+
133
+ /**
134
+ * Writes via a sibling temp file + `fs.rename()` so an interrupted write cannot
135
+ * leave a half-written document behind. A reader racing this one — a Claude
136
+ * Code session holding `~/.claude.json` open — observes the old file or the new
137
+ * one, never a truncated one.
138
+ */
139
+ async function writeAtomic(target: string, contents: string): Promise<void> {
140
+ const tempPath = `${target}.${crypto.randomUUID()}.tmp`;
141
+ try {
142
+ await fs.writeFile(tempPath, contents, "utf8");
143
+ await fs.rename(tempPath, target);
144
+ } catch (error) {
145
+ await fs.rm(tempPath, { force: true }).catch(() => {});
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ async function readManifest(repoPath: string): Promise<Manifest> {
151
+ const parsed = parseObject(await readRaw(manifestPath(repoPath)));
152
+ return {
153
+ documents: isRecord(parsed.documents) ? (parsed.documents as Manifest["documents"]) : {},
154
+ };
155
+ }
156
+
157
+ async function writeManifest(repoPath: string, manifest: Manifest): Promise<void> {
158
+ const target = manifestPath(repoPath);
159
+ if (Object.keys(manifest.documents).length === 0) {
160
+ await fs.unlink(target).catch(() => {});
161
+ return;
162
+ }
163
+ await fs.mkdir(path.dirname(target), { recursive: true });
164
+ await writeAtomic(target, serialize(manifest));
165
+ }
166
+
167
+ /** The container holding a region's final key, created on demand when writing. */
168
+ function containerFor(
169
+ document: Record<string, unknown>,
170
+ at: string[],
171
+ create: boolean,
172
+ ): Record<string, unknown> | null {
173
+ let node = document;
174
+ for (const key of at.slice(0, -1)) {
175
+ const next = node[key];
176
+ if (isRecord(next)) {
177
+ node = next;
178
+ continue;
179
+ }
180
+ if (!create) return null;
181
+ const created: Record<string, unknown> = {};
182
+ node[key] = created;
183
+ node = created;
184
+ }
185
+ return node;
186
+ }
187
+
188
+ function applyRegion(document: Record<string, unknown>, region: ManagedRegion): void {
189
+ const container = containerFor(document, region.at, true)!;
190
+ const key = region.at.at(-1)!;
191
+ if (region.kind === "value") {
192
+ container[key] = region.value;
193
+ return;
194
+ }
195
+ if (region.kind === "map") {
196
+ const found = isRecord(container[key]) ? (container[key] as Record<string, unknown>) : {};
197
+ /** Keys an older Mate wrote here, dropped before this Mate's own are set. */
198
+ const existing = region.stripPrefix
199
+ ? Object.fromEntries(
200
+ Object.entries(found).filter(([name]) => !name.startsWith(region.stripPrefix!)),
201
+ )
202
+ : found;
203
+ container[key] = { ...existing, ...region.entries };
204
+ return;
205
+ }
206
+ const existing = Array.isArray(container[key]) ? (container[key] as unknown[]) : [];
207
+ const seen = new Set(existing.map((entry) => JSON.stringify(entry)));
208
+ container[key] = [
209
+ ...existing,
210
+ ...region.values.filter((value) => !seen.has(JSON.stringify(value))),
211
+ ];
212
+ }
213
+
214
+ /** Removes only what was recorded; a value a human has since changed stays. */
215
+ function revertRegion(document: Record<string, unknown>, region: ManagedRegion): void {
216
+ const container = containerFor(document, region.at, false);
217
+ if (!container) return;
218
+ const key = region.at.at(-1)!;
219
+
220
+ if (region.kind === "value") {
221
+ if (JSON.stringify(container[key]) === JSON.stringify(region.value)) delete container[key];
222
+ } else if (region.kind === "map") {
223
+ const existing = isRecord(container[key])
224
+ ? { ...(container[key] as Record<string, unknown>) }
225
+ : null;
226
+ if (existing) {
227
+ for (const [name, entry] of Object.entries(region.entries)) {
228
+ if (JSON.stringify(existing[name]) === JSON.stringify(entry)) delete existing[name];
229
+ }
230
+ if (Object.keys(existing).length > 0) container[key] = existing;
231
+ else delete container[key];
232
+ }
233
+ } else {
234
+ const existing = Array.isArray(container[key]) ? (container[key] as unknown[]) : null;
235
+ if (existing) {
236
+ const removed = new Set(region.values.map((value) => JSON.stringify(value)));
237
+ const remaining = existing.filter((entry) => !removed.has(JSON.stringify(entry)));
238
+ if (remaining.length > 0) container[key] = remaining;
239
+ else delete container[key];
240
+ }
241
+ }
242
+
243
+ /** An emptied container Mate created is not left behind as `{}`. */
244
+ for (let depth = region.at.length - 1; depth > 0; depth -= 1) {
245
+ const parent = containerFor(document, region.at.slice(0, depth), false);
246
+ const parentKey = region.at[depth - 1];
247
+ const node = parent?.[parentKey];
248
+ if (parent && isRecord(node) && Object.keys(node).length === 0) delete parent[parentKey];
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Writes the document's managed regions into whatever is already there, and
254
+ * records them. An unchanged document is not rewritten, so `current` means
255
+ * untouched — which is what makes a second wrap modify no file.
256
+ *
257
+ * What the last wrap recorded comes back out before this wrap's regions go in,
258
+ * because placing is a reconciliation and not an append. `applyRegion` adds a
259
+ * list value only when it is not already there, so a value that has since
260
+ * changed — a bumped plugin version, a plugin root that moved — would otherwise
261
+ * settle in beside its predecessor, and the predecessor is no longer in the
262
+ * manifest for `removeRuntimeDocument` to reach. Reverting first is value-
263
+ * guarded, so what a human has edited since is not Mate's to take back and
264
+ * survives the round-trip as theirs.
265
+ *
266
+ * A render that no longer produces this destination withdraws it. `document()`
267
+ * drops empty regions and returns nothing when none are left, so a document
268
+ * whose last capability was disabled simply stops being rendered — and without
269
+ * the withdrawal below the entries the previous wrap wrote would stay live
270
+ * forever, a disabled MCP server still declared in the user's `~/.claude.json`.
271
+ * The withdrawal is the same one `unproject` performs, and it reaches only the
272
+ * one destination this call names, so a scope that projects a smaller set of
273
+ * documents can never take back a document it was not asked about.
274
+ */
275
+ export async function placeRuntimeDocument(
276
+ repoPath: string,
277
+ documentPath: string,
278
+ documents?: readonly RenderedRuntimeDocument[],
279
+ ): Promise<"written" | "current" | "skipped"> {
280
+ /**
281
+ * No render at all is not an empty render: a scope that supplies no documents
282
+ * — a launch, which projects this entry without rendering for it — is claiming
283
+ * nothing about this destination and must leave what a wrap recorded alone.
284
+ */
285
+ if (!documents) return "skipped";
286
+
287
+ const rendered = documents.find((candidate) => candidate.path === documentPath);
288
+ if (!rendered) {
289
+ return (await removeRuntimeDocument(repoPath, documentPath)) === "removed"
290
+ ? "written"
291
+ : "current";
292
+ }
293
+
294
+ const target = documentPathIn(repoPath, documentPath);
295
+ const raw = await readRaw(target);
296
+ const document = parseDocument(raw, target);
297
+
298
+ const manifest = await readManifest(repoPath);
299
+ for (const region of manifest.documents[documentPath] ?? []) revertRegion(document, region);
300
+ for (const region of rendered.regions) applyRegion(document, region);
301
+ const next = serialize(document);
302
+
303
+ const regions = [...rendered.regions] as ManagedRegion[];
304
+ const recorded = JSON.stringify(manifest.documents[documentPath]) === JSON.stringify(regions);
305
+ if (next === raw && recorded) return "current";
306
+
307
+ await fs.mkdir(path.dirname(target), { recursive: true });
308
+ await writeAtomic(target, next);
309
+ manifest.documents[documentPath] = regions;
310
+ await writeManifest(repoPath, manifest);
311
+ return "written";
312
+ }
313
+
314
+ /**
315
+ * Strips the recorded regions and leaves the document Mate found; a document
316
+ * left empty is deleted along with any directory Mate created to hold it.
317
+ */
318
+ export async function removeRuntimeDocument(
319
+ repoPath: string,
320
+ documentPath: string,
321
+ ): Promise<"removed" | "absent"> {
322
+ const manifest = await readManifest(repoPath);
323
+ const regions = manifest.documents[documentPath];
324
+ if (!regions) return "absent";
325
+
326
+ await revertDocumentOnDisk(repoPath, documentPath, regions);
327
+ delete manifest.documents[documentPath];
328
+ await writeManifest(repoPath, manifest);
329
+ return "removed";
330
+ }
331
+
332
+ /**
333
+ * One document's regions, taken back out of the file that holds them. Knows
334
+ * nothing of the manifest, which is what lets several destinations be reverted
335
+ * at once: every document is a distinct file, while the manifest recording them
336
+ * all is not.
337
+ */
338
+ async function revertDocumentOnDisk(
339
+ repoPath: string,
340
+ documentPath: string,
341
+ regions: readonly ManagedRegion[],
342
+ ): Promise<void> {
343
+ const target = documentPathIn(repoPath, documentPath);
344
+ const raw = await readRaw(target);
345
+ if (raw === null) return;
346
+
347
+ const document = parseDocument(raw, target);
348
+ for (const region of regions) revertRegion(document, region);
349
+ if (Object.keys(document).length === 0 && !isExternalDocument(documentPath)) {
350
+ await fs.unlink(target);
351
+ const holder = path.dirname(target);
352
+ if (holder !== path.resolve(repoPath)) {
353
+ await pruneEmptyAncestors(holder, path.resolve(repoPath));
354
+ }
355
+ } else {
356
+ await writeAtomic(target, serialize(document));
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Withdraws several destinations against a single read and a single write of the
362
+ * manifest. `removeRuntimeDocument` per destination cannot be run concurrently —
363
+ * each rewrites the whole manifest, so the last write restores the keys the
364
+ * others removed — and running it sequentially rewrites the manifest once per
365
+ * document. Reverting the files together and recording the outcome once is both
366
+ * safe and one write.
367
+ *
368
+ * A document that throws keeps its manifest entry, so the record still names
369
+ * what is left behind, and the destinations that did succeed are still
370
+ * withdrawn. The first error is returned rather than raised: unwrapping reports
371
+ * per document, and a rejected batch would lose which one failed.
372
+ */
373
+ export async function removeRuntimeDocuments(
374
+ repoPath: string,
375
+ documentPaths: readonly string[],
376
+ ): Promise<{ removed: string[]; absent: string[]; error?: { document: string; error: Error } }> {
377
+ const manifest = await readManifest(repoPath);
378
+ const recorded = documentPaths.filter((candidate) => manifest.documents[candidate] !== undefined);
379
+ const absent = documentPaths.filter((candidate) => manifest.documents[candidate] === undefined);
380
+ if (recorded.length === 0) return { removed: [], absent };
381
+
382
+ const settled = await Promise.allSettled(
383
+ recorded.map((documentPath) =>
384
+ revertDocumentOnDisk(repoPath, documentPath, manifest.documents[documentPath]!),
385
+ ),
386
+ );
387
+
388
+ const removed: string[] = [];
389
+ let failure: { document: string; error: Error } | undefined;
390
+ for (const [index, result] of settled.entries()) {
391
+ const documentPath = recorded[index]!;
392
+ if (result.status === "rejected") {
393
+ failure ??= {
394
+ document: documentPath,
395
+ error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
396
+ };
397
+ continue;
398
+ }
399
+ delete manifest.documents[documentPath];
400
+ removed.push(documentPath);
401
+ }
402
+
403
+ await writeManifest(repoPath, manifest);
404
+ return { removed, absent, ...(failure ? { error: failure } : {}) };
405
+ }
406
+
407
+ /**
408
+ * Every destination the manifest records, in the order it recorded them. This
409
+ * is what makes a Working Repository "wrapped": the manifest is written only by
410
+ * a pass that placed a runtime document, and `mate wrap` is the only pass that
411
+ * places one. Reported as data so neither the launch refusal nor `mate unwrap`
412
+ * holds a list of destinations that the entry catalogue could outgrow.
413
+ */
414
+ export async function recordedRuntimeDocuments(repoPath: string): Promise<string[]> {
415
+ return Object.keys((await readManifest(repoPath)).documents);
416
+ }
417
+
418
+ /** Present when Mate recorded regions for it, not merely when the file exists. */
419
+ export async function runtimeDocumentPresent(
420
+ repoPath: string,
421
+ documentPath: string,
422
+ ): Promise<boolean> {
423
+ return (await readManifest(repoPath)).documents[documentPath] !== undefined;
424
+ }
@@ -0,0 +1,169 @@
1
+ import type { GlobalConfigStore } from "./global-config-store";
2
+ import type { ProjectionFile } from "../../runtime/projection";
3
+ import type { CompanionSource, FrameworkConfig, LinkedRepository } from "./types";
4
+
5
+ /**
6
+ * `owned` — Mate created the whole path; removal deletes it. `merged` — Mate
7
+ * maintains a marked region inside a path a human may also author; removal
8
+ * strips exactly that region and rewrites nothing else.
9
+ */
10
+ export type ProjectionEntryKind = "owned" | "merged";
11
+
12
+ /**
13
+ * What the caller is doing, not which files it wants. The entry set cannot be
14
+ * derived from the inputs: recording a Repository Link holds a companion and a
15
+ * repository yet deliberately writes no projection, because wrapping is an
16
+ * explicit act.
17
+ */
18
+ export type ProjectionScope = "link" | "session" | "launch" | "workspace" | "wrap";
19
+
20
+ export type ProjectionEntryId =
21
+ | "git-excludes"
22
+ | "projection-root"
23
+ | "companion-link"
24
+ | "repo-local-framework"
25
+ | "repo-local-registry"
26
+ | "projection-pair"
27
+ | "claude-skill-links"
28
+ | "workspace-document"
29
+ | "capability-excludes"
30
+ | "claude-working-settings"
31
+ | "legacy-tokensave-claude-md"
32
+ | "claude-runtime-document"
33
+ | "claude-local-mcp-document"
34
+ | "mcp-runtime-document"
35
+ | "opencode-runtime-document";
36
+
37
+ /**
38
+ * A region of a runtime document that Mate maintains. JSON carries no comment
39
+ * syntax, so the region is recorded rather than marked in the file: `list`
40
+ * appends values not already there, `map` sets entries by key, `value` sets one
41
+ * key — and removal takes back exactly those, leaving anything a human added.
42
+ *
43
+ * `stripPrefix` is the one exception to "removal takes back exactly what was
44
+ * written": it deletes keys Mate never wrote, and removal does not restore
45
+ * them. It exists for keys an older Mate wrote and a current one must not
46
+ * leave behind, so a re-wrap heals a repository an old version configured.
47
+ */
48
+ export type ManagedRegion =
49
+ | { readonly at: string[]; readonly kind: "list"; readonly values: unknown[] }
50
+ | {
51
+ readonly at: string[];
52
+ readonly kind: "map";
53
+ readonly entries: Record<string, unknown>;
54
+ readonly stripPrefix?: string;
55
+ }
56
+ | { readonly at: string[]; readonly kind: "value"; readonly value: unknown };
57
+
58
+ /**
59
+ * What a Runtime Surface hands the Projection Root: the managed content of one
60
+ * document, without a destination. The surface renders; the entry places.
61
+ */
62
+ export interface RenderedRuntimeDocument {
63
+ /** Repo-relative with POSIX separators, or `~/`-prefixed for a user-global target. */
64
+ readonly path: string;
65
+ readonly regions: readonly ManagedRegion[];
66
+ }
67
+
68
+ export interface ProjectionInput {
69
+ repoPath: string;
70
+ companionPath?: string;
71
+ repository?: LinkedRepository;
72
+ source?: CompanionSource;
73
+ config?: FrameworkConfig;
74
+ /** Which companion paths Mate registered; injectable so fixtures stay hermetic. */
75
+ globalConfigStore?: GlobalConfigStore;
76
+ /** Rendered by the active Runtime Surfaces; placed by the `wrap` scope's entries. */
77
+ runtimeDocuments?: readonly RenderedRuntimeDocument[];
78
+ }
79
+
80
+ export interface ProjectionRemovalInput {
81
+ repoPath: string;
82
+ /** Companion paths Mate registered; only those are Mate's to strip. */
83
+ registeredCompanionPaths?: string[];
84
+ }
85
+
86
+ export type ProjectionWriteState = "written" | "current" | "skipped";
87
+ export type ProjectionRemoveState = "removed" | "absent" | "retained" | "covered";
88
+
89
+ /**
90
+ * Every entry names its inverse. `self` removes its own path, `entry` is covered
91
+ * by another entry's removal, `retained` is deliberately left behind and says
92
+ * why — so a path is never merely absent from removal.
93
+ */
94
+ export type ProjectionEntryRemoval =
95
+ | {
96
+ readonly by: "self";
97
+ remove(input: ProjectionRemovalInput): Promise<"removed" | "absent">;
98
+ }
99
+ | { readonly by: "entry"; readonly entry: ProjectionEntryId }
100
+ | { readonly by: "retained"; readonly because: string };
101
+
102
+ export interface ProjectionEntry {
103
+ readonly id: ProjectionEntryId;
104
+ readonly kind: ProjectionEntryKind;
105
+ /** Repo-relative, POSIX separators. An entry spanning siblings names its primary. */
106
+ readonly path: string;
107
+ readonly scopes: readonly ProjectionScope[];
108
+ /** `skipped` when an input this entry needs was not supplied. */
109
+ write(input: ProjectionInput): Promise<ProjectionWriteState>;
110
+ /**
111
+ * Whether a failed write is reported without failing the operation that
112
+ * triggered the projection. Only for an entry something else already covers:
113
+ * the link is an optimisation over the per-runtime allow-lists, so a platform
114
+ * that permits no link degrades rather than fails.
115
+ */
116
+ readonly degradable?: boolean;
117
+ readonly removal: ProjectionEntryRemoval;
118
+ /** Whether `unwrap` withdraws this entry in addition to recorded documents. */
119
+ readonly removeOnUnwrap?: boolean;
120
+ /**
121
+ * Whether the entry is on disk. Defaults to `path` existing, which is only
122
+ * the truth for an `owned` entry: a `merged` entry shares its host path with
123
+ * the human and must answer for its region alone.
124
+ */
125
+ present?(repoPath: string): Promise<boolean>;
126
+ }
127
+
128
+ export interface ProjectionEntryOutcome {
129
+ id: ProjectionEntryId;
130
+ path: string;
131
+ kind: ProjectionEntryKind;
132
+ state: ProjectionWriteState | ProjectionRemoveState | "failed";
133
+ error?: Error;
134
+ degradable?: boolean;
135
+ }
136
+
137
+ export interface ProjectionResult {
138
+ root: string;
139
+ scope: ProjectionScope;
140
+ outcomes: ProjectionEntryOutcome[];
141
+ }
142
+
143
+ export interface ProjectionRemovalResult {
144
+ root: string;
145
+ outcomes: ProjectionEntryOutcome[];
146
+ }
147
+
148
+ export interface ProjectionEntryPresence {
149
+ id: ProjectionEntryId;
150
+ path: string;
151
+ kind: ProjectionEntryKind;
152
+ present: boolean;
153
+ }
154
+
155
+ /** What `describe` reports. No freshness verdict: that lives above this module. */
156
+ export interface ProjectionDescription {
157
+ root: string;
158
+ entries: ProjectionEntryPresence[];
159
+ projection: ProjectionFile | null;
160
+ }
161
+
162
+ export function anyChanged(outcomes: ProjectionEntryOutcome[]): boolean {
163
+ return outcomes.some((outcome) => outcome.state === "written" || outcome.state === "removed");
164
+ }
165
+
166
+ /** A degradable entry's failure is reported in the outcomes and nowhere else. */
167
+ export function firstFailure(outcomes: ProjectionEntryOutcome[]): ProjectionEntryOutcome | null {
168
+ return outcomes.find((outcome) => outcome.state === "failed" && !outcome.degradable) ?? null;
169
+ }