@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.
@@ -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
- /** A degradable entry's failure is reported in the outcomes and nowhere else. */
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
- outcomes.push(outcome(entry, await removal.remove(input)));
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, process.env, api.location.directory).catch(() => []);
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 managed launch
11
- * that failed to inject its payload is broken and must say so.
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
- * A missing payload while a launch environment *is* present stays a hard error.
30
- * That combination means a managed launch failed to inject, which the projection
31
- * must not paper over.
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 };