@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
@@ -0,0 +1,230 @@
1
+ import crypto from "node:crypto";
2
+ import type { Dirent } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ copies,
8
+ readManifest,
9
+ updateManifest,
10
+ withCopies,
11
+ type CopyRecord,
12
+ } from "../../runtime/runtime-document-manifest";
13
+ import { writeAtomic } from "../../runtime/write-atomic";
14
+ import { pruneEmptyAncestors } from "../../tools/setup/utils";
15
+ import { reconcileWorkingRepoCopyExcludes } from "../../tools/setup/working-repo-local-state";
16
+ import type { ProjectionWriteReport } from "./projection-types";
17
+
18
+ /**
19
+ * Copies the companion's `.opencode/` into the Working Repository's own
20
+ * `.opencode/`, the one slot OpenCode reads beside the user's global config.
21
+ * Regular files, not links: OpenCode does not live-reload a linked folder.
22
+ * Mate touches only the files its manifest records as owned.
23
+ */
24
+
25
+ const OPENCODE_DIR = ".opencode";
26
+ /** `opencode.json` is merged into the wrapped document instead of copied. */
27
+ const SKIPPED_TOP_LEVEL = new Set(["opencode.json"]);
28
+ const SKIPPED_NAMES = new Set(["node_modules", ".DS_Store"]);
29
+
30
+ interface CopySource {
31
+ absolute: string;
32
+ content: Buffer;
33
+ sha256: string;
34
+ }
35
+
36
+ function sha256(content: Buffer): string {
37
+ return crypto.createHash("sha256").update(content).digest("hex");
38
+ }
39
+
40
+ async function walk(dir: string, relative: string, into: Map<string, CopySource>): Promise<void> {
41
+ let entries: Dirent[];
42
+ try {
43
+ entries = await fs.readdir(dir, { withFileTypes: true });
44
+ } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
46
+ throw error;
47
+ }
48
+ for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
49
+ if (SKIPPED_NAMES.has(entry.name)) continue;
50
+ if (relative === "" && SKIPPED_TOP_LEVEL.has(entry.name)) continue;
51
+ const absolute = path.join(dir, entry.name);
52
+ const rel = relative ? `${relative}/${entry.name}` : entry.name;
53
+ // oxlint-disable-next-line no-await-in-loop -- sequential walk keeps the order stable
54
+ const stat = await fs.stat(absolute).catch(() => null);
55
+ if (!stat) continue;
56
+ if (stat.isDirectory()) {
57
+ // oxlint-disable-next-line no-await-in-loop
58
+ await walk(absolute, rel, into);
59
+ continue;
60
+ }
61
+ if (!stat.isFile()) continue;
62
+ // oxlint-disable-next-line no-await-in-loop
63
+ const content = await fs.readFile(absolute);
64
+ into.set(`${OPENCODE_DIR}/${rel}`, { absolute, content, sha256: sha256(content) });
65
+ }
66
+ }
67
+
68
+ async function companionSources(companionPath: string): Promise<Map<string, CopySource>> {
69
+ const sources = new Map<string, CopySource>();
70
+ await walk(path.join(companionPath, OPENCODE_DIR), "", sources);
71
+ return sources;
72
+ }
73
+
74
+ function destination(repoPath: string, relativePath: string): string {
75
+ return path.join(path.resolve(repoPath), ...relativePath.split("/"));
76
+ }
77
+
78
+ /** `null` when absent; a directory or other non-file reads as a foreign entry. */
79
+ async function readDestination(target: string): Promise<Buffer | "foreign" | null> {
80
+ const stat = await fs.lstat(target).catch(() => null);
81
+ if (!stat) return null;
82
+ if (!stat.isFile()) return "foreign";
83
+ return fs.readFile(target);
84
+ }
85
+
86
+ async function writeCopy(target: string, content: Buffer): Promise<void> {
87
+ await fs.mkdir(path.dirname(target), { recursive: true });
88
+ await writeAtomic(target, content);
89
+ }
90
+
91
+ async function deleteCopy(repoPath: string, relativePath: string): Promise<void> {
92
+ const target = destination(repoPath, relativePath);
93
+ await fs.rm(target, { force: true });
94
+ await pruneEmptyAncestors(path.dirname(target), path.resolve(repoPath));
95
+ }
96
+
97
+ interface CopyPlan {
98
+ write: Array<{ relativePath: string; source: CopySource; warning?: string }>;
99
+ remove: string[];
100
+ shadowed: Array<{ relativePath: string; source: CopySource }>;
101
+ records: Record<string, CopyRecord>;
102
+ previous: Record<string, CopyRecord>;
103
+ }
104
+
105
+ async function planCopies(repoPath: string, companionPath: string): Promise<CopyPlan> {
106
+ const previous = copies(await readManifest(repoPath), "opencode");
107
+ const sources = await companionSources(companionPath);
108
+ const plan: CopyPlan = { write: [], remove: [], shadowed: [], records: {}, previous };
109
+
110
+ for (const [relativePath, source] of sources) {
111
+ const owned = previous[relativePath];
112
+ // oxlint-disable-next-line no-await-in-loop -- one stat per file
113
+ const current = await readDestination(destination(repoPath, relativePath));
114
+ if (current === "foreign" || (!owned && current !== null)) {
115
+ plan.shadowed.push({ relativePath, source });
116
+ continue;
117
+ }
118
+ const currentSha = current === null ? null : sha256(current);
119
+ const edited = owned !== undefined && currentSha !== null && currentSha !== owned.sha256;
120
+ /** Local edit survives until its companion source changes. */
121
+ const keepEdit = edited && source.sha256 === owned.sha256;
122
+ if (currentSha !== source.sha256 && !keepEdit) {
123
+ plan.write.push({
124
+ relativePath,
125
+ source,
126
+ warning: edited
127
+ ? `${relativePath} was edited locally; the edit was replaced from the companion. Edit ${source.absolute} instead.`
128
+ : undefined,
129
+ });
130
+ }
131
+ plan.records[relativePath] = { runtime: "opencode", sha256: source.sha256 };
132
+ }
133
+ for (const relativePath of Object.keys(previous)) {
134
+ if (!sources.has(relativePath)) plan.remove.push(relativePath);
135
+ }
136
+ return plan;
137
+ }
138
+
139
+ function sameRecords(a: Record<string, CopyRecord>, b: Record<string, CopyRecord>): boolean {
140
+ return JSON.stringify(a) === JSON.stringify(b);
141
+ }
142
+
143
+ function shadowWarning(relativePath: string, source: CopySource): string {
144
+ return `${relativePath} in the working repository shadows the companion file ${source.absolute}; the repository file wins.`;
145
+ }
146
+
147
+ /**
148
+ * Adds, updates and deletes owned copies; an unowned path is never touched.
149
+ * Excludes widen and new paths are recorded before any write, so an
150
+ * interrupted reconcile leaves no unrecorded copy and none Git would list.
151
+ */
152
+ export async function reconcileCompanionCopies(
153
+ repoPath: string,
154
+ companionPath: string,
155
+ ): Promise<ProjectionWriteReport> {
156
+ const plan = await planCopies(repoPath, companionPath);
157
+ const warnings = plan.shadowed.map(({ relativePath, source }) =>
158
+ shadowWarning(relativePath, source),
159
+ );
160
+
161
+ const covering = [...new Set([...Object.keys(plan.previous), ...Object.keys(plan.records)])];
162
+ const excludesWidened = await reconcileWorkingRepoCopyExcludes(repoPath, covering);
163
+ /** Previous records win: a path not yet rewritten keeps the hash on disk. */
164
+ const interim = { ...plan.records, ...plan.previous };
165
+ if (!sameRecords(interim, plan.previous)) {
166
+ await updateManifest(repoPath, (manifest) => withCopies(manifest, "opencode", interim));
167
+ }
168
+
169
+ for (const { relativePath, source, warning } of plan.write) {
170
+ // oxlint-disable-next-line no-await-in-loop -- bounded by the companion folder
171
+ await writeCopy(destination(repoPath, relativePath), source.content);
172
+ if (warning) warnings.push(warning);
173
+ }
174
+ for (const relativePath of plan.remove) {
175
+ // oxlint-disable-next-line no-await-in-loop
176
+ await deleteCopy(repoPath, relativePath);
177
+ }
178
+
179
+ const recordsWritten = !sameRecords(plan.records, plan.previous);
180
+ if (recordsWritten) {
181
+ await updateManifest(repoPath, (manifest) => withCopies(manifest, "opencode", plan.records));
182
+ }
183
+ const excludesNarrowed = await reconcileWorkingRepoCopyExcludes(
184
+ repoPath,
185
+ Object.keys(plan.records),
186
+ );
187
+
188
+ const written = plan.write.map(({ relativePath }) => relativePath);
189
+ return {
190
+ state:
191
+ written.length > 0 ||
192
+ plan.remove.length > 0 ||
193
+ recordsWritten ||
194
+ excludesWidened ||
195
+ excludesNarrowed
196
+ ? "written"
197
+ : "current",
198
+ warnings,
199
+ changed: { written, removed: plan.remove },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Deletes every owned copy, then its exclude line, and prunes what that
205
+ * emptied: a copy is never on disk without the line that hides it.
206
+ */
207
+ export async function withdrawCompanionCopies(repoPath: string): Promise<ProjectionWriteReport> {
208
+ const owned = Object.keys(copies(await readManifest(repoPath), "opencode"));
209
+ for (const relativePath of owned) {
210
+ // oxlint-disable-next-line no-await-in-loop
211
+ await deleteCopy(repoPath, relativePath);
212
+ }
213
+ if (owned.length > 0) {
214
+ await updateManifest(repoPath, (manifest) => withCopies(manifest, "opencode", {}));
215
+ }
216
+ const excludesChanged = await reconcileWorkingRepoCopyExcludes(repoPath, []);
217
+ return {
218
+ state: owned.length > 0 || excludesChanged ? "written" : "current",
219
+ changed: { written: [], removed: owned },
220
+ };
221
+ }
222
+
223
+ export async function removeCompanionCopies(repoPath: string): Promise<"removed" | "absent"> {
224
+ return (await withdrawCompanionCopies(repoPath)).state === "written" ? "removed" : "absent";
225
+ }
226
+
227
+ /** Whether any owned copy is recorded. */
228
+ export async function companionCopiesPresent(repoPath: string): Promise<boolean> {
229
+ return Object.keys(copies(await readManifest(repoPath), "opencode")).length > 0;
230
+ }
@@ -0,0 +1,164 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ import { generatedHeader } from "../../runtime/projection";
7
+ import { reconcileWorkingRepoCapabilityExcludes } from "../../tools/setup/working-repo-local-state";
8
+ import { ensureStoreRegistration } from "./openspec-store";
9
+ import type { ProjectionWriteReport, ProjectionWriteState } from "./projection-types";
10
+
11
+ /**
12
+ * The store pointer: an `openspec/config.yaml` in the Working Repository that
13
+ * declares the companion's OpenSpec store. Run from inside the repository,
14
+ * OpenSpec then plans in the store and names the repository as the edit root.
15
+ *
16
+ * The pointer's whole `openspec/` folder is hidden only through
17
+ * `.git/info/exclude`, never the repository's own `.gitignore`: all planning
18
+ * lives in the store, so nothing under it belongs to the repository. Mate
19
+ * never writes it where the repository already owns an OpenSpec root.
20
+ */
21
+
22
+ const execFileAsync = promisify(execFile);
23
+
24
+ export const OPENSPEC_POINTER_PATH = path.join("openspec", "config.yaml");
25
+ /** The exclusion line, outside the managed block so a core-block rewrite keeps it. */
26
+ export const OPENSPEC_POINTER_EXCLUDE = "/openspec/";
27
+ /** The earlier file-only line; still managed so a refresh replaces it. */
28
+ const LEGACY_POINTER_EXCLUDE = "/openspec/config.yaml";
29
+ const MANAGED_POINTER_EXCLUDES = [OPENSPEC_POINTER_EXCLUDE, LEGACY_POINTER_EXCLUDE];
30
+
31
+ export type StorePointer = { kind: "absent" } | { kind: "foreign" } | { kind: "mate"; id: string };
32
+
33
+ function pointerFile(repoPath: string): string {
34
+ return path.join(repoPath, OPENSPEC_POINTER_PATH);
35
+ }
36
+
37
+ function renderPointer(id: string): string {
38
+ return `${generatedHeader()}\nstore: ${id}\n`;
39
+ }
40
+
41
+ async function exists(candidate: string): Promise<boolean> {
42
+ return fs
43
+ .access(candidate)
44
+ .then(() => true)
45
+ .catch(() => false);
46
+ }
47
+
48
+ export async function readStorePointer(repoPath: string): Promise<StorePointer> {
49
+ const content = await fs.readFile(pointerFile(repoPath), "utf8").catch(() => null);
50
+ if (content === null) return { kind: "absent" };
51
+ const [first, ...rest] = content.split(/\r?\n/);
52
+ if (first !== generatedHeader()) return { kind: "foreign" };
53
+ const store = rest.map((line) => /^store:\s*(\S+)\s*$/.exec(line)?.[1]).find(Boolean);
54
+ return { kind: "mate", id: store ?? "" };
55
+ }
56
+
57
+ /**
58
+ * Why Mate may not point this repository at a store, or `null` when it may: a
59
+ * config Mate did not write, or a planning root of the repository's own.
60
+ */
61
+ export async function storePointerIneligibility(repoPath: string): Promise<string | null> {
62
+ if ((await readStorePointer(repoPath)).kind === "foreign") {
63
+ return `${pointerFile(repoPath)} is not Mate's`;
64
+ }
65
+ for (const dir of ["specs", "changes"]) {
66
+ const planningRoot = path.join(repoPath, "openspec", dir);
67
+ // oxlint-disable-next-line no-await-in-loop -- two probes, first hit wins
68
+ if (await exists(planningRoot)) return `${planningRoot} is the repository's own OpenSpec root`;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Whether Git would show the pointer, asked of Git itself so a re-including
75
+ * rule anywhere above the exclude file counts. `null` when the pointer stays
76
+ * out of every diff, including outside a Git work tree.
77
+ */
78
+ export async function pointerIgnoreProblem(repoPath: string): Promise<string | null> {
79
+ const pointer = OPENSPEC_POINTER_PATH.split(path.sep).join("/");
80
+ try {
81
+ await execFileAsync("git", ["check-ignore", "-q", pointer], { cwd: repoPath });
82
+ return null;
83
+ } catch (error) {
84
+ if ((error as { code?: unknown }).code !== 1) return null;
85
+ }
86
+ const { stdout } = await execFileAsync("git", ["check-ignore", "-v", "-n", pointer], {
87
+ cwd: repoPath,
88
+ }).catch(() => ({ stdout: "" }));
89
+ const [source, line, pattern] = stdout.split("\t")[0]?.split(":") ?? [];
90
+ return pattern
91
+ ? `${pointer} is re-included by "${pattern}" (${source}:${line})`
92
+ : `${pointer} is not ignored by Git`;
93
+ }
94
+
95
+ async function persistPointer(repoPath: string, id: string): Promise<void> {
96
+ await fs.mkdir(path.dirname(pointerFile(repoPath)), { recursive: true });
97
+ await fs.writeFile(pointerFile(repoPath), renderPointer(id), "utf8");
98
+ }
99
+
100
+ export async function writeStorePointer(
101
+ repoPath: string,
102
+ id: string,
103
+ ): Promise<"written" | "current"> {
104
+ const current = await readStorePointer(repoPath);
105
+ if (current.kind === "mate" && current.id === id) return "current";
106
+ await persistPointer(repoPath, id);
107
+ return "written";
108
+ }
109
+
110
+ /** Only Mate's pointer; `openspec/` goes too when nothing else is left in it. */
111
+ export async function removeStorePointer(repoPath: string): Promise<"removed" | "absent"> {
112
+ if ((await readStorePointer(repoPath)).kind !== "mate") return "absent";
113
+ await fs.rm(pointerFile(repoPath));
114
+ await fs.rmdir(path.dirname(pointerFile(repoPath))).catch(() => {});
115
+ return "removed";
116
+ }
117
+
118
+ export async function storePointerPresent(repoPath: string): Promise<boolean> {
119
+ return (await readStorePointer(repoPath)).kind === "mate";
120
+ }
121
+
122
+ /** Keeps the exclusion line exactly while the repository may carry Mate's pointer. */
123
+ export async function reconcileStorePointerExclude(repoPath: string): Promise<boolean> {
124
+ const eligible = (await storePointerIneligibility(repoPath)) === null;
125
+ return reconcileWorkingRepoCapabilityExcludes(
126
+ repoPath,
127
+ eligible ? [OPENSPEC_POINTER_EXCLUDE] : [],
128
+ MANAGED_POINTER_EXCLUDES,
129
+ );
130
+ }
131
+
132
+ export async function removeStorePointerExclude(repoPath: string): Promise<"removed" | "absent"> {
133
+ return (await reconcileWorkingRepoCapabilityExcludes(repoPath, [], MANAGED_POINTER_EXCLUDES))
134
+ ? "removed"
135
+ : "absent";
136
+ }
137
+
138
+ function pointerNotWritten(reason: string): ProjectionWriteReport {
139
+ return { state: "skipped", warnings: [`OpenSpec store pointer not written: ${reason}`] };
140
+ }
141
+
142
+ /**
143
+ * Eligibility, then registration, then the pointer: a pointer naming a store
144
+ * OpenSpec cannot resolve would break every root-resolving command. Git is
145
+ * asked about the pointer only before it is created or rewritten, so a
146
+ * steady-state refresh spawns nothing.
147
+ */
148
+ export async function projectStorePointer(
149
+ repoPath: string,
150
+ companionPath: string,
151
+ ): Promise<ProjectionWriteState | ProjectionWriteReport> {
152
+ /** A companion without OpenSpec has no store to point at; nothing to say. */
153
+ if (!(await exists(path.join(companionPath, "openspec")))) return "skipped";
154
+ const ineligible = await storePointerIneligibility(repoPath);
155
+ if (ineligible) return pointerNotWritten(ineligible);
156
+ const registration = await ensureStoreRegistration(companionPath);
157
+ if (registration.state === "skipped") return pointerNotWritten(registration.reason);
158
+ const current = await readStorePointer(repoPath);
159
+ if (current.kind === "mate" && current.id === registration.id) return registration.state;
160
+ const ignoreProblem = await pointerIgnoreProblem(repoPath);
161
+ if (ignoreProblem) return pointerNotWritten(ignoreProblem);
162
+ await persistPointer(repoPath, registration.id);
163
+ return "written";
164
+ }
@@ -10,6 +10,7 @@ import {
10
10
  GRAPHIFY_OUTPUT_SUBDIR,
11
11
  GRAPHIFY_STORE_SEGMENT,
12
12
  } from "../../tools/setup/capabilities/graphify";
13
+ import { runtimeWrapperBinPath } from "../../runtime/runtime-directory";
13
14
  import { getReactDoctorBinPath, getWrapperBinPath } from "../package-paths";
14
15
  import type { LinkedRepository } from "./types";
15
16
 
@@ -25,17 +26,22 @@ const COMPANION_GRAPHIFY_REPOSITORY_ID = "__companion__";
25
26
  * Both bin paths and `GRAPHIFY_OUT` are unconditional: a path exists whether or
26
27
  * not its Capability is enabled, and enablement is a predicate, read live from
27
28
  * the companion rather than cached here.
29
+ *
30
+ * The persisted record names the runtime directory so it survives a reinstall;
31
+ * a launch passes the live install path, since its runtime-directory refresh
32
+ * may only have warned.
28
33
  */
29
34
  export function buildProjection(
30
35
  companionPath: string,
31
36
  repository: LinkedRepository,
37
+ wrapperBinPath: string = runtimeWrapperBinPath(),
32
38
  ): MateProjection {
33
39
  return {
34
40
  version,
35
41
  companionPath,
36
42
  repositoryPath: repository.path,
37
43
  repositoryId: repository.id,
38
- wrapperBinPath: getWrapperBinPath(),
44
+ wrapperBinPath,
39
45
  reactDoctorBinPath: getReactDoctorBinPath(),
40
46
  graphifyOut: path.join(
41
47
  companionPath,