@uniqbit/mate-core 0.18.3 → 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 (56) hide show
  1. package/opencode-plugin/src/add-dir.ts +10 -76
  2. package/opencode-plugin/src/audience-layer.ts +91 -0
  3. package/opencode-plugin/src/companion.ts +28 -11
  4. package/opencode-plugin/src/launch-environment.ts +29 -0
  5. package/opencode-plugin/src/server.ts +25 -8
  6. package/package.json +1 -1
  7. package/src/cli/commands/cap/index-cmd.ts +2 -11
  8. package/src/cli/commands/doctor.ts +86 -4
  9. package/src/cli/commands/install.ts +8 -0
  10. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  11. package/src/cli/commands/wrap.ts +29 -4
  12. package/src/cli/usage.ts +2 -1
  13. package/src/hooks/session-guidance.ts +20 -2
  14. package/src/lib/orchestrator/adapters/base.ts +3 -2
  15. package/src/lib/orchestrator/adapters/claude.ts +130 -20
  16. package/src/lib/orchestrator/adapters/opencode.ts +55 -15
  17. package/src/lib/orchestrator/config-store.ts +74 -1
  18. package/src/lib/orchestrator/openspec-store.ts +202 -0
  19. package/src/lib/orchestrator/projection-claude-skills.ts +88 -35
  20. package/src/lib/orchestrator/projection-entries.ts +46 -0
  21. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  22. package/src/lib/orchestrator/projection-types.ts +9 -1
  23. package/src/lib/orchestrator/session-prepare.ts +82 -4
  24. package/src/lib/orchestrator/working-repo-projection.ts +2 -1
  25. package/src/lib/orchestrator/wrap-pass.ts +92 -1
  26. package/src/opencode/companion-hooks.ts +7 -4
  27. package/src/opencode/projected-guidance.ts +6 -13
  28. package/src/runtime/audience-layer.ts +84 -0
  29. package/src/runtime/guidance.ts +17 -0
  30. package/src/runtime/opencode-audience-layer.ts +176 -0
  31. package/src/runtime/policy.ts +14 -1
  32. package/src/runtime/projection.ts +1 -1
  33. package/src/runtime/runtime-document-manifest.ts +17 -0
  34. package/src/runtime/wrap-state.ts +4 -0
  35. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  36. package/src/tools/setup/audience-layer.ts +207 -0
  37. package/src/tools/setup/audience-registrations.ts +52 -0
  38. package/src/tools/setup/capabilities/openspec.ts +9 -0
  39. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  40. package/src/tools/setup/contribution-ledger.ts +207 -0
  41. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  42. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  43. package/src/tools/setup/dynamic-plugins/hydrate.ts +7 -4
  44. package/src/tools/setup/engine.ts +141 -10
  45. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  46. package/src/tools/setup/plugin.ts +19 -4
  47. package/src/tools/setup/plugins/gitignore.ts +7 -1
  48. package/src/tools/setup/providers/claude.ts +27 -2
  49. package/src/tools/setup/providers/opencode.ts +10 -0
  50. package/src/tools/setup/registry.ts +24 -2
  51. package/src/tools/setup/surface-target.ts +13 -4
  52. package/src/tools/setup/upgrade-sweep.ts +153 -0
  53. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  54. package/src/tools/setup/working-repo-local-state.ts +9 -1
  55. package/src/tools/setup.ts +9 -6
  56. package/wrappers/bin/openspec +42 -1
@@ -0,0 +1,202 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import { parse as parseYaml } from "yaml";
8
+
9
+ import { MATE_ENV } from "../../runtime/env-names";
10
+ import { runtimeWrapperBinPath } from "../../runtime/runtime-directory";
11
+ import { resolveCommandOnPath } from "../../tools/setup/utils";
12
+ import { getWrapperBinPath } from "../package-paths";
13
+
14
+ /**
15
+ * The Companion Repository as an OpenSpec store. OpenSpec reports the Working
16
+ * Repository as an edit root only when the CLI runs inside a project whose
17
+ * `openspec/config.yaml` declares a registered store, so the store has to be
18
+ * registered before any pointer may name it.
19
+ *
20
+ * Registration is checked by reading OpenSpec's registry file, never by
21
+ * spawning: the check runs on every session refresh. The CLI is spawned only to
22
+ * register, which OpenSpec answers authoritatively.
23
+ */
24
+
25
+ const execFileAsync = promisify(execFile);
26
+
27
+ /** OpenSpec's one id grammar, shared by store and change ids. */
28
+ const KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
29
+
30
+ const STORE_METADATA_FILE = path.join(".openspec-store", "store.yaml");
31
+
32
+ /** Injectable so the suite never touches the developer's registry or binary. */
33
+ export const openSpecStoreDeps = {
34
+ env: (): NodeJS.ProcessEnv => process.env,
35
+ platform: (): NodeJS.Platform => process.platform,
36
+ homedir: (): string => os.homedir(),
37
+ /** Mate's own `openspec` wrappers, which must never answer a registration. */
38
+ wrapperBinDirs: (): string[] => {
39
+ const launched = openSpecStoreDeps.env()[MATE_ENV.wrapperBinPath];
40
+ return [getWrapperBinPath(), runtimeWrapperBinPath(), ...(launched ? [launched] : [])];
41
+ },
42
+ resolveBinary: (): string | undefined => {
43
+ const excluded = new Set(openSpecStoreDeps.wrapperBinDirs().map((dir) => path.resolve(dir)));
44
+ const searchPath = (openSpecStoreDeps.env().PATH ?? "")
45
+ .split(path.delimiter)
46
+ .filter((dir) => dir && !excluded.has(path.resolve(dir)))
47
+ .join(path.delimiter);
48
+ return resolveCommandOnPath("openspec", searchPath);
49
+ },
50
+ };
51
+
52
+ export type StoreRegistrationCheck =
53
+ | { state: "registered" }
54
+ | { state: "absent" }
55
+ | { state: "conflict"; path: string }
56
+ | { state: "unreadable" };
57
+
58
+ export type StoreRegistration =
59
+ | { state: "written" | "current"; id: string }
60
+ | { state: "skipped"; reason: string };
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ async function canonicalPath(candidate: string): Promise<string> {
67
+ return fs.realpath(candidate).catch(() => path.resolve(candidate));
68
+ }
69
+
70
+ async function isDirectory(candidate: string): Promise<boolean> {
71
+ return fs
72
+ .stat(candidate)
73
+ .then((stats) => stats.isDirectory())
74
+ .catch(() => false);
75
+ }
76
+
77
+ function sanitizeStoreId(name: string): string {
78
+ return name
79
+ .toLowerCase()
80
+ .replace(/[^a-z0-9]+/g, "-")
81
+ .replace(/^-+|-+$/g, "");
82
+ }
83
+
84
+ /**
85
+ * The id a registration already recorded beside the companion, else the
86
+ * companion folder name made into a valid id. Machine-local on purpose: no
87
+ * artifact embeds it, and a committed one would collide across clones.
88
+ */
89
+ export async function deriveStoreId(companionPath: string): Promise<string> {
90
+ const metadata = await fs
91
+ .readFile(path.join(companionPath, STORE_METADATA_FILE), "utf8")
92
+ .then((content) => parseYaml(content) as unknown)
93
+ .catch(() => null);
94
+ if (isRecord(metadata) && typeof metadata.id === "string" && KEBAB_ID.test(metadata.id)) {
95
+ return metadata.id;
96
+ }
97
+ const folder = path.basename(path.resolve(companionPath));
98
+ const id = sanitizeStoreId(folder);
99
+ if (!KEBAB_ID.test(id)) {
100
+ throw new Error(`cannot derive an OpenSpec store id from the companion folder "${folder}"`);
101
+ }
102
+ return id;
103
+ }
104
+
105
+ /** Mirrors OpenSpec's `getGlobalDataDir`, so Mate reads the registry OpenSpec writes. */
106
+ export function openSpecDataDir(
107
+ env: NodeJS.ProcessEnv,
108
+ platform: NodeJS.Platform,
109
+ homedir: string,
110
+ ): string {
111
+ if (env.XDG_DATA_HOME) return path.join(env.XDG_DATA_HOME, "openspec");
112
+ if (platform === "win32") {
113
+ return path.join(env.LOCALAPPDATA ?? path.join(homedir, "AppData", "Local"), "openspec");
114
+ }
115
+ return path.join(homedir, ".local", "share", "openspec");
116
+ }
117
+
118
+ function registryPath(): string {
119
+ return path.join(
120
+ openSpecDataDir(
121
+ openSpecStoreDeps.env(),
122
+ openSpecStoreDeps.platform(),
123
+ openSpecStoreDeps.homedir(),
124
+ ),
125
+ "stores",
126
+ "registry.yaml",
127
+ );
128
+ }
129
+
130
+ /** Read-only: what the registry says about `id`, compared by canonical path. */
131
+ export async function checkStoreRegistration(
132
+ companionPath: string,
133
+ id: string,
134
+ ): Promise<StoreRegistrationCheck> {
135
+ let content: string;
136
+ try {
137
+ content = await fs.readFile(registryPath(), "utf8");
138
+ } catch (error) {
139
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
140
+ ? { state: "absent" }
141
+ : { state: "unreadable" };
142
+ }
143
+ let registry: unknown;
144
+ try {
145
+ registry = parseYaml(content);
146
+ } catch {
147
+ return { state: "unreadable" };
148
+ }
149
+ if (!isRecord(registry) || !isRecord(registry.stores)) return { state: "unreadable" };
150
+ const entry = registry.stores[id];
151
+ if (entry === undefined) return { state: "absent" };
152
+ const localPath =
153
+ isRecord(entry) && isRecord(entry.backend) ? entry.backend.local_path : undefined;
154
+ if (typeof localPath !== "string") return { state: "unreadable" };
155
+ return (await canonicalPath(localPath)) === (await canonicalPath(companionPath))
156
+ ? { state: "registered" }
157
+ : { state: "conflict", path: localPath };
158
+ }
159
+
160
+ function commandOutput(error: unknown): string {
161
+ const { stderr, stdout, message } = error as {
162
+ stderr?: string;
163
+ stdout?: string;
164
+ message: string;
165
+ };
166
+ return (stderr || stdout || message).trim();
167
+ }
168
+
169
+ /**
170
+ * Registers the companion when the registry lacks it. A conflicting id fails
171
+ * rather than silently picking another one; OpenSpec without stores, or a
172
+ * companion that is no OpenSpec root, is skipped.
173
+ */
174
+ export async function ensureStoreRegistration(companionPath: string): Promise<StoreRegistration> {
175
+ const companion = await canonicalPath(companionPath);
176
+ if (!(await isDirectory(path.join(companion, "openspec")))) {
177
+ return { state: "skipped", reason: `${companion} is not an OpenSpec root` };
178
+ }
179
+ const id = await deriveStoreId(companion);
180
+ const check = await checkStoreRegistration(companion, id);
181
+ if (check.state === "registered") return { state: "current", id };
182
+ if (check.state === "conflict") {
183
+ throw new Error(`OpenSpec store id "${id}" is already registered at ${check.path}`);
184
+ }
185
+
186
+ const bin = openSpecStoreDeps.resolveBinary();
187
+ if (!bin) return { state: "skipped", reason: "the openspec CLI is not on PATH" };
188
+ try {
189
+ await execFileAsync(bin, ["store", "register", companion, "--id", id, "--yes"], {
190
+ cwd: companion,
191
+ env: openSpecStoreDeps.env(),
192
+ timeout: 30_000,
193
+ });
194
+ } catch (error) {
195
+ const output = commandOutput(error);
196
+ if (/unknown command/i.test(output)) {
197
+ return { state: "skipped", reason: "the openspec CLI does not support stores" };
198
+ }
199
+ throw new Error(`openspec store register failed: ${output}`, { cause: error });
200
+ }
201
+ return { state: "written", id };
202
+ }
@@ -1,6 +1,8 @@
1
+ // oxlint-disable no-await-in-loop
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
 
5
+ import { CURRENT_LAYER_LINK } from "../../runtime/audience-layer";
4
6
  import { readProjectionFile } from "../../runtime/projection";
5
7
  import { realLinkTarget, realOrResolved } from "../fs-utils";
6
8
  import {
@@ -64,63 +66,114 @@ async function createSkillLink(target: string, link: string): Promise<void> {
64
66
  await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
65
67
  }
66
68
 
69
+ const CLAUDE_AGENTS_PATH = path.join(".claude", "agents");
70
+
71
+ /** The manifest key of an agent link: repo-relative, POSIX separators. */
72
+ function agentKey(file: string): string {
73
+ return `.claude/agents/${file}`;
74
+ }
75
+
76
+ async function agentFiles(root: string): Promise<string[]> {
77
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
78
+ return entries
79
+ .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md"))
80
+ .map((entry) => entry.name);
81
+ }
82
+
83
+ /**
84
+ * Every link the wrap wants, by manifest key: the companion's skills, then the
85
+ * wrapped audience's skills and agents. An audience link targets the layer
86
+ * through its `current` link, so a re-rendered layer never strands it. A
87
+ * companion skill wins a name both provide.
88
+ */
89
+ async function wantedLinks(
90
+ currentRoot: string | null,
91
+ layerRoot: string | null,
92
+ ): Promise<Map<string, string>> {
93
+ const wanted = new Map<string, string>();
94
+ if (currentRoot) {
95
+ for (const name of await skillNames(currentRoot)) {
96
+ wanted.set(recordKey(name), path.join(currentRoot, name));
97
+ }
98
+ }
99
+ if (layerRoot) {
100
+ const layerSkills = path.join(layerRoot, "skills");
101
+ for (const name of await skillNames(layerSkills)) {
102
+ if (!wanted.has(recordKey(name))) wanted.set(recordKey(name), path.join(layerSkills, name));
103
+ }
104
+ const layerAgents = path.join(layerRoot, "agents");
105
+ for (const file of await agentFiles(layerAgents)) {
106
+ wanted.set(agentKey(file), path.join(layerAgents, file));
107
+ }
108
+ }
109
+ return wanted;
110
+ }
111
+
67
112
  /**
68
113
  * Recorded link: Mate's while it resolves to its recorded target. Unrecorded:
69
- * adopted only when it resolves into a registered, current or projected
70
- * companion. Records follow the filesystem, so a partial failure re-adopts.
114
+ * a skill link is adopted only when it resolves into a registered, current or
115
+ * projected companion; an agent link never is. Records follow the filesystem,
116
+ * so a partial failure re-adopts.
71
117
  */
72
118
  export async function reconcileWorkingRepoClaudeSkillLinks(
73
119
  repoPath: string,
74
120
  companionPath: string | null,
75
121
  enabled: boolean,
76
122
  registeredCompanionPaths: string[] = [],
123
+ audienceLayerDir: string | null = null,
77
124
  ): Promise<"written" | "current"> {
125
+ const repoRoot = path.resolve(repoPath);
78
126
  const currentRoot = companionPath ? path.resolve(companionPath, CLAUDE_SKILLS_PATH) : null;
127
+ const layerRoot =
128
+ audienceLayerDir && enabled ? path.join(audienceLayerDir, CURRENT_LAYER_LINK, ".claude") : null;
79
129
  const roots = await registeredSkillRoots(repoPath, companionPath, registeredCompanionPaths);
80
- const targetRoot = path.join(path.resolve(repoPath), CLAUDE_SKILLS_PATH);
81
130
  const recorded = links(await readManifest(repoPath), "claude");
82
- const existing = await fs.readdir(targetRoot, { withFileTypes: true }).catch(() => []);
83
- const sourceNames: Set<string> =
84
- currentRoot && enabled ? new Set(await skillNames(currentRoot)) : new Set<string>();
131
+ const wanted = await wantedLinks(enabled ? currentRoot : null, layerRoot);
85
132
  const records: Record<string, LinkRecord> = {};
86
133
  let changed = false;
87
134
 
88
- for (const entry of existing) {
89
- if (!entry.isSymbolicLink()) continue;
90
- const link = path.join(targetRoot, entry.name);
91
- const target = await linkTarget(link);
92
- if (target === null) continue;
93
- const record = recorded[recordKey(entry.name)];
94
- const owned = record
95
- ? (await realOrResolved(record.target)) === target
96
- : roots.some((root) => path.dirname(target) === root);
97
- if (!owned) continue;
98
-
99
- const wanted = currentRoot ? path.join(currentRoot, entry.name) : null;
100
- if (wanted && sourceNames.has(entry.name) && (await realOrResolved(wanted)) === target) {
101
- records[recordKey(entry.name)] = { runtime: "claude", target: wanted };
102
- continue;
103
- }
135
+ const linkDirs = [
136
+ { dir: path.join(repoRoot, CLAUDE_SKILLS_PATH), keyOf: recordKey, adoptable: true },
137
+ { dir: path.join(repoRoot, CLAUDE_AGENTS_PATH), keyOf: agentKey, adoptable: false },
138
+ ];
139
+ for (const { dir, keyOf, adoptable } of linkDirs) {
140
+ const existing = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
141
+ for (const entry of existing) {
142
+ if (!entry.isSymbolicLink()) continue;
143
+ const link = path.join(dir, entry.name);
144
+ const target = await linkTarget(link);
145
+ if (target === null) continue;
146
+ const key = keyOf(entry.name);
147
+ const record = recorded[key];
148
+ const owned = record
149
+ ? (await realOrResolved(record.target)) === target
150
+ : adoptable && roots.some((root) => path.dirname(target) === root);
151
+ if (!owned) continue;
104
152
 
105
- await fs.unlink(link);
106
- changed = true;
107
- }
153
+ const want = wanted.get(key);
154
+ if (want && (await realOrResolved(want)) === target) {
155
+ records[key] = { runtime: "claude", target: want };
156
+ continue;
157
+ }
108
158
 
109
- if (currentRoot && sourceNames.size > 0) {
110
- await fs.mkdir(targetRoot, { recursive: true });
111
- for (const name of sourceNames) {
112
- const link = path.join(targetRoot, name);
113
- const existingLink = await fs.lstat(link).catch(() => null);
114
- if (existingLink) continue;
115
- const target = path.join(currentRoot, name);
116
- await createSkillLink(target, link);
117
- records[recordKey(name)] = { runtime: "claude", target };
159
+ await fs.unlink(link);
118
160
  changed = true;
119
161
  }
120
162
  }
121
163
 
164
+ for (const [key, target] of wanted) {
165
+ const link = path.join(repoRoot, ...key.split("/"));
166
+ const existingLink = await fs.lstat(link).catch(() => null);
167
+ if (existingLink) continue;
168
+ await fs.mkdir(path.dirname(link), { recursive: true });
169
+ if (key.startsWith(".claude/agents/")) await fs.symlink(target, link, "file");
170
+ else await createSkillLink(target, link);
171
+ records[key] = { runtime: "claude", target };
172
+ changed = true;
173
+ }
174
+
122
175
  if (changed || !enabled) {
123
- await pruneEmptyAncestors(targetRoot, path.resolve(repoPath));
176
+ for (const { dir } of linkDirs) await pruneEmptyAncestors(dir, repoRoot);
124
177
  }
125
178
  if (JSON.stringify(sortedEntries(records)) !== JSON.stringify(sortedEntries(recorded))) {
126
179
  await updateManifest(repoPath, (manifest) => withLinks(manifest, "claude", records));
@@ -37,6 +37,15 @@ import {
37
37
  removeCompanionCopies,
38
38
  withdrawCompanionCopies,
39
39
  } from "./projection-opencode-copies";
40
+ import {
41
+ OPENSPEC_POINTER_EXCLUDE,
42
+ OPENSPEC_POINTER_PATH,
43
+ projectStorePointer,
44
+ reconcileStorePointerExclude,
45
+ removeStorePointer,
46
+ removeStorePointerExclude,
47
+ storePointerPresent,
48
+ } from "./projection-openspec-store";
40
49
  import { buildProjection } from "./projection-record";
41
50
  import {
42
51
  CLAUDE_WORKING_SETTINGS_PATH,
@@ -245,6 +254,42 @@ function buildEntries(): readonly ProjectionEntry[] {
245
254
  input.companionPath && input.repository ? writeProjectionPairEntry(input) : "skipped",
246
255
  removal: { by: "entry", entry: "projection-root" },
247
256
  },
257
+ /**
258
+ * The OpenSpec store pointer's exclusion, then the pointer: the line is on
259
+ * disk before the file it hides. Its own line outside the managed block,
260
+ * disjoint from `capability-excludes`, so neither strips the other's.
261
+ */
262
+ {
263
+ id: "openspec-store-exclude",
264
+ kind: "merged",
265
+ path: path.join(".git", "info", "exclude"),
266
+ scopes: ["session", "wrap"],
267
+ write: async (input) =>
268
+ (await reconcileStorePointerExclude(input.repoPath)) ? "written" : "current",
269
+ degradable: true,
270
+ removal: { by: "self", remove: (input) => removeStorePointerExclude(input.repoPath) },
271
+ present: (repoPath) =>
272
+ workingRepoCapabilityExcludesPresent(repoPath, [OPENSPEC_POINTER_EXCLUDE]),
273
+ },
274
+ /**
275
+ * Degradable: without it OpenSpec still plans in the companion, it only
276
+ * names no Working Repository edit root — and the wrapper says so.
277
+ */
278
+ {
279
+ id: "openspec-store-pointer",
280
+ kind: "owned",
281
+ path: OPENSPEC_POINTER_PATH,
282
+ scopes: ["session", "wrap"],
283
+ write: async (input) =>
284
+ input.companionPath ? projectStorePointer(input.repoPath, input.companionPath) : "skipped",
285
+ degradable: true,
286
+ removal: {
287
+ by: "self",
288
+ remove: (input) => removeStorePointer(input.repoPath),
289
+ retains: "openspec-store-registration",
290
+ },
291
+ present: storePointerPresent,
292
+ },
248
293
  {
249
294
  id: "claude-skill-links",
250
295
  kind: "merged",
@@ -262,6 +307,7 @@ function buildEntries(): readonly ProjectionEntry[] {
262
307
  input.companionPath ?? null,
263
308
  input.config.allowedAgents?.includes("claude") ?? false,
264
309
  registeredCompanionPaths,
310
+ input.audienceLayerDir ?? null,
265
311
  );
266
312
  },
267
313
  removal: {
@@ -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
+ }
@@ -25,6 +25,8 @@ export type ProjectionEntryId =
25
25
  | "repo-local-framework"
26
26
  | "repo-local-registry"
27
27
  | "projection-pair"
28
+ | "openspec-store-exclude"
29
+ | "openspec-store-pointer"
28
30
  | "claude-skill-links"
29
31
  | "workspace-document"
30
32
  | "capability-excludes"
@@ -48,6 +50,8 @@ export interface ProjectionInput {
48
50
  runtimeDocuments?: readonly RenderedRuntimeDocument[];
49
51
  /** Restricts runtime-tagged entries to these runtimes; every runtime when absent. */
50
52
  runtimes?: readonly WrapRuntime[];
53
+ /** The layer directory of the audience the checkout is wrapped for; `null` or absent for base only. */
54
+ audienceLayerDir?: string | null;
51
55
  }
52
56
 
53
57
  export interface ProjectionRemovalInput {
@@ -85,6 +89,8 @@ export type ProjectionEntryRemoval =
85
89
  | {
86
90
  readonly by: "self";
87
91
  remove(input: ProjectionRemovalInput): Promise<"removed" | "absent">;
92
+ /** What removing this path deliberately leaves outside the repository, named in the outcome. */
93
+ readonly retains?: string;
88
94
  }
89
95
  | { readonly by: "entry"; readonly entry: ProjectionEntryId }
90
96
  | { readonly by: "retained"; readonly because: string };
@@ -130,6 +136,8 @@ export interface ProjectionEntryOutcome {
130
136
  warnings?: string[];
131
137
  /** Present only when the entry wrote or withdrew paths it reports one by one. */
132
138
  changed?: ProjectionChangedPaths;
139
+ /** Present only on a removal that leaves something outside the repository behind. */
140
+ retains?: string;
133
141
  }
134
142
 
135
143
  export interface ProjectionResult {
@@ -157,7 +165,7 @@ export interface ProjectionDescription {
157
165
  projection: ProjectionFile | null;
158
166
  }
159
167
 
160
- /** A degradable entry's failure is reported in the outcomes and nowhere else. */
168
+ /** Skips a degradable entry's failure: it never fails a pass, only shows in the outcomes. */
161
169
  export function firstFailure(outcomes: ProjectionEntryOutcome[]): ProjectionEntryOutcome | null {
162
170
  return outcomes.find((outcome) => outcome.state === "failed" && !outcome.degradable) ?? null;
163
171
  }