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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/claude-plugin/hooks/hooks.json +10 -1
  2. package/claude-plugin/hooks/session-banner.mjs +4 -3
  3. package/claude-plugin/hooks/session-prepare.mjs +9 -0
  4. package/opencode-plugin/src/companion.ts +47 -12
  5. package/opencode-plugin/src/server.ts +39 -4
  6. package/package.json +3 -3
  7. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  8. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  9. package/src/cli/commands/companion/link.ts +16 -11
  10. package/src/cli/commands/companion/list.ts +4 -5
  11. package/src/cli/commands/companion/open.ts +4 -5
  12. package/src/cli/commands/companion/tui.ts +1 -1
  13. package/src/cli/commands/doctor.ts +1 -1
  14. package/src/cli/commands/report/renderer.ts +9 -19
  15. package/src/cli/commands/session/prepare.ts +119 -0
  16. package/src/cli/commands/shared/companion-selection.ts +2 -2
  17. package/src/cli/commands/studio/reports.ts +0 -1
  18. package/src/cli/commands/studio/server.ts +6 -2
  19. package/src/cli/commands/unwrap.ts +28 -3
  20. package/src/cli/commands/wrap.ts +53 -59
  21. package/src/cli/companion-selector.tsx +1 -1
  22. package/src/cli/main.ts +17 -0
  23. package/src/cli/usage.ts +3 -1
  24. package/src/hooks/session-banner.ts +19 -10
  25. package/src/hooks/session-prepare.ts +101 -0
  26. package/src/lib/fs-utils.ts +13 -0
  27. package/src/lib/orchestrator/adapters/base.ts +43 -8
  28. package/src/lib/orchestrator/adapters/claude.ts +130 -29
  29. package/src/lib/orchestrator/adapters/opencode.ts +17 -19
  30. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  31. package/src/lib/orchestrator/companion-hub.ts +2 -1
  32. package/src/lib/orchestrator/config-store.ts +56 -2
  33. package/src/lib/orchestrator/framework-context.ts +5 -29
  34. package/src/lib/orchestrator/launcher.ts +36 -171
  35. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  36. package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
  37. package/src/lib/orchestrator/projection-entries.ts +78 -11
  38. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  39. package/src/lib/orchestrator/projection-record.ts +7 -1
  40. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  41. package/src/lib/orchestrator/projection-types.ts +38 -33
  42. package/src/lib/orchestrator/repository-link.ts +473 -0
  43. package/src/lib/orchestrator/root-context.ts +6 -2
  44. package/src/lib/orchestrator/session-prepare.ts +425 -0
  45. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  46. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  47. package/src/lib/orchestrator/types.ts +4 -1
  48. package/src/lib/orchestrator/working-repo-projection.ts +71 -159
  49. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  50. package/src/lib/orchestrator/wrap-pass.ts +131 -0
  51. package/src/lib/runtime-directory-refresh.ts +186 -0
  52. package/src/lib/update-checker.ts +6 -0
  53. package/src/opencode/companion-hooks.ts +24 -132
  54. package/src/opencode/companion-policy.ts +26 -10
  55. package/src/opencode/index.ts +1 -7
  56. package/src/opencode/session-notes.ts +76 -0
  57. package/src/opencode/session-prepare.ts +139 -0
  58. package/src/opencode/tui.tsx +56 -9
  59. package/src/runtime/claude-delivery-plan.ts +73 -0
  60. package/src/runtime/claude-hook-group.ts +21 -0
  61. package/src/runtime/companion-lock.ts +121 -0
  62. package/src/runtime/companion-sync.ts +66 -9
  63. package/src/runtime/runtime-directory.ts +43 -0
  64. package/src/runtime/runtime-document-manifest.ts +335 -0
  65. package/src/runtime/session-prepare-runner.ts +148 -0
  66. package/src/runtime/wrap-state.ts +90 -0
  67. package/src/runtime/write-atomic.ts +27 -0
  68. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  69. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  70. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  71. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  72. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  73. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  74. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  75. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  76. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  77. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  78. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  79. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
  80. package/src/tools/setup/engine.ts +15 -4
  81. package/src/tools/setup/mate.ts +1 -1
  82. package/src/tools/setup/providers/claude-format.ts +11 -18
  83. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  84. package/src/tools/setup/providers/claude.ts +23 -6
  85. package/src/tools/setup/runtime-documents.ts +152 -32
  86. package/src/tools/setup/working-repo-local-state.ts +82 -13
  87. package/src/tools/setup.ts +2 -2
  88. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  89. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  90. package/src/lib/orchestrator/companion-store.ts +0 -94
  91. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  92. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  93. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  94. package/src/templates/report-assets/README.md +0 -32
  95. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  96. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Claude hook-group shape and its one identity. A `runtime/` leaf so hooks and
3
+ * Wrap State can compare groups without loading setup code.
4
+ */
5
+
6
+ export interface ClaudeHookCommand {
7
+ type?: string;
8
+ command?: string;
9
+ args?: string[];
10
+ timeout?: number;
11
+ }
12
+
13
+ export interface ClaudeHookGroup {
14
+ matcher?: string;
15
+ hooks?: ClaudeHookCommand[];
16
+ }
17
+
18
+ /** The one hook-group identity: structurally identical groups are the same group. */
19
+ export function hookGroupKey(group: ClaudeHookGroup): string {
20
+ return JSON.stringify(group);
21
+ }
@@ -0,0 +1,121 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import crypto from "node:crypto";
3
+ import fsSync from "node:fs";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+
7
+ import { mateHome } from "./runtime-directory";
8
+
9
+ /**
10
+ * Per-companion lock under `<mate home>/locks/`, outside the companion's tree;
11
+ * repos share a companion, so a per-repo lock would race pulls. Re-entrant per
12
+ * async call chain, not per process.
13
+ */
14
+
15
+ export interface CompanionLockOptions {
16
+ homeDir?: string;
17
+ /** `false` tries once and returns `acquired: false` at once when the lock is held. */
18
+ wait?: boolean;
19
+ /** How long to wait for another holder before giving up. */
20
+ timeoutMs?: number;
21
+ pollMs?: number;
22
+ /** Called once, when the lock is found held and the caller starts waiting. */
23
+ onWait?: () => void;
24
+ }
25
+
26
+ export type CompanionLockResult<T> = { acquired: true; value: T } | { acquired: false };
27
+
28
+ function companionKey(companionPath: string): string {
29
+ let resolved = path.resolve(companionPath);
30
+ try {
31
+ resolved = fsSync.realpathSync(resolved);
32
+ } catch {
33
+ /* an unresolvable companion is still keyed by its spelling */
34
+ }
35
+ return crypto.createHash("sha256").update(resolved).digest("hex").slice(0, 32);
36
+ }
37
+
38
+ export function companionLockPath(companionPath: string, homeDir?: string): string {
39
+ return path.join(mateHome(homeDir), "locks", `${companionKey(companionPath)}.lock`);
40
+ }
41
+
42
+ const heldLocks = new AsyncLocalStorage<ReadonlySet<string>>();
43
+
44
+ /** An unreadable holder id is only trusted this long, so a corrupt lock cannot block forever. */
45
+ const UNREADABLE_LOCK_GRACE_MS = 10_000;
46
+
47
+ async function holderAlive(lockPath: string, raw: string): Promise<boolean> {
48
+ const pid = Number.parseInt(raw.trim(), 10);
49
+ if (!Number.isInteger(pid) || pid <= 0) {
50
+ const stat = await fs.stat(lockPath).catch(() => null);
51
+ return stat !== null && Date.now() - stat.mtimeMs < UNREADABLE_LOCK_GRACE_MS;
52
+ }
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (error) {
57
+ return (error as NodeJS.ErrnoException).code === "EPERM";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The holder's pid is written to a private file first and hard-linked into
63
+ * place, so the lock never exists without its holder: a contender reading an
64
+ * empty file in between would otherwise take it as stale and remove it.
65
+ */
66
+ async function tryAcquire(lockPath: string): Promise<boolean> {
67
+ const tempPath = `${lockPath}.${crypto.randomUUID()}.tmp`;
68
+ await fs.writeFile(tempPath, String(process.pid));
69
+ try {
70
+ await fs.link(tempPath, lockPath);
71
+ return true;
72
+ } catch (error) {
73
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
74
+ } finally {
75
+ await fs.rm(tempPath, { force: true });
76
+ }
77
+ const raw = await fs.readFile(lockPath, "utf8").catch(() => null);
78
+ if (raw === null || (await holderAlive(lockPath, raw))) return false;
79
+ /** A dead holder's lock is removed only if it is still the one just read. */
80
+ if ((await fs.readFile(lockPath, "utf8").catch(() => null)) === raw) {
81
+ await fs.rm(lockPath, { force: true });
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * Runs `task` while holding the companion's lock; `acquired: false` after the
88
+ * bounded wait, or at once with `wait: false`. A caller already holding it in
89
+ * this call chain runs `task` directly.
90
+ */
91
+ export async function withCompanionLock<T>(
92
+ companionPath: string,
93
+ options: CompanionLockOptions,
94
+ task: () => Promise<T>,
95
+ ): Promise<CompanionLockResult<T>> {
96
+ const lockPath = companionLockPath(companionPath, options.homeDir);
97
+ const held = heldLocks.getStore() ?? new Set<string>();
98
+ if (held.has(lockPath)) return { acquired: true, value: await task() };
99
+
100
+ const timeoutMs = options.wait === false ? 0 : (options.timeoutMs ?? 30_000);
101
+ const pollMs = options.pollMs ?? 200;
102
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
103
+
104
+ const deadline = Date.now() + timeoutMs;
105
+ let waiting = false;
106
+ // oxlint-disable-next-line no-await-in-loop -- polling is the point
107
+ while (!(await tryAcquire(lockPath))) {
108
+ if (Date.now() >= deadline) return { acquired: false };
109
+ if (!waiting) {
110
+ waiting = true;
111
+ options.onWait?.();
112
+ }
113
+ // oxlint-disable-next-line no-await-in-loop
114
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
115
+ }
116
+ try {
117
+ return { acquired: true, value: await heldLocks.run(new Set([...held, lockPath]), task) };
118
+ } finally {
119
+ await fs.rm(lockPath, { force: true });
120
+ }
121
+ }
@@ -30,6 +30,7 @@ import {
30
30
  type CompanionForkState,
31
31
  type CompanionGit,
32
32
  } from "./companion-git";
33
+ import { withCompanionLock } from "./companion-lock";
33
34
  import { hasLaunchEnvironment } from "./env";
34
35
  import { FRAMEWORK_NAME } from "./framework";
35
36
  import { readCompanionPolicy } from "./policy";
@@ -37,10 +38,20 @@ import { readCompanionPolicy } from "./policy";
37
38
  /** The one command that finishes what the unattended half declines to do. */
38
39
  export const COMPANION_SYNC_COMMAND = `${FRAMEWORK_NAME} companion sync`;
39
40
 
41
+ /** OpenCode's recovery is the managed launch: it runs the sync at a terminal that can answer Git. */
42
+ export const OPENCODE_LAUNCH_COMMAND = `${FRAMEWORK_NAME} opencode`;
43
+
44
+ /** A Claude-wrapped repository's equivalent: the attended launch reconciles the wrap. */
45
+ export const CLAUDE_LAUNCH_COMMAND = `${FRAMEWORK_NAME} claude`;
46
+
47
+ /** Which runtime's operator reads the note; it decides the recovery named. */
48
+ export type StalenessAudience = "claude" | "opencode";
49
+
40
50
  /** A cold link costs this once; the recorded completion then suppresses retries. */
41
51
  export const COMPANION_SYNC_TIMEOUT_MS = 20_000;
42
52
 
43
- export type UnattendedSyncStatus = "not-applicable" | "fresh" | "complete" | "unfinished";
53
+ /** `busy`: another operation held the companion lock; nothing ran and nothing is owed. */
54
+ export type UnattendedSyncStatus = "not-applicable" | "fresh" | "busy" | "complete" | "unfinished";
44
55
 
45
56
  export interface UnattendedSyncOutcome {
46
57
  status: UnattendedSyncStatus;
@@ -58,6 +69,12 @@ export interface UnattendedSyncOptions {
58
69
  homeDir?: string;
59
70
  /** Environment Git runs with. */
60
71
  env?: Record<string, string | undefined>;
72
+ /**
73
+ * `wait` (the default) queues behind another holder of the companion lock,
74
+ * within the time budget; `try` skips at once, for a hook that must not
75
+ * stall a session start.
76
+ */
77
+ lock?: "wait" | "try";
61
78
  }
62
79
 
63
80
  const UNFINISHED_GIT_PATHS = [
@@ -115,6 +132,24 @@ export async function syncCompanionUnattended(
115
132
  return { status: "fresh", changed: false, companionPath };
116
133
  }
117
134
 
135
+ const locked = await withCompanionLock(
136
+ companionPath,
137
+ { homeDir, wait: options.lock !== "try", timeoutMs },
138
+ () => syncHoldingLock(companionPath, { timeoutMs, now, homeDir, env }),
139
+ );
140
+ return locked.acquired ? locked.value : { status: "busy", changed: false, companionPath };
141
+ }
142
+
143
+ async function syncHoldingLock(
144
+ companionPath: string,
145
+ options: {
146
+ timeoutMs: number;
147
+ now: Date;
148
+ homeDir?: string;
149
+ env?: Record<string, string | undefined>;
150
+ },
151
+ ): Promise<UnattendedSyncOutcome> {
152
+ const { timeoutMs, now, homeDir, env } = options;
118
153
  const git = companionGit(companionPath, {
119
154
  prompt: "never",
120
155
  budgetMs: timeoutMs,
@@ -191,13 +226,36 @@ export async function syncCompanionUnattended(
191
226
  * answer a credential prompt and no way to tell a fast-forward from a
192
227
  * conflicted rebase.
193
228
  */
194
- export function unattendedSyncStalenessLines(outcome: UnattendedSyncOutcome): string[] {
229
+ export function unattendedSyncStalenessLines(
230
+ outcome: UnattendedSyncOutcome,
231
+ audience: StalenessAudience = "claude",
232
+ ): string[] {
195
233
  if (outcome.status !== "unfinished") return [];
234
+ const reason = outcome.reason ?? "unknown reason";
235
+ if (audience === "opencode") return launchRecoveryStalenessLines(outcome, ["opencode"]);
196
236
  return [
197
- `companion Git synchronization unfinished: ${outcome.reason ?? "unknown reason"} — run \`${COMPANION_SYNC_COMMAND}\``,
237
+ `companion Git synchronization unfinished: ${reason} — run \`${COMPANION_SYNC_COMMAND}\``,
198
238
  ];
199
239
  }
200
240
 
241
+ /**
242
+ * The note for a session prepare, naming the managed launch of each wrapped
243
+ * runtime as the recovery: it runs the sync at a terminal that can answer Git.
244
+ */
245
+ export function launchRecoveryStalenessLines(
246
+ outcome: UnattendedSyncOutcome,
247
+ runtimes: readonly StalenessAudience[],
248
+ ): string[] {
249
+ if (outcome.status !== "unfinished") return [];
250
+ const reason = outcome.reason ?? "unknown reason";
251
+ const wrapped = new Set(runtimes);
252
+ const launches = [
253
+ ...(wrapped.has("claude") ? [CLAUDE_LAUNCH_COMMAND] : []),
254
+ ...(wrapped.has("opencode") || wrapped.size === 0 ? [OPENCODE_LAUNCH_COMMAND] : []),
255
+ ].map((command) => `\`${command}\``);
256
+ return [`Companion not updated: ${reason}. Restart with ${launches.join(" or ")} to fix it.`];
257
+ }
258
+
201
259
  /**
202
260
  * The note a session-state reader surfaces, taken from the persisted record so
203
261
  * a render costs no Git. Operator-facing only, exactly as the live note is.
@@ -205,16 +263,15 @@ export function unattendedSyncStalenessLines(outcome: UnattendedSyncOutcome): st
205
263
  export function persistedCompanionGitStalenessLines(
206
264
  companionPath: string,
207
265
  homeDir?: string,
266
+ audience: StalenessAudience = "claude",
208
267
  ): string[] {
209
268
  if (!companionPath) return [];
210
269
  const reason = readCompanionGitRecord(companionPath, homeDir).unfinishedReason;
211
270
  if (!reason) return [];
212
- return unattendedSyncStalenessLines({
213
- status: "unfinished",
214
- changed: false,
215
- companionPath,
216
- reason,
217
- });
271
+ return unattendedSyncStalenessLines(
272
+ { status: "unfinished", changed: false, companionPath, reason },
273
+ audience,
274
+ );
218
275
  }
219
276
 
220
277
  export interface ForkGuardOptions {
@@ -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,335 @@
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
+ }
84
+
85
+ export const EMPTY_MANIFEST: Manifest = Object.freeze({
86
+ documents: Object.freeze({}),
87
+ runtimes: Object.freeze({}),
88
+ copies: Object.freeze({}),
89
+ links: Object.freeze({}),
90
+ });
91
+
92
+ /** Legacy manifests carry no tag; every destination Mate ever wrote is runtime-specific by path. */
93
+ export function documentRuntime(documentPath: string): WrapRuntime {
94
+ return documentPath.startsWith(".opencode/") ? "opencode" : "claude";
95
+ }
96
+
97
+ function isRecord(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === "object" && value !== null && !Array.isArray(value);
99
+ }
100
+
101
+ /** Never throws: an absent, unparseable or foreign manifest reads as empty — Mate can rebuild it. */
102
+ export function parseManifest(raw: string | null): Manifest {
103
+ if (raw === null) return EMPTY_MANIFEST;
104
+ let parsed: unknown;
105
+ try {
106
+ parsed = JSON.parse(raw) as unknown;
107
+ } catch {
108
+ return EMPTY_MANIFEST;
109
+ }
110
+ return isRecord(parsed) ? manifestOf(parsed) : EMPTY_MANIFEST;
111
+ }
112
+
113
+ /** For readers that rewrite on its strength: a manifest present but unparseable throws. */
114
+ export function parseManifestStrict(raw: string | null): Manifest {
115
+ if (raw === null) return EMPTY_MANIFEST;
116
+ const parsed = JSON.parse(raw) as unknown;
117
+ if (!isRecord(parsed)) throw new Error("runtime document manifest is not a JSON object");
118
+ return manifestOf(parsed);
119
+ }
120
+
121
+ function manifestOf(parsed: Record<string, unknown>): Manifest {
122
+ return {
123
+ documents: isRecord(parsed.documents) ? (parsed.documents as Manifest["documents"]) : {},
124
+ runtimes: isRecord(parsed.runtimes) ? (parsed.runtimes as Manifest["runtimes"]) : {},
125
+ copies: isRecord(parsed.copies) ? (parsed.copies as Manifest["copies"]) : {},
126
+ links: isRecord(parsed.links) ? (parsed.links as Manifest["links"]) : {},
127
+ };
128
+ }
129
+
130
+ function recordedRuntime(manifest: Manifest, documentPath: string): WrapRuntime {
131
+ return manifest.runtimes[documentPath] ?? documentRuntime(documentPath);
132
+ }
133
+
134
+ /**
135
+ * `null` means the manifest records nothing and its file should not exist.
136
+ * `runtimes` is rebuilt from `documents`, so a legacy manifest is tagged on its
137
+ * next write and a withdrawn document leaves no stale tag behind.
138
+ */
139
+ export function serializeManifest(manifest: Manifest): string | null {
140
+ const recorded = Object.keys(manifest.documents);
141
+ const copied = Object.keys(manifest.copies);
142
+ const linked = Object.keys(manifest.links);
143
+ if (recorded.length === 0 && copied.length === 0 && linked.length === 0) return null;
144
+ const runtimes = Object.fromEntries(
145
+ recorded.map((documentPath) => [documentPath, recordedRuntime(manifest, documentPath)]),
146
+ );
147
+ return `${JSON.stringify(
148
+ {
149
+ documents: manifest.documents,
150
+ ...(recorded.length > 0 ? { runtimes } : {}),
151
+ ...(copied.length > 0 ? { copies: manifest.copies } : {}),
152
+ ...(linked.length > 0 ? { links: manifest.links } : {}),
153
+ },
154
+ null,
155
+ 2,
156
+ )}\n`;
157
+ }
158
+
159
+ /**
160
+ * Every destination the manifest records, in the order it recorded them,
161
+ * optionally only one runtime's. The manifest is written only by a pass that
162
+ * placed a runtime document or a copy, and `mate wrap` is the only such pass.
163
+ */
164
+ export function documents(manifest: Manifest, runtime?: WrapRuntime): string[] {
165
+ return Object.keys(manifest.documents).filter(
166
+ (documentPath) => !runtime || recordedRuntime(manifest, documentPath) === runtime,
167
+ );
168
+ }
169
+
170
+ /** One destination's recorded regions; `undefined` when unrecorded or malformed. */
171
+ export function documentRegions(
172
+ manifest: Manifest,
173
+ documentPath: string,
174
+ ): readonly ManagedRegion[] | undefined {
175
+ const recorded: unknown = manifest.documents[documentPath];
176
+ return Array.isArray(recorded) ? (recorded as ManagedRegion[]) : undefined;
177
+ }
178
+
179
+ /** Owned companion copies, keyed by repo-relative POSIX path. */
180
+ export function copies(manifest: Manifest, runtime?: WrapRuntime): Record<string, CopyRecord> {
181
+ return Object.fromEntries(
182
+ Object.entries(manifest.copies).filter(
183
+ ([, record]) => !runtime || (isRecord(record) && record.runtime === runtime),
184
+ ),
185
+ );
186
+ }
187
+
188
+ /** Links Mate created, keyed by repo-relative POSIX path. */
189
+ export function links(manifest: Manifest, runtime?: WrapRuntime): Record<string, LinkRecord> {
190
+ return Object.fromEntries(
191
+ Object.entries(manifest.links).filter(
192
+ ([, record]) =>
193
+ isRecord(record) &&
194
+ typeof record.target === "string" &&
195
+ (!runtime || record.runtime === runtime),
196
+ ),
197
+ );
198
+ }
199
+
200
+ function isAt(at: unknown, address: readonly string[]): boolean {
201
+ return (
202
+ Array.isArray(at) &&
203
+ at.length === address.length &&
204
+ at.every((key, index) => key === address[index])
205
+ );
206
+ }
207
+
208
+ /**
209
+ * The keys of the `map` regions recorded at `at` in one document. Shapes are
210
+ * checked rather than trusted: a hand-edited manifest reads as no keys, never
211
+ * as a throw.
212
+ */
213
+ export function recordedMapKeys(
214
+ manifest: Manifest,
215
+ documentPath: string,
216
+ at: readonly string[],
217
+ ): string[] {
218
+ return (documentRegions(manifest, documentPath) ?? []).flatMap((region: unknown) =>
219
+ isRecord(region) && region.kind === "map" && isAt(region.at, at) && isRecord(region.entries)
220
+ ? Object.keys(region.entries)
221
+ : [],
222
+ );
223
+ }
224
+
225
+ /** Every value of the `list` regions recorded under `prefix` in one document. */
226
+ export function recordedListValues(
227
+ manifest: Manifest,
228
+ documentPath: string,
229
+ prefix: readonly string[],
230
+ ): unknown[] {
231
+ return (documentRegions(manifest, documentPath) ?? []).flatMap((region: unknown) =>
232
+ isRecord(region) &&
233
+ region.kind === "list" &&
234
+ Array.isArray(region.at) &&
235
+ isAt(region.at.slice(0, prefix.length), prefix) &&
236
+ Array.isArray(region.values)
237
+ ? region.values
238
+ : [],
239
+ );
240
+ }
241
+
242
+ /** Records a destination's regions, or withdraws it when `regions` is undefined. */
243
+ export function withDocument(
244
+ manifest: Manifest,
245
+ documentPath: string,
246
+ regions: readonly ManagedRegion[] | undefined,
247
+ ): Manifest {
248
+ if (regions === undefined) return withoutDocuments(manifest, [documentPath]);
249
+ return {
250
+ ...manifest,
251
+ documents: { ...manifest.documents, [documentPath]: [...regions] },
252
+ };
253
+ }
254
+
255
+ export function withoutDocuments(manifest: Manifest, documentPaths: readonly string[]): Manifest {
256
+ const removed = new Set(documentPaths);
257
+ return {
258
+ ...manifest,
259
+ documents: Object.fromEntries(
260
+ Object.entries(manifest.documents).filter(([documentPath]) => !removed.has(documentPath)),
261
+ ),
262
+ };
263
+ }
264
+
265
+ /** Replaces one runtime's records, leaving every other record as it is. */
266
+ function replaceRuntimeRecords<T>(
267
+ existing: Readonly<Record<string, T>>,
268
+ runtime: WrapRuntime,
269
+ records: Readonly<Record<string, T>>,
270
+ ): Record<string, T> {
271
+ const kept = Object.entries(existing).filter(
272
+ ([, record]) => !(isRecord(record) && record.runtime === runtime),
273
+ );
274
+ return { ...Object.fromEntries(kept), ...records };
275
+ }
276
+
277
+ export function withCopies(
278
+ manifest: Manifest,
279
+ runtime: WrapRuntime,
280
+ records: Readonly<Record<string, CopyRecord>>,
281
+ ): Manifest {
282
+ return { ...manifest, copies: replaceRuntimeRecords(manifest.copies, runtime, records) };
283
+ }
284
+
285
+ export function withLinks(
286
+ manifest: Manifest,
287
+ runtime: WrapRuntime,
288
+ records: Readonly<Record<string, LinkRecord>>,
289
+ ): Manifest {
290
+ return { ...manifest, links: replaceRuntimeRecords(manifest.links, runtime, records) };
291
+ }
292
+
293
+ export function manifestPath(repoPath: string): string {
294
+ return path.join(repoLocalDirPath(repoPath), RUNTIME_DOCUMENT_MANIFEST_FILE);
295
+ }
296
+
297
+ /**
298
+ * The runtime document manifest at the Projection Root; empty when absent. A
299
+ * read that failed for a reason other than absence throws, so a writer never
300
+ * replaces a manifest it could not read.
301
+ */
302
+ export async function readManifest(repoPath: string): Promise<Manifest> {
303
+ return parseManifest(await readManifestText(repoPath));
304
+ }
305
+
306
+ /** `null` when absent; any other read failure throws. */
307
+ export async function readManifestText(repoPath: string): Promise<string | null> {
308
+ try {
309
+ return await fsp.readFile(manifestPath(repoPath), "utf8");
310
+ } catch (error) {
311
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
312
+ return null;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * The one way the manifest changes: read, edit, then write it atomically — or
318
+ * delete it once it records nothing. Every writer goes through here, so none
319
+ * can drop another's records.
320
+ */
321
+ export async function updateManifest(
322
+ repoPath: string,
323
+ edit: (manifest: Manifest) => Manifest,
324
+ ): Promise<Manifest> {
325
+ const next = edit(await readManifest(repoPath));
326
+ const target = manifestPath(repoPath);
327
+ const contents = serializeManifest(next);
328
+ if (contents === null) {
329
+ await fsp.unlink(target).catch(() => {});
330
+ } else {
331
+ await fsp.mkdir(path.dirname(target), { recursive: true });
332
+ await writeAtomic(target, contents);
333
+ }
334
+ return next;
335
+ }