@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.
- package/opencode-plugin/src/add-dir.ts +10 -76
- package/opencode-plugin/src/audience-layer.ts +91 -0
- package/opencode-plugin/src/companion.ts +28 -11
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +25 -8
- package/package.json +1 -1
- package/src/cli/commands/cap/index-cmd.ts +2 -11
- package/src/cli/commands/doctor.ts +86 -4
- package/src/cli/commands/install.ts +8 -0
- package/src/cli/commands/studio/terminal-launch.ts +16 -7
- package/src/cli/commands/wrap.ts +29 -4
- package/src/cli/usage.ts +2 -1
- package/src/hooks/session-guidance.ts +20 -2
- package/src/lib/orchestrator/adapters/base.ts +3 -2
- package/src/lib/orchestrator/adapters/claude.ts +130 -20
- package/src/lib/orchestrator/adapters/opencode.ts +55 -15
- package/src/lib/orchestrator/config-store.ts +74 -1
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +88 -35
- package/src/lib/orchestrator/projection-entries.ts +46 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
- package/src/lib/orchestrator/projection-types.ts +9 -1
- package/src/lib/orchestrator/session-prepare.ts +82 -4
- package/src/lib/orchestrator/working-repo-projection.ts +2 -1
- package/src/lib/orchestrator/wrap-pass.ts +92 -1
- package/src/opencode/companion-hooks.ts +7 -4
- package/src/opencode/projected-guidance.ts +6 -13
- package/src/runtime/audience-layer.ts +84 -0
- package/src/runtime/guidance.ts +17 -0
- package/src/runtime/opencode-audience-layer.ts +176 -0
- package/src/runtime/policy.ts +14 -1
- package/src/runtime/projection.ts +1 -1
- package/src/runtime/runtime-document-manifest.ts +17 -0
- package/src/runtime/wrap-state.ts +4 -0
- package/src/tools/setup/audience-isolation.fixture.ts +325 -0
- package/src/tools/setup/audience-layer.ts +207 -0
- package/src/tools/setup/audience-registrations.ts +52 -0
- package/src/tools/setup/capabilities/openspec.ts +9 -0
- package/src/tools/setup/capabilities/tokensave.ts +11 -0
- package/src/tools/setup/contribution-ledger.ts +207 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
- package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
- package/src/tools/setup/dynamic-plugins/hydrate.ts +7 -4
- package/src/tools/setup/engine.ts +141 -10
- package/src/tools/setup/isolation-diagnostics.ts +139 -0
- package/src/tools/setup/plugin.ts +19 -4
- package/src/tools/setup/plugins/gitignore.ts +7 -1
- package/src/tools/setup/providers/claude.ts +27 -2
- package/src/tools/setup/providers/opencode.ts +10 -0
- package/src/tools/setup/registry.ts +24 -2
- package/src/tools/setup/surface-target.ts +13 -4
- package/src/tools/setup/upgrade-sweep.ts +153 -0
- package/src/tools/setup/working-repo-cleanup.ts +2 -0
- package/src/tools/setup/working-repo-local-state.ts +9 -1
- package/src/tools/setup.ts +9 -6
- 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
|
|
70
|
-
* companion. Records follow the filesystem,
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
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
|
}
|