@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.
@@ -3,6 +3,8 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
5
 
6
+ import { Document, isMap, isScalar, isSeq, parseDocument, type YAMLMap, type YAMLSeq } from "yaml";
7
+
6
8
  import { getOpenSpecSchemaSelection } from "../../../lib/orchestrator/setup-compatibilities";
7
9
  import { getWrapperBinPath } from "../../../lib/package-paths";
8
10
  import { fetchPublicPackageVersion } from "../../../lib/public-npm";
@@ -55,6 +57,25 @@ const MATE_SCHEMA_SOURCE_ROOT = path.join(
55
57
  );
56
58
  const MATE_CONVENTIONS_SOURCE = path.join(MATE_SCHEMA_SOURCE_ROOT, "openspec-conventions.yaml");
57
59
 
60
+ /**
61
+ * Identifies Mate's own `rules.specs` entry, so a later release can replace its
62
+ * text instead of adding a second entry.
63
+ */
64
+ export const MATE_SPECS_RULE_MARKER = "[mate] ";
65
+
66
+ /**
67
+ * Delivered through `rules.specs`, the one config hook OpenSpec's archive and
68
+ * sync workflows read when writing main specs. Propose reads it for delta specs
69
+ * too, so the text binds main specs only.
70
+ */
71
+ export const MATE_SPECS_RULE = `${MATE_SPECS_RULE_MARKER}A main spec at openspec/specs/<capability>/spec.md MUST begin with flat frontmatter: type: spec, capability: <capability>, repository: <org/repository> (one scalar), areas: [<area>, ...] (a flat list in the delta's scope order), and tags: [openspec/spec]. Project repository and areas from the delta's paired scopes; never copy scopes, change, or delta tags onto a main spec. Delta specs under openspec/changes/<name>/specs/ keep their delta frontmatter. Full rules: openspec/mate-conventions.yaml.`;
72
+
73
+ /** What Mate writes besides its entry; anything beyond the two is user content. */
74
+ const MANAGED_CONFIG_SCHEMA_ONLY = "schema: mate-v1";
75
+
76
+ /** Unfolded output keeps the Mate entry on one line and user scalars unwrapped. */
77
+ const CONFIG_TO_STRING = { lineWidth: 0 } as const;
78
+
58
79
  /**
59
80
  * Runtime → OpenSpec CLI tools. OpenCode reads workflow skills from the shared
60
81
  * `agents` root; its own tool only contributes the `/opsx-*` commands.
@@ -66,6 +87,13 @@ const OPENSPEC_TOOLS_BY_RUNTIME = {
66
87
 
67
88
  const OPENSPEC_INSTALL_COMMAND = "npm install -g @fission-ai/openspec@latest";
68
89
 
90
+ /**
91
+ * Store metadata `openspec store register` writes beside the companion. Its id
92
+ * is machine-local, and companion Git sync would otherwise stash it on every
93
+ * launch. Persisted: the registration outlives the capability.
94
+ */
95
+ const OPENSPEC_GITIGNORE_ENTRIES = [".openspec-store/"];
96
+
69
97
  type OpenSpecTool =
70
98
  (typeof OPENSPEC_TOOLS_BY_RUNTIME)[keyof typeof OPENSPEC_TOOLS_BY_RUNTIME][number];
71
99
  type OpenSpecRunCommand = typeof runCommandSilently;
@@ -286,18 +314,129 @@ async function ensureOpenSpecInstalled(
286
314
  return true;
287
315
  }
288
316
 
289
- /** Only the config Mate itself wrote is removed; a user-edited config.yaml is left alone. */
317
+ function isMateSpecsRule(item: unknown): boolean {
318
+ return (
319
+ isScalar(item) &&
320
+ typeof item.value === "string" &&
321
+ item.value.startsWith(MATE_SPECS_RULE_MARKER)
322
+ );
323
+ }
324
+
325
+ /** The config's `rules.specs` sequence, `null` when absent, or `undefined` when a shape is foreign. */
326
+ function readSpecsRules(doc: Document): YAMLSeq | null | undefined {
327
+ if (doc.contents !== null && !isMap(doc.contents)) return undefined;
328
+ const rules = doc.get("rules", true);
329
+ if (rules === undefined || rules === null) return null;
330
+ if (!isMap(rules)) return undefined;
331
+ const specs = rules.get("specs", true);
332
+ if (specs === undefined || specs === null) return null;
333
+ return isSeq(specs) ? specs : undefined;
334
+ }
335
+
336
+ /**
337
+ * Removes Mate's `rules.specs` entry and any `rules.specs` list or `rules` map
338
+ * that leaves empty. Returns whether the document changed.
339
+ */
340
+ function removeMateSpecsRule(doc: Document): boolean {
341
+ const specs = readSpecsRules(doc);
342
+ if (!specs) return false;
343
+ const before = specs.items.length;
344
+ specs.items = specs.items.filter((item) => !isMateSpecsRule(item));
345
+ if (specs.items.length === before) return false;
346
+ if (specs.items.length === 0) doc.deleteIn(["rules", "specs"]);
347
+ const rules = doc.get("rules", true);
348
+ if (isMap(rules) && rules.items.length === 0) doc.delete("rules");
349
+ return true;
350
+ }
351
+
352
+ /**
353
+ * Ensures exactly one current Mate entry in `rules.specs`: appended when
354
+ * missing, replaced in place when outdated. Returns whether the document changed.
355
+ */
356
+ function upsertMateSpecsRule(doc: Document): boolean {
357
+ if (doc.contents === null) doc.contents = doc.createNode({}) as YAMLMap;
358
+ const specs = readSpecsRules(doc);
359
+ if (specs === null) {
360
+ doc.setIn(["rules", "specs"], doc.createNode([MATE_SPECS_RULE]));
361
+ return true;
362
+ }
363
+ if (!specs) return false;
364
+
365
+ const index = specs.items.findIndex(isMateSpecsRule);
366
+ if (index === -1) {
367
+ specs.add(doc.createNode(MATE_SPECS_RULE));
368
+ return true;
369
+ }
370
+ let changed = false;
371
+ const current = specs.items[index];
372
+ if (isScalar(current) && current.value !== MATE_SPECS_RULE) {
373
+ current.value = MATE_SPECS_RULE;
374
+ changed = true;
375
+ }
376
+ const deduplicated = specs.items.filter((item, i) => i === index || !isMateSpecsRule(item));
377
+ if (deduplicated.length !== specs.items.length) {
378
+ specs.items = deduplicated;
379
+ changed = true;
380
+ }
381
+ return changed;
382
+ }
383
+
384
+ /**
385
+ * Removes Mate's own config content: its `rules.specs` entry always, and the
386
+ * file only when what remains is exactly what Mate writes. User content stays.
387
+ */
290
388
  async function removeManagedOpenSpecConfig(companionPath: string): Promise<void> {
291
389
  const configPath = getOpenSpecConfigPath(companionPath);
390
+ let content: string;
292
391
  try {
293
- const content = await fs.readFile(configPath, "utf8");
294
- if (content.trim() === "schema: mate-v1") {
295
- await fs.rm(configPath, { force: true });
296
- await pruneEmptyAncestors(path.dirname(configPath), companionPath);
297
- }
392
+ content = await fs.readFile(configPath, "utf8");
393
+ } catch {
394
+ return;
395
+ }
396
+
397
+ let remaining = content;
398
+ if (content.trim() !== MANAGED_CONFIG_SCHEMA_ONLY) {
399
+ const doc = parseDocument(content);
400
+ if (doc.errors.length > 0 || !removeMateSpecsRule(doc)) return;
401
+ remaining = doc.toString(CONFIG_TO_STRING);
402
+ }
403
+
404
+ if (remaining.trim() === MANAGED_CONFIG_SCHEMA_ONLY) {
405
+ await fs.rm(configPath, { force: true });
406
+ await pruneEmptyAncestors(path.dirname(configPath), companionPath);
407
+ } else {
408
+ await fs.writeFile(configPath, remaining, "utf8");
409
+ }
410
+ }
411
+
412
+ /** Writes a fresh Mate config, or merges Mate's entry into an existing one; writes only on change. */
413
+ async function ensureManagedOpenSpecConfig(companionPath: string): Promise<void> {
414
+ const configPath = getOpenSpecConfigPath(companionPath);
415
+ let content: string | undefined;
416
+ try {
417
+ content = await fs.readFile(configPath, "utf8");
298
418
  } catch {
299
- /* no managed config present */
419
+ /* no config yet */
420
+ }
421
+
422
+ if (content === undefined) {
423
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
424
+ const fresh = new Document({ schema: "mate-v1", rules: { specs: [MATE_SPECS_RULE] } });
425
+ await fs.writeFile(configPath, fresh.toString(CONFIG_TO_STRING), "utf8");
426
+ return;
300
427
  }
428
+
429
+ // config.yaml is user-editable once created; only Mate's own entry is touched.
430
+ const doc = parseDocument(content);
431
+ if (doc.errors.length > 0) {
432
+ process.stderr.write(
433
+ `openspec: left openspec/config.yaml unchanged, it is not valid YAML: ${doc.errors[0].message.split("\n")[0]}\n`,
434
+ );
435
+ return;
436
+ }
437
+ if (!upsertMateSpecsRule(doc)) return;
438
+ const merged = doc.toString(CONFIG_TO_STRING);
439
+ if (merged !== content) await fs.writeFile(configPath, merged, "utf8");
301
440
  }
302
441
 
303
442
  async function teardownOpenSpecSchema(companionPath: string): Promise<void> {
@@ -333,17 +472,7 @@ async function reconcileOpenSpecSchema(ctx: SetupContext): Promise<void> {
333
472
  return;
334
473
  }
335
474
 
336
- const configPath = getOpenSpecConfigPath(ctx.companionPath);
337
- await fs.mkdir(path.dirname(configPath), { recursive: true });
338
-
339
- // config.yaml is user-editable once created; never overwrite it on re-sync.
340
- const configExists = await fs
341
- .access(configPath)
342
- .then(() => true)
343
- .catch(() => false);
344
- if (configExists) return;
345
-
346
- await fs.writeFile(configPath, "schema: mate-v1\n", "utf8");
475
+ await ensureManagedOpenSpecConfig(ctx.companionPath);
347
476
  }
348
477
 
349
478
  export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityPlugin {
@@ -394,6 +523,8 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
394
523
  },
395
524
  };
396
525
  },
526
+ gitignoreEntries: () => OPENSPEC_GITIGNORE_ENTRIES,
527
+ persistGitignoreEntries: true,
397
528
  getInstallRequirements: (): InstallRequirement[] => [
398
529
  {
399
530
  id: "capability:openspec",
@@ -14,6 +14,8 @@ export { TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES } from "./tokensave-shared";
14
14
  export const TOKENSAVE_SUPPORTED_AGENTS = new Set(["claude", "opencode"]);
15
15
  export const TOKENSAVE_STORE_DIR = ".tokensave";
16
16
  export const TOKENSAVE_STORE_CONFIG_FILE = "config.json";
17
+ /** The index itself: `tokensave sync` refuses a store without it. */
18
+ const TOKENSAVE_STORE_DATABASE_FILE = "tokensave.db";
17
19
  export const TOKENSAVE_MIN_RUST_VERSION = "1.91.0";
18
20
  /** Single-graph posture Mate owns in the per-project store config; every other key is left as found. */
19
21
  const TOKENSAVE_OWNED_POSTURE = { auto_track: false, suppress_scope_warning: true } as const;
@@ -357,6 +359,15 @@ async function deactivateTokensaveBranchMetadata(repoPath: string): Promise<void
357
359
  }
358
360
  }
359
361
 
362
+ /**
363
+ * Whether the working repo holds a TokenSave index `tokensave sync` accepts.
364
+ * The database, not the store folder: an empty or half-initialized
365
+ * `.tokensave/` must still get `tokensave init`, or every pass fails on sync.
366
+ */
367
+ export async function isTokensaveIndexed(repoPath: string): Promise<boolean> {
368
+ return fileExists(path.join(repoPath, TOKENSAVE_STORE_DIR, TOKENSAVE_STORE_DATABASE_FILE));
369
+ }
370
+
360
371
  /**
361
372
  * Reconciles Mate's single-graph branching posture into the working repo's
362
373
  * git-excluded `.tokensave/config.json` and deactivates existing branch
@@ -4,10 +4,19 @@ import path from "node:path";
4
4
  import { parse } from "yaml";
5
5
 
6
6
  import { FRAMEWORK_NAME } from "../../../framework";
7
- import type { PluginDeclaration } from "../../../lib/orchestrator/types";
7
+ import { ConfigError, type PluginDeclaration } from "../../../lib/orchestrator/types";
8
8
 
9
9
  export const AUDIENCE_ENV = "MATE_AUDIENCE";
10
10
 
11
+ /**
12
+ * Mate 0.18.4 saved the base set under `audiences.base`. That shape is read
13
+ * back for compatibility, never written: the next save uses the root shape.
14
+ */
15
+ export const BASE_AUDIENCE = "base";
16
+
17
+ /** The keys 0.18.4 moved under `audiences.base`; companion-wide keys stayed at the root. */
18
+ export const BASE_AUDIENCE_KEYS = ["capabilities", "plugins", "studio"] as const;
19
+
11
20
  export type AudienceResolution =
12
21
  | { ok: true; active: string | null }
13
22
  | { ok: false; requested: string; declared: string[] };
@@ -21,7 +30,8 @@ function pluginList(value: unknown): unknown[] {
21
30
  }
22
31
 
23
32
  function audienceEntry(raw: unknown, audience: string | null): unknown {
24
- if (audience === null || !isRecord(raw) || !isRecord(raw.audiences)) return undefined;
33
+ if (audience === null || audience === BASE_AUDIENCE) return undefined;
34
+ if (!isRecord(raw) || !isRecord(raw.audiences)) return undefined;
25
35
  return Object.hasOwn(raw.audiences, audience) ? raw.audiences[audience] : undefined;
26
36
  }
27
37
 
@@ -32,6 +42,38 @@ function studioAgent(value: unknown): unknown {
32
42
  return value.studio.terminal.agent;
33
43
  }
34
44
 
45
+ /**
46
+ * The base set at the root, whichever shape the file uses: `audiences.base`
47
+ * keys are lifted to the root and `base` leaves `audiences`. A file in the
48
+ * root shape is returned as it is. Throws when a key is declared in both
49
+ * places, or `audiences.base` holds a key the base set does not have.
50
+ */
51
+ export function normalizeBaseAudience<T>(raw: T): T {
52
+ if (!isRecord(raw) || !isRecord(raw.audiences) || !Object.hasOwn(raw.audiences, BASE_AUDIENCE)) {
53
+ return raw;
54
+ }
55
+ const { [BASE_AUDIENCE]: baseEntry, ...others } = raw.audiences;
56
+ const lifted: Record<string, unknown> = { ...raw };
57
+ if (isRecord(baseEntry)) {
58
+ for (const [key, value] of Object.entries(baseEntry)) {
59
+ if (!(BASE_AUDIENCE_KEYS as readonly string[]).includes(key)) {
60
+ throw new ConfigError(
61
+ `audiences.${BASE_AUDIENCE} may contain only ${BASE_AUDIENCE_KEYS.join(", ")}; "${key}" belongs at the root of framework.yaml.`,
62
+ );
63
+ }
64
+ if (Object.hasOwn(raw, key)) {
65
+ throw new ConfigError(
66
+ `"${key}" is declared both at the root of framework.yaml and under audiences.${BASE_AUDIENCE}; keep one.`,
67
+ );
68
+ }
69
+ lifted[key] = value;
70
+ }
71
+ }
72
+ if (Object.keys(others).length > 0) lifted.audiences = others;
73
+ else delete lifted.audiences;
74
+ return lifted as T;
75
+ }
76
+
35
77
  /** `MATE_AUDIENCE` from the environment; unset, empty or blank means none. */
36
78
  export function readAudienceSelection(env: Record<string, string | undefined>): string | null {
37
79
  const value = env[AUDIENCE_ENV]?.trim();
@@ -40,11 +82,13 @@ export function readAudienceSelection(env: Record<string, string | undefined>):
40
82
 
41
83
  /** Audience names declared by raw parsed `framework.yaml`, in declared order. */
42
84
  export function declaredAudiences(raw: unknown): string[] {
43
- return isRecord(raw) && isRecord(raw.audiences) ? Object.keys(raw.audiences) : [];
85
+ return isRecord(raw) && isRecord(raw.audiences)
86
+ ? Object.keys(raw.audiences).filter((name) => name !== BASE_AUDIENCE)
87
+ : [];
44
88
  }
45
89
 
46
90
  export function resolveAudience(raw: unknown, audience: string | null): AudienceResolution {
47
- if (audience === null) return { ok: true, active: null };
91
+ if (audience === null || audience === BASE_AUDIENCE) return { ok: true, active: null };
48
92
  const declared = declaredAudiences(raw);
49
93
  if (declared.includes(audience)) return { ok: true, active: audience };
50
94
  return { ok: false, requested: audience, declared };
@@ -55,7 +99,7 @@ export function resolveAudience(raw: unknown, audience: string | null): Audience
55
99
  * its own `policy` and `config`. An unknown audience contributes nothing.
56
100
  */
57
101
  export function effectivePluginDeclarations(raw: unknown, audience: string | null): unknown[] {
58
- return [...pluginList(raw), ...pluginList(audienceEntry(raw, audience))];
102
+ return [...pluginList(normalizeBaseAudience(raw)), ...pluginList(audienceEntry(raw, audience))];
59
103
  }
60
104
 
61
105
  /**
@@ -64,7 +108,7 @@ export function effectivePluginDeclarations(raw: unknown, audience: string | nul
64
108
  * version. Runtime declarations must come from `effectivePluginDeclarations`.
65
109
  */
66
110
  export function allPluginDeclarations(raw: unknown): unknown[] {
67
- const all = [...pluginList(raw)];
111
+ const all = [...pluginList(normalizeBaseAudience(raw))];
68
112
  for (const name of declaredAudiences(raw)) {
69
113
  all.push(...pluginList(audienceEntry(raw, name)));
70
114
  }
@@ -96,24 +140,28 @@ export function installPluginDeclarations(config: unknown): PluginDeclaration[]
96
140
  * as found: the caller owns rejecting a value that is not a safe name.
97
141
  */
98
142
  export function effectiveStudioAgent(raw: unknown, audience: string | null): unknown {
99
- return studioAgent(audienceEntry(raw, audience)) ?? studioAgent(raw);
143
+ return studioAgent(audienceEntry(raw, audience)) ?? studioAgent(normalizeBaseAudience(raw));
100
144
  }
101
145
 
102
146
  /**
103
- * Raw read of the companion's `framework.yaml`. Deliberately avoids
104
- * `ConfigStore.load()` — hydration runs on every invocation and must never
105
- * create or migrate config files as a side effect.
147
+ * Raw read of the companion's `framework.yaml`, the base set at the root
148
+ * whichever shape the file uses. Deliberately avoids `ConfigStore.load()` —
149
+ * hydration runs on every invocation and must never create or migrate config
150
+ * files as a side effect.
106
151
  */
107
152
  export async function readFrameworkRaw(companionPath: string): Promise<unknown> {
153
+ let raw: unknown;
108
154
  try {
109
155
  const text = await fs.readFile(
110
156
  path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
111
157
  "utf8",
112
158
  );
113
- return (parse(text) as unknown) ?? null;
159
+ raw = (parse(text) as unknown) ?? null;
114
160
  } catch {
115
161
  return null;
116
162
  }
163
+ /** Outside the catch: a base key in both places is the operator's to fix, never silently base-less. */
164
+ return normalizeBaseAudience(raw);
117
165
  }
118
166
 
119
167
  export async function readEffectiveDeclarations(
@@ -6,6 +6,7 @@ import { parse } from "yaml";
6
6
  import { FRAMEWORK_NAME } from "../../../framework";
7
7
  import { PLUGIN_DECLARATION_POLICIES } from "../../../lib/orchestrator/config-store";
8
8
  import type { PluginDeclaration } from "../../../lib/orchestrator/types";
9
+ import { normalizeBaseAudience } from "./audiences";
9
10
 
10
11
  function isRecord(value: unknown): value is Record<string, unknown> {
11
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -22,7 +23,7 @@ export async function readDeclarations(companionPath: string): Promise<unknown[]
22
23
  path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
23
24
  "utf8",
24
25
  );
25
- const parsed = parse(raw) as unknown;
26
+ const parsed = normalizeBaseAudience(parse(raw) as unknown);
26
27
  if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return [];
27
28
  return parsed.plugins;
28
29
  } catch {
@@ -18,6 +18,7 @@ const REPORTED_AS: Partial<
18
18
  > = {
19
19
  "projection-root": { as: "removed", label: ".mate" },
20
20
  "git-excludes": { as: "removed", label: "git-excludes" },
21
+ "openspec-store-pointer": { as: "removed", label: "openspec/config.yaml" },
21
22
  "claude-working-settings": { as: "updated", label: "claude-settings" },
22
23
  "legacy-tokensave-claude-md": { as: "updated", label: "claude-settings" },
23
24
  };
@@ -36,6 +37,7 @@ export async function cleanupWorkingRepository(
36
37
  retained.push(entry.id);
37
38
  continue;
38
39
  }
40
+ if (entry.retains && !retained.includes(entry.retains)) retained.push(entry.retains);
39
41
  if (entry.state !== "removed") continue;
40
42
  const reported = REPORTED_AS[entry.id];
41
43
  if (!reported) continue;
@@ -118,8 +118,16 @@ export async function reconcileWorkingRepoCapabilityExcludes(
118
118
  if (!excludePath) return false;
119
119
  const existing = await readExclude(excludePath);
120
120
  const managed = new Set(managedEntries);
121
+ const desired = new Set(desiredEntries);
122
+ /**
123
+ * A desired line stays where it stands: re-appending it would trade places
124
+ * with a block written after it, like the companion copies', on every pass.
125
+ */
121
126
  const lines = trimTrailingEmptyLines(
122
- contentLines(existing).filter((line) => !managed.has(line.trim())),
127
+ contentLines(existing).filter((line) => {
128
+ const entry = line.trim();
129
+ return desired.has(entry) || !managed.has(entry);
130
+ }),
123
131
  );
124
132
  for (const entry of desiredEntries) {
125
133
  if (!lines.some((line) => line.trim() === entry)) lines.push(entry);
@@ -30,10 +30,12 @@ _mate_same_dir() {
30
30
  }
31
31
 
32
32
  _companion="$MATE_ARTIFACT_PATH"
33
+ _repo="$MATE_REPO_PATH"
33
34
  if [ -z "$_companion" ]; then
34
35
  _projection="$(_mate_projection_file)"
35
36
  if [ -n "$_projection" ]; then
36
37
  _companion="$(_mate_projected "$_projection" MATE_ARTIFACT_PATH)"
38
+ _repo="$(_mate_projected "$_projection" MATE_REPO_PATH)"
37
39
  _projected_wrapper_dir="$(_mate_projected "$_projection" MATE_WRAPPER_BIN_PATH)"
38
40
  if [ -n "$_companion" ] && ! _mate_same_dir "$_projected_wrapper_dir" "$_this_dir"; then
39
41
  {
@@ -92,4 +94,43 @@ if [ "$command" = "templates" ]; then
92
94
  fi
93
95
  fi
94
96
  fi
95
- exec env -C "$_companion" "$_real" "$@"
97
+
98
+ _mate_is_under() {
99
+ case "$1/" in "$2"/*) return 0 ;; esac
100
+ return 1
101
+ }
102
+
103
+ # Moves _cwd to the Working Repository root when the caller is inside that
104
+ # repository, outside the companion, and the root carries Mate's pointer.
105
+ _mate_use_store_pointer() {
106
+ [ -n "$_repo" ] && [ -d "$_repo" ] || return 0
107
+ local repo_root here companion_root pointer first
108
+ repo_root="$(cd "$_repo" && pwd -P)"
109
+ here="$(pwd -P)"
110
+ companion_root="$(cd "$_companion" 2>/dev/null && pwd -P)"
111
+ _mate_is_under "$here" "$repo_root" || return 0
112
+ [ -n "$companion_root" ] && _mate_is_under "$here" "$companion_root" && return 0
113
+ pointer="$repo_root/openspec/config.yaml"
114
+ if [ -f "$pointer" ]; then
115
+ IFS= read -r first < "$pointer" || true
116
+ case "$first" in
117
+ "# generated by "*" — do not edit") _cwd="$repo_root" ;;
118
+ esac
119
+ elif [ ! -f "$repo_root/openspec/config.yml" ]; then
120
+ echo "openspec: no OpenSpec store pointer in $repo_root, so edits are scoped to the companion — run \`mate wrap\` to enable Working Repository edit roots" >&2
121
+ fi
122
+ }
123
+
124
+ # Commands that resolve an OpenSpec root run from the Working Repository root
125
+ # when it carries Mate's store pointer: OpenSpec then plans in the companion's
126
+ # store and names the repository as the edit root. The root, not the caller's
127
+ # cwd, so a nested openspec/ cannot win. Every other case — including
128
+ # templates, init and update, which do not resolve stores — stays on the
129
+ # companion, exactly as before.
130
+ _cwd="$_companion"
131
+ case "$command" in
132
+ new|status|instructions|list|show|validate|archive|doctor|context|schemas|view)
133
+ _mate_use_store_pointer
134
+ ;;
135
+ esac
136
+ exec env -C "$_cwd" "$_real" "$@"