@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,3 +1,5 @@
1
+ import path from "node:path";
2
+
1
3
  /**
2
4
  * Versioned companion guidance contract. The CLI builds the guidance data
3
5
  * from its playbooks at launch time and delivers it through the
@@ -16,6 +18,12 @@ export type MateGuidanceFile = {
16
18
  companionGuidance: string;
17
19
  codebaseExplorationGuidance: string;
18
20
  errors: string[];
21
+ /**
22
+ * The active audience's layer, for a launch that hosts its own server only.
23
+ * The plugin applies it only from a payload it trusts; a masked payload
24
+ * carries none, so the shared service and bare sessions never see one.
25
+ */
26
+ audienceLayer?: string;
19
27
  };
20
28
 
21
29
  export type GuidanceValidation = {
@@ -76,12 +84,21 @@ export function validateGuidanceData(value: unknown): GuidanceValidation {
76
84
  errors.push("injected codebase exploration guidance is malformed");
77
85
  }
78
86
 
87
+ const audienceLayer =
88
+ typeof value.audienceLayer === "string" && path.isAbsolute(value.audienceLayer)
89
+ ? value.audienceLayer
90
+ : undefined;
91
+ if (value.audienceLayer !== undefined && audienceLayer === undefined) {
92
+ errors.push("injected audience layer is not an absolute path");
93
+ }
94
+
79
95
  return {
80
96
  guidance: {
81
97
  version: GUIDANCE_FILE_VERSION,
82
98
  companionGuidance,
83
99
  codebaseExplorationGuidance,
84
100
  errors,
101
+ ...(audienceLayer ? { audienceLayer } : {}),
85
102
  },
86
103
  errors,
87
104
  };
@@ -0,0 +1,176 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { layerGuidanceText } from "./audience-layer";
5
+ import { OPENCODE_MCP_SERVERS_AT } from "./runtime-document-manifest";
6
+
7
+ /**
8
+ * Reads an audience layer the OpenCode Runtime Surface wrote, as what the Mate
9
+ * OpenCode plugin applies through the plugin API. The surface re-exports it as
10
+ * its own reader; it lives under `runtime/` only so the import-isolated plugin
11
+ * can reach it. The plugin parses no OpenCode document itself.
12
+ */
13
+
14
+ export interface LayerMcpServer {
15
+ name: string;
16
+ /** The entry exactly as the surface wrote it under `mcp.servers`. */
17
+ config: Record<string, unknown>;
18
+ }
19
+
20
+ export interface LayerSkill {
21
+ id: string;
22
+ name: string;
23
+ description?: string;
24
+ /** The skill's `SKILL.md`. */
25
+ path: string;
26
+ content: string;
27
+ }
28
+
29
+ export type LayerAgentMode = "subagent" | "primary" | "all";
30
+
31
+ export interface LayerAgent {
32
+ id: string;
33
+ description?: string;
34
+ mode: LayerAgentMode;
35
+ /** The agent's prompt: the definition's body. */
36
+ system: string;
37
+ }
38
+
39
+ export interface OpenCodeLayerContents {
40
+ mcpServers: LayerMcpServer[];
41
+ skills: LayerSkill[];
42
+ agents: LayerAgent[];
43
+ /** The layer's guidance sections, managed-block markers removed. */
44
+ guidance: string;
45
+ }
46
+
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ }
50
+
51
+ function splitFrontmatter(content: string): { fields: string; body: string } {
52
+ const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
53
+ return match
54
+ ? { fields: match[1]!, body: content.slice(match[0].length) }
55
+ : { fields: "", body: content };
56
+ }
57
+
58
+ function frontmatterScalarFromFields(fields: string, key: string): string | undefined {
59
+ const value = fields.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim();
60
+ return value?.replace(
61
+ /^(?:"([\s\S]*)"|'([\s\S]*)')$/,
62
+ (_all, doubleQuoted?: string, singleQuoted?: string) => doubleQuoted ?? singleQuoted ?? "",
63
+ );
64
+ }
65
+
66
+ /** One scalar of a simple YAML frontmatter, quotes removed. */
67
+ export function frontmatterScalar(content: string, key: string): string | undefined {
68
+ return frontmatterScalarFromFields(splitFrontmatter(content).fields, key);
69
+ }
70
+
71
+ /** An id OpenCode accepts for a skill or agent name. */
72
+ export function contributionId(name: string, fallback: string): string {
73
+ return (
74
+ name
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9_-]+/g, "-")
77
+ .replace(/^-+|-+$/g, "") || fallback
78
+ );
79
+ }
80
+
81
+ async function findSkillFiles(directory: string): Promise<string[]> {
82
+ try {
83
+ const entries = await fs.readdir(directory, { withFileTypes: true });
84
+ const nested = await Promise.all(
85
+ entries.map((entry) => {
86
+ const entryPath = path.join(directory, entry.name);
87
+ if (entry.isFile() && entry.name === "SKILL.md") return Promise.resolve([entryPath]);
88
+ return entry.isDirectory() ? findSkillFiles(entryPath) : Promise.resolve([]);
89
+ }),
90
+ );
91
+ return nested.flat().toSorted();
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ /** Every `SKILL.md` under `roots`; an unreadable skill is skipped, never fatal. */
98
+ export async function loadSkillInfos(roots: readonly string[]): Promise<LayerSkill[]> {
99
+ const files = (await Promise.all(roots.map(findSkillFiles))).flat();
100
+ const skills = await Promise.all(
101
+ files.map(async (filePath): Promise<LayerSkill | null> => {
102
+ try {
103
+ const content = await fs.readFile(filePath, "utf8");
104
+ const { fields } = splitFrontmatter(content);
105
+ const name =
106
+ frontmatterScalarFromFields(fields, "name") || path.basename(path.dirname(filePath));
107
+ const description = frontmatterScalarFromFields(fields, "description");
108
+ return {
109
+ id: contributionId(name, "mate-skill"),
110
+ name,
111
+ ...(description ? { description } : {}),
112
+ path: filePath,
113
+ content,
114
+ };
115
+ } catch {
116
+ return null;
117
+ }
118
+ }),
119
+ );
120
+ return skills.filter((skill) => skill !== null);
121
+ }
122
+
123
+ async function readMcpServers(root: string): Promise<LayerMcpServer[]> {
124
+ const parsed = await fs
125
+ .readFile(path.join(root, ".opencode", "opencode.json"), "utf8")
126
+ .then((text) => JSON.parse(text) as unknown)
127
+ .catch(() => null);
128
+ let servers: unknown = parsed;
129
+ for (const key of OPENCODE_MCP_SERVERS_AT) servers = isRecord(servers) ? servers[key] : undefined;
130
+ if (!isRecord(servers)) return [];
131
+ return Object.entries(servers)
132
+ .filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]))
133
+ .map(([name, config]) => ({ name, config }))
134
+ .toSorted((left, right) => left.name.localeCompare(right.name));
135
+ }
136
+
137
+ function agentMode(value: string | undefined): LayerAgentMode {
138
+ return value === "subagent" || value === "primary" ? value : "all";
139
+ }
140
+
141
+ async function readAgents(root: string): Promise<LayerAgent[]> {
142
+ const dir = path.join(root, ".opencode", "agents");
143
+ const names = await fs.readdir(dir).catch(() => [] as string[]);
144
+ const agents = await Promise.all(
145
+ names
146
+ .filter((name) => name.endsWith(".md"))
147
+ .toSorted()
148
+ .map(async (name): Promise<LayerAgent | null> => {
149
+ const content = await fs.readFile(path.join(dir, name), "utf8").catch(() => null);
150
+ if (content === null) return null;
151
+ const { fields, body } = splitFrontmatter(content);
152
+ const description = frontmatterScalarFromFields(fields, "description");
153
+ return {
154
+ id: contributionId(path.basename(name, ".md"), "mate-agent"),
155
+ ...(description ? { description } : {}),
156
+ mode: agentMode(frontmatterScalarFromFields(fields, "mode")),
157
+ system: body.trim(),
158
+ };
159
+ }),
160
+ );
161
+ return agents.filter((agent) => agent !== null);
162
+ }
163
+
164
+ async function readGuidance(root: string): Promise<string> {
165
+ return layerGuidanceText(await fs.readFile(path.join(root, "AGENTS.md"), "utf8").catch(() => ""));
166
+ }
167
+
168
+ export async function readOpenCodeAudienceLayer(root: string): Promise<OpenCodeLayerContents> {
169
+ const [mcpServers, skills, agents, guidance] = await Promise.all([
170
+ readMcpServers(root),
171
+ loadSkillInfos([path.join(root, ".agents", "skills")]),
172
+ readAgents(root),
173
+ readGuidance(root),
174
+ ]);
175
+ return { mcpServers, skills, agents, guidance };
176
+ }
@@ -40,6 +40,17 @@ function capabilityNames(value: unknown): string[] {
40
40
  .filter((name): name is string => typeof name === "string");
41
41
  }
42
42
 
43
+ /**
44
+ * A base-set key from `audiences.base`, the shape a companion is saved in. The
45
+ * lenient twin of the setup normalizer, which `runtime/` cannot import.
46
+ */
47
+ function baseAudienceKey(config: Record<string, unknown>, key: string): unknown {
48
+ const audiences = config.audiences;
49
+ if (!audiences || typeof audiences !== "object") return undefined;
50
+ const base = (audiences as Record<string, unknown>).base;
51
+ return base && typeof base === "object" ? (base as Record<string, unknown>)[key] : undefined;
52
+ }
53
+
43
54
  /**
44
55
  * Synchronous so it composes into `readCompanionRuntimeContext`. A missing or
45
56
  * unparseable companion config yields an inert policy rather than throwing.
@@ -56,7 +67,9 @@ export function readCompanionPolicy(companionPath: string): CompanionPolicy {
56
67
  const config = parsed as Record<string, unknown>;
57
68
  return {
58
69
  allowedAgents: stringList(config.allowedAgents),
59
- enabledCapabilities: capabilityNames(config.capabilities),
70
+ enabledCapabilities: capabilityNames(
71
+ config.capabilities ?? baseAudienceKey(config, "capabilities"),
72
+ ),
60
73
  gitAutoMode: config.git === "auto",
61
74
  };
62
75
  }
@@ -88,7 +88,7 @@ export function projectionEnvPath(repoRoot: string): string {
88
88
  return path.join(repoLocalDirPath(repoRoot), PROJECTION_ENV_FILE);
89
89
  }
90
90
 
91
- const generatedHeader = () => `# generated by ${FRAMEWORK_NAME} — do not edit`;
91
+ export const generatedHeader = (): string => `# generated by ${FRAMEWORK_NAME} — do not edit`;
92
92
 
93
93
  export function renderProjectionYaml(file: ProjectionFile): string {
94
94
  const document: Record<string, string> = { stamp: file.stamp };
@@ -0,0 +1,43 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ import { FRAMEWORK_NAME } from "./framework";
5
+
6
+ /**
7
+ * Stable, install-independent paths to the installed Mate runtime assets.
8
+ * Documents written into a Working Repository name these, never the
9
+ * version-bound install path, so a reinstall or Node switch is repaired by the
10
+ * next `mate` run instead of a re-wrap.
11
+ */
12
+
13
+ /** `HOME` first, matching the companion Git state: an OpenCode service inherits the user's. */
14
+ export function mateHome(homeDir?: string): string {
15
+ return path.join(homeDir || process.env.HOME || os.homedir(), `.${FRAMEWORK_NAME}`);
16
+ }
17
+
18
+ export function runtimeDirectoryPath(homeDir?: string): string {
19
+ return path.join(mateHome(homeDir), "runtime");
20
+ }
21
+
22
+ export function runtimeOpenCodePluginRoot(homeDir?: string): string {
23
+ return path.join(runtimeDirectoryPath(homeDir), "opencode-plugin");
24
+ }
25
+
26
+ export function runtimeClaudePluginRoot(homeDir?: string): string {
27
+ return path.join(runtimeDirectoryPath(homeDir), "claude-plugin");
28
+ }
29
+
30
+ export function runtimeWrapperBinPath(homeDir?: string): string {
31
+ return path.join(runtimeDirectoryPath(homeDir), "wrappers", "bin");
32
+ }
33
+
34
+ export function runtimeMateBin(
35
+ homeDir?: string,
36
+ platform: NodeJS.Platform = process.platform,
37
+ ): string {
38
+ return path.join(
39
+ runtimeDirectoryPath(homeDir),
40
+ "bin",
41
+ platform === "win32" ? `${FRAMEWORK_NAME}.cmd` : FRAMEWORK_NAME,
42
+ );
43
+ }
@@ -0,0 +1,352 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { repoLocalDirPath } from "./repo-local";
5
+ import { writeAtomic } from "./write-atomic";
6
+
7
+ /**
8
+ * Runtime document manifest at the Projection Root; sole owner of its format.
9
+ * Pure queries plus a thin I/O shell; readers go through Wrap State (`./wrap-state`).
10
+ */
11
+
12
+ const RUNTIME_DOCUMENT_MANIFEST_FILE = "runtime-documents.json";
13
+
14
+ /** Destinations, repo-relative POSIX. Here so the plugin needn't load the projection owner. */
15
+ export const CLAUDE_SETTINGS_DOCUMENT = ".claude/settings.local.json";
16
+ export const CLAUDE_MCP_DOCUMENT = ".mcp.json";
17
+ export const OPENCODE_CONFIG_DOCUMENT = ".opencode/opencode.json";
18
+
19
+ /**
20
+ * Only destination outside the repo: local-scope `projects[<repo>].mcpServers`
21
+ * is the sole channel live on first use (`.mcp.json` servers await approval).
22
+ * `~`-prefixed so the manifest stays machine-independent.
23
+ */
24
+ export const CLAUDE_LOCAL_CONFIG_DOCUMENT = "~/.claude.json";
25
+
26
+ /** Where in {@link OPENCODE_CONFIG_DOCUMENT} the wrap renders its MCP servers. */
27
+ export const OPENCODE_MCP_SERVERS_AT: readonly string[] = Object.freeze(["mcp", "servers"]);
28
+
29
+ /** The Agent Runtimes a wrap places documents or copies for. */
30
+ export type WrapRuntime = "claude" | "opencode";
31
+
32
+ /**
33
+ * A Mate-maintained region, recorded (JSON has no comments): `list` appends,
34
+ * `map` sets by key, `value` sets one key; removal takes back exactly those.
35
+ * `stripPrefix` also deletes matching keys Mate didn't write, never restored.
36
+ * A yielding `value` gives way to an existing key at placement. Only applied
37
+ * regions are recorded.
38
+ */
39
+ export type ManagedRegion =
40
+ | { readonly at: string[]; readonly kind: "list"; readonly values: unknown[] }
41
+ | {
42
+ readonly at: string[];
43
+ readonly kind: "map";
44
+ readonly entries: Record<string, unknown>;
45
+ readonly stripPrefix?: string;
46
+ }
47
+ | {
48
+ readonly at: string[];
49
+ readonly kind: "value";
50
+ readonly value: unknown;
51
+ /** Applied only when the key is absent once the prior pass is withdrawn. */
52
+ readonly yields?: true;
53
+ };
54
+
55
+ /**
56
+ * What a Runtime Surface hands the Projection Root: the managed content of one
57
+ * document, without a destination. The surface renders; the entry places.
58
+ */
59
+ export interface RenderedRuntimeDocument {
60
+ /** Repo-relative with POSIX separators, or `~/`-prefixed for a user-global target. */
61
+ readonly path: string;
62
+ readonly regions: readonly ManagedRegion[];
63
+ }
64
+
65
+ /** An owned companion copy: the hash of what Mate last wrote there. */
66
+ export interface CopyRecord {
67
+ runtime: WrapRuntime;
68
+ sha256: string;
69
+ }
70
+
71
+ /** A link Mate created; `target` is absolute, so a repointed link no longer matches. */
72
+ export interface LinkRecord {
73
+ runtime: WrapRuntime;
74
+ target: string;
75
+ }
76
+
77
+ /** `runtimes` sits beside `documents` so `documents` keeps its region-array shape. */
78
+ export interface Manifest {
79
+ readonly documents: Readonly<Record<string, ManagedRegion[]>>;
80
+ readonly runtimes: Readonly<Record<string, WrapRuntime>>;
81
+ readonly copies: Readonly<Record<string, CopyRecord>>;
82
+ readonly links: Readonly<Record<string, LinkRecord>>;
83
+ /** The audience `mate wrap --audience` recorded; the only source of a bare session's audience. */
84
+ readonly audience?: string;
85
+ }
86
+
87
+ export const EMPTY_MANIFEST: Manifest = Object.freeze({
88
+ documents: Object.freeze({}),
89
+ runtimes: Object.freeze({}),
90
+ copies: Object.freeze({}),
91
+ links: Object.freeze({}),
92
+ });
93
+
94
+ /** Legacy manifests carry no tag; every destination Mate ever wrote is runtime-specific by path. */
95
+ export function documentRuntime(documentPath: string): WrapRuntime {
96
+ return documentPath.startsWith(".opencode/") ? "opencode" : "claude";
97
+ }
98
+
99
+ function isRecord(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === "object" && value !== null && !Array.isArray(value);
101
+ }
102
+
103
+ /** Never throws: an absent, unparseable or foreign manifest reads as empty — Mate can rebuild it. */
104
+ export function parseManifest(raw: string | null): Manifest {
105
+ if (raw === null) return EMPTY_MANIFEST;
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(raw) as unknown;
109
+ } catch {
110
+ return EMPTY_MANIFEST;
111
+ }
112
+ return isRecord(parsed) ? manifestOf(parsed) : EMPTY_MANIFEST;
113
+ }
114
+
115
+ /** For readers that rewrite on its strength: a manifest present but unparseable throws. */
116
+ export function parseManifestStrict(raw: string | null): Manifest {
117
+ if (raw === null) return EMPTY_MANIFEST;
118
+ const parsed = JSON.parse(raw) as unknown;
119
+ if (!isRecord(parsed)) throw new Error("runtime document manifest is not a JSON object");
120
+ return manifestOf(parsed);
121
+ }
122
+
123
+ function manifestOf(parsed: Record<string, unknown>): Manifest {
124
+ return {
125
+ documents: isRecord(parsed.documents) ? (parsed.documents as Manifest["documents"]) : {},
126
+ runtimes: isRecord(parsed.runtimes) ? (parsed.runtimes as Manifest["runtimes"]) : {},
127
+ copies: isRecord(parsed.copies) ? (parsed.copies as Manifest["copies"]) : {},
128
+ links: isRecord(parsed.links) ? (parsed.links as Manifest["links"]) : {},
129
+ ...(typeof parsed.audience === "string" && parsed.audience
130
+ ? { audience: parsed.audience }
131
+ : {}),
132
+ };
133
+ }
134
+
135
+ /** The audience the checkout is wrapped for; `null` for base only. */
136
+ export function recordedAudience(manifest: Manifest): string | null {
137
+ return manifest.audience ?? null;
138
+ }
139
+
140
+ /** Records the checkout's audience, or none with `null`. */
141
+ export function withAudience(manifest: Manifest, audience: string | null): Manifest {
142
+ const { audience: _previous, ...rest } = manifest;
143
+ return audience ? { ...rest, audience } : rest;
144
+ }
145
+
146
+ function recordedRuntime(manifest: Manifest, documentPath: string): WrapRuntime {
147
+ return manifest.runtimes[documentPath] ?? documentRuntime(documentPath);
148
+ }
149
+
150
+ /**
151
+ * `null` means the manifest records nothing and its file should not exist.
152
+ * `runtimes` is rebuilt from `documents`, so a legacy manifest is tagged on its
153
+ * next write and a withdrawn document leaves no stale tag behind.
154
+ */
155
+ export function serializeManifest(manifest: Manifest): string | null {
156
+ const recorded = Object.keys(manifest.documents);
157
+ const copied = Object.keys(manifest.copies);
158
+ const linked = Object.keys(manifest.links);
159
+ if (recorded.length === 0 && copied.length === 0 && linked.length === 0) return null;
160
+ const runtimes = Object.fromEntries(
161
+ recorded.map((documentPath) => [documentPath, recordedRuntime(manifest, documentPath)]),
162
+ );
163
+ return `${JSON.stringify(
164
+ {
165
+ documents: manifest.documents,
166
+ ...(recorded.length > 0 ? { runtimes } : {}),
167
+ ...(copied.length > 0 ? { copies: manifest.copies } : {}),
168
+ ...(linked.length > 0 ? { links: manifest.links } : {}),
169
+ ...(manifest.audience ? { audience: manifest.audience } : {}),
170
+ },
171
+ null,
172
+ 2,
173
+ )}\n`;
174
+ }
175
+
176
+ /**
177
+ * Every destination the manifest records, in the order it recorded them,
178
+ * optionally only one runtime's. The manifest is written only by a pass that
179
+ * placed a runtime document or a copy, and `mate wrap` is the only such pass.
180
+ */
181
+ export function documents(manifest: Manifest, runtime?: WrapRuntime): string[] {
182
+ return Object.keys(manifest.documents).filter(
183
+ (documentPath) => !runtime || recordedRuntime(manifest, documentPath) === runtime,
184
+ );
185
+ }
186
+
187
+ /** One destination's recorded regions; `undefined` when unrecorded or malformed. */
188
+ export function documentRegions(
189
+ manifest: Manifest,
190
+ documentPath: string,
191
+ ): readonly ManagedRegion[] | undefined {
192
+ const recorded: unknown = manifest.documents[documentPath];
193
+ return Array.isArray(recorded) ? (recorded as ManagedRegion[]) : undefined;
194
+ }
195
+
196
+ /** Owned companion copies, keyed by repo-relative POSIX path. */
197
+ export function copies(manifest: Manifest, runtime?: WrapRuntime): Record<string, CopyRecord> {
198
+ return Object.fromEntries(
199
+ Object.entries(manifest.copies).filter(
200
+ ([, record]) => !runtime || (isRecord(record) && record.runtime === runtime),
201
+ ),
202
+ );
203
+ }
204
+
205
+ /** Links Mate created, keyed by repo-relative POSIX path. */
206
+ export function links(manifest: Manifest, runtime?: WrapRuntime): Record<string, LinkRecord> {
207
+ return Object.fromEntries(
208
+ Object.entries(manifest.links).filter(
209
+ ([, record]) =>
210
+ isRecord(record) &&
211
+ typeof record.target === "string" &&
212
+ (!runtime || record.runtime === runtime),
213
+ ),
214
+ );
215
+ }
216
+
217
+ function isAt(at: unknown, address: readonly string[]): boolean {
218
+ return (
219
+ Array.isArray(at) &&
220
+ at.length === address.length &&
221
+ at.every((key, index) => key === address[index])
222
+ );
223
+ }
224
+
225
+ /**
226
+ * The keys of the `map` regions recorded at `at` in one document. Shapes are
227
+ * checked rather than trusted: a hand-edited manifest reads as no keys, never
228
+ * as a throw.
229
+ */
230
+ export function recordedMapKeys(
231
+ manifest: Manifest,
232
+ documentPath: string,
233
+ at: readonly string[],
234
+ ): string[] {
235
+ return (documentRegions(manifest, documentPath) ?? []).flatMap((region: unknown) =>
236
+ isRecord(region) && region.kind === "map" && isAt(region.at, at) && isRecord(region.entries)
237
+ ? Object.keys(region.entries)
238
+ : [],
239
+ );
240
+ }
241
+
242
+ /** Every value of the `list` regions recorded under `prefix` in one document. */
243
+ export function recordedListValues(
244
+ manifest: Manifest,
245
+ documentPath: string,
246
+ prefix: readonly string[],
247
+ ): unknown[] {
248
+ return (documentRegions(manifest, documentPath) ?? []).flatMap((region: unknown) =>
249
+ isRecord(region) &&
250
+ region.kind === "list" &&
251
+ Array.isArray(region.at) &&
252
+ isAt(region.at.slice(0, prefix.length), prefix) &&
253
+ Array.isArray(region.values)
254
+ ? region.values
255
+ : [],
256
+ );
257
+ }
258
+
259
+ /** Records a destination's regions, or withdraws it when `regions` is undefined. */
260
+ export function withDocument(
261
+ manifest: Manifest,
262
+ documentPath: string,
263
+ regions: readonly ManagedRegion[] | undefined,
264
+ ): Manifest {
265
+ if (regions === undefined) return withoutDocuments(manifest, [documentPath]);
266
+ return {
267
+ ...manifest,
268
+ documents: { ...manifest.documents, [documentPath]: [...regions] },
269
+ };
270
+ }
271
+
272
+ export function withoutDocuments(manifest: Manifest, documentPaths: readonly string[]): Manifest {
273
+ const removed = new Set(documentPaths);
274
+ return {
275
+ ...manifest,
276
+ documents: Object.fromEntries(
277
+ Object.entries(manifest.documents).filter(([documentPath]) => !removed.has(documentPath)),
278
+ ),
279
+ };
280
+ }
281
+
282
+ /** Replaces one runtime's records, leaving every other record as it is. */
283
+ function replaceRuntimeRecords<T>(
284
+ existing: Readonly<Record<string, T>>,
285
+ runtime: WrapRuntime,
286
+ records: Readonly<Record<string, T>>,
287
+ ): Record<string, T> {
288
+ const kept = Object.entries(existing).filter(
289
+ ([, record]) => !(isRecord(record) && record.runtime === runtime),
290
+ );
291
+ return { ...Object.fromEntries(kept), ...records };
292
+ }
293
+
294
+ export function withCopies(
295
+ manifest: Manifest,
296
+ runtime: WrapRuntime,
297
+ records: Readonly<Record<string, CopyRecord>>,
298
+ ): Manifest {
299
+ return { ...manifest, copies: replaceRuntimeRecords(manifest.copies, runtime, records) };
300
+ }
301
+
302
+ export function withLinks(
303
+ manifest: Manifest,
304
+ runtime: WrapRuntime,
305
+ records: Readonly<Record<string, LinkRecord>>,
306
+ ): Manifest {
307
+ return { ...manifest, links: replaceRuntimeRecords(manifest.links, runtime, records) };
308
+ }
309
+
310
+ export function manifestPath(repoPath: string): string {
311
+ return path.join(repoLocalDirPath(repoPath), RUNTIME_DOCUMENT_MANIFEST_FILE);
312
+ }
313
+
314
+ /**
315
+ * The runtime document manifest at the Projection Root; empty when absent. A
316
+ * read that failed for a reason other than absence throws, so a writer never
317
+ * replaces a manifest it could not read.
318
+ */
319
+ export async function readManifest(repoPath: string): Promise<Manifest> {
320
+ return parseManifest(await readManifestText(repoPath));
321
+ }
322
+
323
+ /** `null` when absent; any other read failure throws. */
324
+ export async function readManifestText(repoPath: string): Promise<string | null> {
325
+ try {
326
+ return await fsp.readFile(manifestPath(repoPath), "utf8");
327
+ } catch (error) {
328
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
329
+ return null;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * The one way the manifest changes: read, edit, then write it atomically — or
335
+ * delete it once it records nothing. Every writer goes through here, so none
336
+ * can drop another's records.
337
+ */
338
+ export async function updateManifest(
339
+ repoPath: string,
340
+ edit: (manifest: Manifest) => Manifest,
341
+ ): Promise<Manifest> {
342
+ const next = edit(await readManifest(repoPath));
343
+ const target = manifestPath(repoPath);
344
+ const contents = serializeManifest(next);
345
+ if (contents === null) {
346
+ await fsp.unlink(target).catch(() => {});
347
+ } else {
348
+ await fsp.mkdir(path.dirname(target), { recursive: true });
349
+ await writeAtomic(target, contents);
350
+ }
351
+ return next;
352
+ }