@uniqbit/mate-core 0.18.3 → 0.18.5
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/companion.ts +20 -10
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +11 -7
- package/package.json +1 -1
- package/src/cli/commands/artifact/finish/openspec.ts +40 -8
- package/src/cli/commands/cap/index-cmd.ts +2 -11
- package/src/cli/commands/doctor.ts +88 -0
- package/src/lib/orchestrator/adapters/base.ts +3 -2
- package/src/lib/orchestrator/adapters/opencode.ts +33 -10
- package/src/lib/orchestrator/config-store.ts +6 -1
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-entries.ts +45 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
- package/src/lib/orchestrator/projection-types.ts +7 -1
- package/src/lib/orchestrator/working-repo-projection.ts +2 -1
- package/src/lib/orchestrator/wrap-pass.ts +4 -0
- package/src/opencode/companion-hooks.ts +7 -4
- package/src/opencode/projected-guidance.ts +6 -13
- package/src/runtime/projection.ts +1 -1
- package/src/tools/setup/capabilities/openspec.ts +149 -18
- package/src/tools/setup/capabilities/tokensave.ts +11 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +59 -11
- package/src/tools/setup/dynamic-plugins/declarations.ts +2 -1
- package/src/tools/setup/working-repo-cleanup.ts +2 -0
- package/src/tools/setup/working-repo-local-state.ts +9 -1
- 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
|
+
}
|
|
@@ -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",
|
|
@@ -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"
|
|
@@ -85,6 +87,8 @@ export type ProjectionEntryRemoval =
|
|
|
85
87
|
| {
|
|
86
88
|
readonly by: "self";
|
|
87
89
|
remove(input: ProjectionRemovalInput): Promise<"removed" | "absent">;
|
|
90
|
+
/** What removing this path deliberately leaves outside the repository, named in the outcome. */
|
|
91
|
+
readonly retains?: string;
|
|
88
92
|
}
|
|
89
93
|
| { readonly by: "entry"; readonly entry: ProjectionEntryId }
|
|
90
94
|
| { readonly by: "retained"; readonly because: string };
|
|
@@ -130,6 +134,8 @@ export interface ProjectionEntryOutcome {
|
|
|
130
134
|
warnings?: string[];
|
|
131
135
|
/** Present only when the entry wrote or withdrew paths it reports one by one. */
|
|
132
136
|
changed?: ProjectionChangedPaths;
|
|
137
|
+
/** Present only on a removal that leaves something outside the repository behind. */
|
|
138
|
+
retains?: string;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
export interface ProjectionResult {
|
|
@@ -157,7 +163,7 @@ export interface ProjectionDescription {
|
|
|
157
163
|
projection: ProjectionFile | null;
|
|
158
164
|
}
|
|
159
165
|
|
|
160
|
-
/**
|
|
166
|
+
/** Skips a degradable entry's failure: it never fails a pass, only shows in the outcomes. */
|
|
161
167
|
export function firstFailure(outcomes: ProjectionEntryOutcome[]): ProjectionEntryOutcome | null {
|
|
162
168
|
return outcomes.find((outcome) => outcome.state === "failed" && !outcome.degradable) ?? null;
|
|
163
169
|
}
|
|
@@ -120,7 +120,8 @@ export async function unproject(input: ProjectionRemovalInput): Promise<Projecti
|
|
|
120
120
|
}
|
|
121
121
|
try {
|
|
122
122
|
// oxlint-disable-next-line no-await-in-loop -- reverse catalogue order is the contract
|
|
123
|
-
|
|
123
|
+
const removed = outcome(entry, await removal.remove(input));
|
|
124
|
+
outcomes.push(removal.retains ? { ...removed, retains: removal.retains } : removed);
|
|
124
125
|
} catch (error) {
|
|
125
126
|
outcomes.push(outcome(entry, "failed", toError(error)));
|
|
126
127
|
}
|
|
@@ -124,6 +124,10 @@ export async function runWrapPass(
|
|
|
124
124
|
warnings: [
|
|
125
125
|
...rendered.warnings,
|
|
126
126
|
...result.outcomes.flatMap((outcome) => outcome.warnings ?? []),
|
|
127
|
+
/** A degradable failure does not fail the pass, but it is never silent. */
|
|
128
|
+
...result.outcomes
|
|
129
|
+
.filter((outcome) => outcome.state === "failed" && outcome.degradable)
|
|
130
|
+
.map((outcome) => entryFailureMessage(outcome, repository.id)),
|
|
127
131
|
],
|
|
128
132
|
runtimes,
|
|
129
133
|
wrapState,
|
|
@@ -99,6 +99,7 @@ export async function guardToolInput(
|
|
|
99
99
|
context: CompanionContext,
|
|
100
100
|
tool: string,
|
|
101
101
|
input: unknown,
|
|
102
|
+
env: Record<string, string | undefined> = process.env,
|
|
102
103
|
): Promise<void> {
|
|
103
104
|
const args = (input ?? {}) as Record<string, unknown>;
|
|
104
105
|
const filePaths =
|
|
@@ -113,25 +114,27 @@ export async function guardToolInput(
|
|
|
113
114
|
throw new Error(buildArtifactError(context, filePath));
|
|
114
115
|
}
|
|
115
116
|
// oxlint-disable-next-line no-await-in-loop -- the first refusal wins
|
|
116
|
-
await refuseForkedCompanionWrite(context, filePath);
|
|
117
|
+
await refuseForkedCompanionWrite(context, filePath, env);
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
122
|
* Registers the companion guardrails on an OpenCode V2 plugin context.
|
|
122
|
-
* The caller resolves `context` and skips this when no companion resolved
|
|
123
|
+
* The caller resolves `context` and skips this when no companion resolved;
|
|
124
|
+
* `env` is the launch environment the caller decided to trust.
|
|
123
125
|
*/
|
|
124
126
|
export async function registerCompanionHooks(
|
|
125
127
|
api: Context,
|
|
126
128
|
context: CompanionContext,
|
|
129
|
+
env: Record<string, string | undefined> = process.env,
|
|
127
130
|
): Promise<void> {
|
|
128
131
|
/**
|
|
129
132
|
* Anything escaping the repair would cost the session every guardrail
|
|
130
133
|
* below to save a synchronization that is optional by design.
|
|
131
134
|
*/
|
|
132
|
-
await repairCompanionGitOnce(context,
|
|
135
|
+
await repairCompanionGitOnce(context, env, api.location.directory).catch(() => []);
|
|
133
136
|
|
|
134
137
|
await api.tool.hook("execute.before", (event) =>
|
|
135
|
-
guardToolInput(context, event.tool, event.input),
|
|
138
|
+
guardToolInput(context, event.tool, event.input, env),
|
|
136
139
|
);
|
|
137
140
|
}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { buildOpenCodeGuidance } from "../runtime/companion-guidance";
|
|
2
2
|
import { MATE_ENV } from "../runtime/env-names";
|
|
3
|
-
import { hasLaunchEnvironment } from "../runtime/env";
|
|
4
3
|
import { parseGuidanceContent, type MateGuidanceFile } from "../runtime/guidance";
|
|
5
4
|
import { readCompanionPolicy } from "../runtime/policy";
|
|
6
5
|
import { resolveProjection } from "../runtime/projection";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* `null` guidance with no errors is the inert case — no companion resolves, so
|
|
10
|
-
* the plugin contributes nothing. Errors are startup failures: a
|
|
11
|
-
*
|
|
9
|
+
* the plugin contributes nothing. Errors are startup failures: a payload that
|
|
10
|
+
* does not parse, or projected guidance that cannot be built.
|
|
12
11
|
*/
|
|
13
12
|
export interface OpenCodeGuidanceResolution {
|
|
14
13
|
guidance: MateGuidanceFile | null;
|
|
@@ -26,9 +25,10 @@ export interface OpenCodeGuidanceResolution {
|
|
|
26
25
|
* means a Capability toggled after a wrap changes the guidance on the next
|
|
27
26
|
* session with no re-wrap.
|
|
28
27
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* Without a payload the projection decides, whatever other `MATE_*` variables
|
|
29
|
+
* `env` carries: those are inherited by every descendant of a Mate session and
|
|
30
|
+
* do not mark a managed launch. A present but malformed payload stays a hard
|
|
31
|
+
* error.
|
|
32
32
|
*/
|
|
33
33
|
export function resolveOpenCodeGuidance(
|
|
34
34
|
env: Record<string, string | undefined> = process.env,
|
|
@@ -40,13 +40,6 @@ export function resolveOpenCodeGuidance(
|
|
|
40
40
|
return { guidance: errors.length > 0 ? null : guidance, errors };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
if (hasLaunchEnvironment(env)) {
|
|
44
|
-
return {
|
|
45
|
-
guidance: null,
|
|
46
|
-
errors: [`missing ${MATE_ENV.guidanceJson} in the launch environment`],
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
43
|
const projection = resolveProjection(cwd);
|
|
51
44
|
if (!projection) return { guidance: null, errors: [] };
|
|
52
45
|
|
|
@@ -88,7 +88,7 @@ export function projectionEnvPath(repoRoot: string): string {
|
|
|
88
88
|
return path.join(repoLocalDirPath(repoRoot), PROJECTION_ENV_FILE);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
const generatedHeader = () => `# generated by ${FRAMEWORK_NAME} — do not edit`;
|
|
91
|
+
export const generatedHeader = (): string => `# generated by ${FRAMEWORK_NAME} — do not edit`;
|
|
92
92
|
|
|
93
93
|
export function renderProjectionYaml(file: ProjectionFile): string {
|
|
94
94
|
const document: Record<string, string> = { stamp: file.stamp };
|