@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
|
@@ -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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
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)
|
|
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
|
|
104
|
-
* `ConfigStore.load()` —
|
|
105
|
-
*
|
|
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
|
-
|
|
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) =>
|
|
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);
|
package/wrappers/bin/openspec
CHANGED
|
@@ -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
|
-
|
|
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" "$@"
|