@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,44 +1,38 @@
1
- import crypto from "node:crypto";
1
+ import { execFile } from "node:child_process";
2
2
  import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
-
6
- import { repoLocalDirPath } from "../../runtime/repo-local";
5
+ import { promisify } from "node:util";
6
+
7
+ import { FRAMEWORK_NAME } from "../../framework";
8
+ import { gitEnvironment } from "../../runtime/companion-git";
9
+ import {
10
+ documentRegions,
11
+ readManifest,
12
+ updateManifest,
13
+ withDocument,
14
+ withoutDocuments,
15
+ type ManagedRegion,
16
+ type RenderedRuntimeDocument,
17
+ } from "../../runtime/runtime-document-manifest";
18
+ import { writeAtomic } from "../../runtime/write-atomic";
7
19
  import { pruneEmptyAncestors } from "../../tools/setup/utils";
8
- import type { ManagedRegion, RenderedRuntimeDocument } from "./projection-types";
20
+
21
+ export {
22
+ CLAUDE_LOCAL_CONFIG_DOCUMENT,
23
+ CLAUDE_MCP_DOCUMENT,
24
+ CLAUDE_SETTINGS_DOCUMENT,
25
+ OPENCODE_CONFIG_DOCUMENT,
26
+ } from "../../runtime/runtime-document-manifest";
9
27
 
10
28
  /**
11
29
  * Places the documents an Agent Runtime discovers by its own upward walk from
12
30
  * the current directory. A Runtime Surface renders one as a value; this module
13
31
  * writes it, records the regions it wrote at the Projection Root, and removes
14
32
  * 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.
33
+ * `mate working cleanup` without cleanup holding a list of them. The manifest
34
+ * — format, I/O and queries — belongs to `runtime/runtime-document-manifest`.
40
35
  */
41
- export const CLAUDE_LOCAL_CONFIG_DOCUMENT = "~/.claude.json";
42
36
 
43
37
  /**
44
38
  * A document Mate does not own the surroundings of. Mate's regions are stripped
@@ -50,6 +44,38 @@ export function isExternalDocument(documentPath: string): boolean {
50
44
  return documentPath.startsWith("~/");
51
45
  }
52
46
 
47
+ const execFileAsync = promisify(execFile);
48
+
49
+ /** Whether Git tracks the repo-relative path; any `ls-files` failure reads as untracked. */
50
+ async function gitTracks(repoPath: string, relativePath: string): Promise<boolean> {
51
+ try {
52
+ const { stdout } = await execFileAsync("git", ["ls-files", "-z", "--", relativePath], {
53
+ cwd: path.resolve(repoPath),
54
+ encoding: "utf8",
55
+ env: gitEnvironment(),
56
+ });
57
+ return stdout.split("\0").includes(relativePath);
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Excludes don't hide tracked files, so regions with machine-absolute paths
65
+ * would land in a committed document: warn, still write. Best-effort: any
66
+ * `ls-files` failure reads as "not tracked".
67
+ */
68
+ export async function trackedDocumentWarnings(
69
+ repoPath: string,
70
+ documentPath: string,
71
+ repositoryId: string,
72
+ ): Promise<string[]> {
73
+ if (!(await gitTracks(repoPath, documentPath))) return [];
74
+ return [
75
+ `${documentPath} is tracked by Git in ${repositoryId}; ${FRAMEWORK_NAME} writes machine-absolute companion paths into it, so committing it would push this machine's paths to everyone else on the repository. Untrack it with \`git rm --cached ${documentPath}\` if those paths should stay local.`,
76
+ ];
77
+ }
78
+
53
79
  /**
54
80
  * Injectable so a test never writes to the real `~/.claude.json`. An external
55
81
  * target is the one destination outside a temporary fixture, so it is also the
@@ -59,14 +85,6 @@ export const runtimeDocumentDeps = {
59
85
  homeDir: (): string => os.homedir(),
60
86
  };
61
87
 
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
88
  function documentPathIn(repoPath: string, documentPath: string): string {
71
89
  if (isExternalDocument(documentPath)) {
72
90
  return path.join(runtimeDocumentDeps.homeDir(), ...documentPath.slice("~/".length).split("/"));
@@ -97,26 +115,9 @@ async function readRaw(filePath: string): Promise<string | null> {
97
115
  }
98
116
  }
99
117
 
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
118
  /**
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.
119
+ * Unparseable throws, never reads as empty: writing back a torn
120
+ * `~/.claude.json` would wipe the user's auth and history.
120
121
  */
121
122
  function parseDocument(raw: string | null, target: string): Record<string, unknown> {
122
123
  if (raw === null) return {};
@@ -130,40 +131,6 @@ function parseDocument(raw: string | null, target: string): Record<string, unkno
130
131
  return parsed;
131
132
  }
132
133
 
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
134
  /** The container holding a region's final key, created on demand when writing. */
168
135
  function containerFor(
169
136
  document: Record<string, unknown>,
@@ -185,6 +152,11 @@ function containerFor(
185
152
  return node;
186
153
  }
187
154
 
155
+ function keyPresent(document: Record<string, unknown>, at: string[]): boolean {
156
+ const container = containerFor(document, at, false);
157
+ return container !== null && Object.hasOwn(container, at.at(-1)!);
158
+ }
159
+
188
160
  function applyRegion(document: Record<string, unknown>, region: ManagedRegion): void {
189
161
  const container = containerFor(document, region.at, true)!;
190
162
  const key = region.at.at(-1)!;
@@ -295,19 +267,22 @@ export async function placeRuntimeDocument(
295
267
  const raw = await readRaw(target);
296
268
  const document = parseDocument(raw, target);
297
269
 
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);
270
+ const prior = documentRegions(await readManifest(repoPath), documentPath);
271
+ for (const region of prior ?? []) revertRegion(document, region);
272
+ /** After the revert, a key still present is the human's: a yielding region gives way to it. */
273
+ const applied = rendered.regions.filter(
274
+ (region) => !(region.kind === "value" && region.yields && keyPresent(document, region.at)),
275
+ );
276
+ for (const region of applied) applyRegion(document, region);
301
277
  const next = serialize(document);
302
278
 
303
- const regions = [...rendered.regions] as ManagedRegion[];
304
- const recorded = JSON.stringify(manifest.documents[documentPath]) === JSON.stringify(regions);
279
+ const recorded = JSON.stringify(prior) === JSON.stringify(applied);
305
280
  if (next === raw && recorded) return "current";
306
281
 
282
+ /** The file first: a failed write leaves the manifest naming what is really there. */
307
283
  await fs.mkdir(path.dirname(target), { recursive: true });
308
284
  await writeAtomic(target, next);
309
- manifest.documents[documentPath] = regions;
310
- await writeManifest(repoPath, manifest);
285
+ await updateManifest(repoPath, (manifest) => withDocument(manifest, documentPath, applied));
311
286
  return "written";
312
287
  }
313
288
 
@@ -319,13 +294,11 @@ export async function removeRuntimeDocument(
319
294
  repoPath: string,
320
295
  documentPath: string,
321
296
  ): Promise<"removed" | "absent"> {
322
- const manifest = await readManifest(repoPath);
323
- const regions = manifest.documents[documentPath];
297
+ const regions = documentRegions(await readManifest(repoPath), documentPath);
324
298
  if (!regions) return "absent";
325
299
 
326
300
  await revertDocumentOnDisk(repoPath, documentPath, regions);
327
- delete manifest.documents[documentPath];
328
- await writeManifest(repoPath, manifest);
301
+ await updateManifest(repoPath, (manifest) => withoutDocuments(manifest, [documentPath]));
329
302
  return "removed";
330
303
  }
331
304
 
@@ -358,10 +331,10 @@ async function revertDocumentOnDisk(
358
331
  }
359
332
 
360
333
  /**
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
334
+ * Withdraws several destinations against a single manifest update.
335
+ * `removeRuntimeDocument` per destination cannot be run concurrently — each
336
+ * rewrites the whole manifest, so the last write restores the keys the others
337
+ * removed — and running it sequentially rewrites the manifest once per
365
338
  * document. Reverting the files together and recording the outcome once is both
366
339
  * safe and one write.
367
340
  *
@@ -375,13 +348,13 @@ export async function removeRuntimeDocuments(
375
348
  documentPaths: readonly string[],
376
349
  ): Promise<{ removed: string[]; absent: string[]; error?: { document: string; error: Error } }> {
377
350
  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);
351
+ const recorded = documentPaths.filter((candidate) => documentRegions(manifest, candidate));
352
+ const absent = documentPaths.filter((candidate) => !documentRegions(manifest, candidate));
380
353
  if (recorded.length === 0) return { removed: [], absent };
381
354
 
382
355
  const settled = await Promise.allSettled(
383
356
  recorded.map((documentPath) =>
384
- revertDocumentOnDisk(repoPath, documentPath, manifest.documents[documentPath]!),
357
+ revertDocumentOnDisk(repoPath, documentPath, documentRegions(manifest, documentPath)!),
385
358
  ),
386
359
  );
387
360
 
@@ -396,29 +369,9 @@ export async function removeRuntimeDocuments(
396
369
  };
397
370
  continue;
398
371
  }
399
- delete manifest.documents[documentPath];
400
372
  removed.push(documentPath);
401
373
  }
402
374
 
403
- await writeManifest(repoPath, manifest);
375
+ await updateManifest(repoPath, (current) => withoutDocuments(current, removed));
404
376
  return { removed, absent, ...(failure ? { error: failure } : {}) };
405
377
  }
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
- }
@@ -1,5 +1,6 @@
1
1
  import type { GlobalConfigStore } from "./global-config-store";
2
2
  import type { ProjectionFile } from "../../runtime/projection";
3
+ import type { RenderedRuntimeDocument, WrapRuntime } from "../../runtime/runtime-document-manifest";
3
4
  import type { CompanionSource, FrameworkConfig, LinkedRepository } from "./types";
4
5
 
5
6
  /**
@@ -24,6 +25,8 @@ export type ProjectionEntryId =
24
25
  | "repo-local-framework"
25
26
  | "repo-local-registry"
26
27
  | "projection-pair"
28
+ | "openspec-store-exclude"
29
+ | "openspec-store-pointer"
27
30
  | "claude-skill-links"
28
31
  | "workspace-document"
29
32
  | "capability-excludes"
@@ -32,38 +35,8 @@ export type ProjectionEntryId =
32
35
  | "claude-runtime-document"
33
36
  | "claude-local-mcp-document"
34
37
  | "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
- }
38
+ | "opencode-runtime-document"
39
+ | "opencode-companion-copies";
67
40
 
68
41
  export interface ProjectionInput {
69
42
  repoPath: string;
@@ -75,6 +48,10 @@ export interface ProjectionInput {
75
48
  globalConfigStore?: GlobalConfigStore;
76
49
  /** Rendered by the active Runtime Surfaces; placed by the `wrap` scope's entries. */
77
50
  runtimeDocuments?: readonly RenderedRuntimeDocument[];
51
+ /** Restricts runtime-tagged entries to these runtimes; every runtime when absent. */
52
+ runtimes?: readonly WrapRuntime[];
53
+ /** The layer directory of the audience the checkout is wrapped for; `null` or absent for base only. */
54
+ audienceLayerDir?: string | null;
78
55
  }
79
56
 
80
57
  export interface ProjectionRemovalInput {
@@ -86,6 +63,23 @@ export interface ProjectionRemovalInput {
86
63
  export type ProjectionWriteState = "written" | "current" | "skipped";
87
64
  export type ProjectionRemoveState = "removed" | "absent" | "retained" | "covered";
88
65
 
66
+ /** Repo-relative paths an entry maintaining several files wrote or withdrew. */
67
+ export interface ProjectionChangedPaths {
68
+ written: string[];
69
+ removed: string[];
70
+ }
71
+
72
+ /**
73
+ * What a write may return instead of a bare state, when it has something to
74
+ * tell the operator. The entry reports; it never prints — the caller presents
75
+ * warnings and changed paths without knowing which entry produced them.
76
+ */
77
+ export interface ProjectionWriteReport {
78
+ state: ProjectionWriteState;
79
+ warnings?: string[];
80
+ changed?: ProjectionChangedPaths;
81
+ }
82
+
89
83
  /**
90
84
  * Every entry names its inverse. `self` removes its own path, `entry` is covered
91
85
  * by another entry's removal, `retained` is deliberately left behind and says
@@ -95,6 +89,8 @@ export type ProjectionEntryRemoval =
95
89
  | {
96
90
  readonly by: "self";
97
91
  remove(input: ProjectionRemovalInput): Promise<"removed" | "absent">;
92
+ /** What removing this path deliberately leaves outside the repository, named in the outcome. */
93
+ readonly retains?: string;
98
94
  }
99
95
  | { readonly by: "entry"; readonly entry: ProjectionEntryId }
100
96
  | { readonly by: "retained"; readonly because: string };
@@ -105,8 +101,10 @@ export interface ProjectionEntry {
105
101
  /** Repo-relative, POSIX separators. An entry spanning siblings names its primary. */
106
102
  readonly path: string;
107
103
  readonly scopes: readonly ProjectionScope[];
104
+ /** Set on entries that belong to one Agent Runtime; see {@link ProjectionInput.runtimes}. */
105
+ readonly runtime?: WrapRuntime;
108
106
  /** `skipped` when an input this entry needs was not supplied. */
109
- write(input: ProjectionInput): Promise<ProjectionWriteState>;
107
+ write(input: ProjectionInput): Promise<ProjectionWriteState | ProjectionWriteReport>;
110
108
  /**
111
109
  * Whether a failed write is reported without failing the operation that
112
110
  * triggered the projection. Only for an entry something else already covers:
@@ -132,6 +130,14 @@ export interface ProjectionEntryOutcome {
132
130
  state: ProjectionWriteState | ProjectionRemoveState | "failed";
133
131
  error?: Error;
134
132
  degradable?: boolean;
133
+ /** The entry's runtime tag; absent for runtime-neutral entries. */
134
+ runtime?: WrapRuntime;
135
+ /** Present only when the entry had something to say. */
136
+ warnings?: string[];
137
+ /** Present only when the entry wrote or withdrew paths it reports one by one. */
138
+ changed?: ProjectionChangedPaths;
139
+ /** Present only on a removal that leaves something outside the repository behind. */
140
+ retains?: string;
135
141
  }
136
142
 
137
143
  export interface ProjectionResult {
@@ -159,7 +165,14 @@ export interface ProjectionDescription {
159
165
  projection: ProjectionFile | null;
160
166
  }
161
167
 
162
- /** A degradable entry's failure is reported in the outcomes and nowhere else. */
168
+ /** Skips a degradable entry's failure: it never fails a pass, only shows in the outcomes. */
163
169
  export function firstFailure(outcomes: ProjectionEntryOutcome[]): ProjectionEntryOutcome | null {
164
170
  return outcomes.find((outcome) => outcome.state === "failed" && !outcome.degradable) ?? null;
165
171
  }
172
+
173
+ /** Names the failed entry: "the projection" alone would not say what to fix. */
174
+ export function entryFailureMessage(outcome: ProjectionEntryOutcome, repositoryId: string): string {
175
+ const reason = outcome.error?.message ?? "projection failed";
176
+ const target = outcome.id === "projection-pair" ? "the projection" : outcome.path;
177
+ return `failed to write ${target} for ${repositoryId}: ${reason}`;
178
+ }