@uniqbit/mate-core 0.15.3 → 0.15.4-canary.3
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/package.json +3 -2
- package/src/cli/commands/companion/hub.ts +99 -0
- package/src/cli/commands/companion/tui.ts +3 -1
- package/src/cli/commands/doctor.ts +3 -6
- package/src/cli/commands/install.ts +10 -10
- package/src/cli/commands/launch/shared.ts +4 -4
- package/src/cli/commands/plugin/install.ts +94 -0
- package/src/cli/commands/plugin/plugin.ts +17 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/update.ts +9 -11
- package/src/cli/main.ts +25 -9
- package/src/cli/plugin-commands.ts +4 -4
- package/src/cli/usage.ts +7 -3
- package/src/distribution.ts +3 -5
- package/src/framework.ts +4 -36
- package/src/index.ts +4 -0
- package/src/lib/install.ts +1 -5
- package/src/lib/orchestrator/adapters/base.ts +3 -4
- package/src/lib/orchestrator/companion-hub.ts +374 -0
- package/src/lib/orchestrator/config-store.ts +72 -0
- package/src/lib/orchestrator/editor.ts +4 -20
- package/src/lib/orchestrator/framework-context.ts +119 -23
- package/src/lib/orchestrator/global-config-store.ts +1 -5
- package/src/lib/orchestrator/migration.ts +0 -23
- package/src/lib/orchestrator/setup-preflight.ts +4 -17
- package/src/lib/orchestrator/types.ts +26 -4
- package/src/lib/orchestrator/working-repo-store.ts +2 -2
- package/src/lib/update-checker.ts +5 -5
- package/src/playbooks/companion-guidance.ts +6 -6
- package/src/runtime/env.ts +0 -1
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +79 -3
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/SKILL.md +8 -8
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/references/openspec.md +3 -3
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +58 -0
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +139 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +5 -5
- package/src/tools/setup/capabilities/openspec.ts +1 -1
- package/src/tools/setup/capabilities/tokensave.ts +4 -4
- package/src/tools/setup/dynamic-plugins/host.ts +2 -1
- package/src/tools/setup/dynamic-plugins/hydrate.ts +3 -13
- package/src/tools/setup/dynamic-plugins/install.ts +124 -109
- package/src/tools/setup/dynamic-plugins/loader.ts +23 -58
- package/src/tools/setup/dynamic-plugins/paths.ts +8 -19
- package/src/tools/setup/dynamic-plugins/registry-hint.ts +14 -0
- package/src/tools/setup/mate.ts +28 -31
- package/src/tools/setup/plugins/gitignore.ts +15 -8
- package/src/tools/setup/plugins/guidance.ts +1 -5
- package/src/tools/setup/providers/claude.ts +20 -8
- package/src/tools/setup/providers/opencode.ts +3 -4
- package/src/tools/setup.ts +2 -3
- package/src/tui.ts +5 -0
- package/src/tools/setup/dynamic-plugins/pin-store.ts +0 -30
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import { FRAMEWORK_NAME
|
|
4
|
+
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
|
+
import { parse } from "yaml";
|
|
5
6
|
import { CompanionResolver } from "./companion-resolver";
|
|
6
7
|
import { ConfigStore } from "./config-store";
|
|
7
8
|
import { GlobalConfigStore } from "./global-config-store";
|
|
8
|
-
import { migrateConfigDir } from "./migration";
|
|
9
9
|
import { findRepoLocalLinkedRepository } from "./repo-local-registry";
|
|
10
10
|
import {
|
|
11
11
|
AmbiguousCompanionError,
|
|
12
|
+
ConfigError,
|
|
13
|
+
type FrameworkConfig,
|
|
14
|
+
type HubConfig,
|
|
12
15
|
type LinkedRepository,
|
|
13
16
|
RepositoryNotFoundError,
|
|
14
17
|
WorkingRepoRequiredError,
|
|
@@ -20,7 +23,8 @@ export interface FrameworkContext {
|
|
|
20
23
|
workingRepoStore: WorkingRepoStore;
|
|
21
24
|
companionPath: string;
|
|
22
25
|
repository?: LinkedRepository;
|
|
23
|
-
contextKind: "env" | "working-repo" | "companion-root";
|
|
26
|
+
contextKind: "env" | "working-repo" | "companion-root" | "hub";
|
|
27
|
+
hub?: HubConfig;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export interface LaunchContext extends FrameworkContext {
|
|
@@ -44,6 +48,7 @@ function makeContext(
|
|
|
44
48
|
companionPath: string,
|
|
45
49
|
contextKind: FrameworkContext["contextKind"],
|
|
46
50
|
repository?: LinkedRepository,
|
|
51
|
+
hub?: HubConfig,
|
|
47
52
|
): FrameworkContext {
|
|
48
53
|
const configDir = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config");
|
|
49
54
|
return {
|
|
@@ -52,9 +57,90 @@ function makeContext(
|
|
|
52
57
|
companionPath,
|
|
53
58
|
repository,
|
|
54
59
|
contextKind,
|
|
60
|
+
hub,
|
|
55
61
|
};
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
function isStrictChildPath(parentPath: string, candidatePath: string): boolean {
|
|
65
|
+
const relative = path.relative(parentPath, candidatePath);
|
|
66
|
+
return relative !== "" && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function validateHubMember(
|
|
70
|
+
resolvedRoot: string,
|
|
71
|
+
realRoot: string,
|
|
72
|
+
member: HubConfig["companions"][number],
|
|
73
|
+
): Promise<ConfigError | null> {
|
|
74
|
+
const memberPath = path.resolve(resolvedRoot, member.path);
|
|
75
|
+
if (!isStrictChildPath(resolvedRoot, memberPath)) {
|
|
76
|
+
return new ConfigError(
|
|
77
|
+
`Hub member "${member.id}" path resolves outside the hub root: ${member.path}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const stats = await fs.stat(memberPath).catch(() => null);
|
|
82
|
+
if (!stats?.isDirectory()) {
|
|
83
|
+
return new ConfigError(`Hub member "${member.id}" directory is missing: ${memberPath}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const realMemberPath = await fs.realpath(memberPath).catch(() => memberPath);
|
|
87
|
+
if (!isStrictChildPath(realRoot, realMemberPath)) {
|
|
88
|
+
return new ConfigError(
|
|
89
|
+
`Hub member "${member.id}" path resolves outside the hub root: ${member.path}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const childConfigPath = path.join(memberPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
|
|
94
|
+
let childConfig: Partial<FrameworkConfig> | null;
|
|
95
|
+
try {
|
|
96
|
+
childConfig = parse(
|
|
97
|
+
await fs.readFile(childConfigPath, "utf8"),
|
|
98
|
+
) as Partial<FrameworkConfig> | null;
|
|
99
|
+
} catch {
|
|
100
|
+
return new ConfigError(
|
|
101
|
+
`Hub member "${member.id}" must contain a framework.yaml declaring type "companion".`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (childConfig?.type !== "companion") {
|
|
106
|
+
return new ConfigError(
|
|
107
|
+
`Hub member "${member.id}" must declare type "companion" in ${childConfigPath}.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function validateHubMembers(hubRoot: string, hub: HubConfig): Promise<void> {
|
|
115
|
+
const resolvedRoot = path.resolve(hubRoot);
|
|
116
|
+
const realRoot = await fs.realpath(resolvedRoot).catch(() => resolvedRoot);
|
|
117
|
+
const errors = await Promise.all(
|
|
118
|
+
hub.companions.map((member) => validateHubMember(resolvedRoot, realRoot, member)),
|
|
119
|
+
);
|
|
120
|
+
const firstError = errors.find((error): error is ConfigError => error !== null);
|
|
121
|
+
if (firstError) throw firstError;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function withResolvedHub(context: FrameworkContext): Promise<FrameworkContext> {
|
|
125
|
+
let rawConfig: Partial<FrameworkConfig> | null;
|
|
126
|
+
try {
|
|
127
|
+
rawConfig = parse(
|
|
128
|
+
await fs.readFile(context.configStore.configPath, "utf8"),
|
|
129
|
+
) as Partial<FrameworkConfig> | null;
|
|
130
|
+
} catch {
|
|
131
|
+
return context;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (rawConfig?.type !== "hub") return context;
|
|
135
|
+
|
|
136
|
+
const config = await context.configStore.load();
|
|
137
|
+
if (!config.hub) {
|
|
138
|
+
throw new ConfigError('A "hub" framework requires a hub.companions array.');
|
|
139
|
+
}
|
|
140
|
+
await validateHubMembers(context.companionPath, config.hub);
|
|
141
|
+
return { ...context, contextKind: "hub", hub: config.hub };
|
|
142
|
+
}
|
|
143
|
+
|
|
58
144
|
// Resolves the companion framework context without a repositoryId. Used by commands
|
|
59
145
|
// that only need companion config (e.g. `mate doctor`, `mate config`, `mate report`). Resolution order:
|
|
60
146
|
// 1. MATE_ARTIFACT_PATH env var (agent-launched sessions)
|
|
@@ -67,31 +153,32 @@ export async function resolveFrameworkContext(
|
|
|
67
153
|
const envCompanionPath = process.env.MATE_ARTIFACT_PATH;
|
|
68
154
|
if (envCompanionPath) {
|
|
69
155
|
const repository = repositoryFromEnvironment() ?? (await findRepoLocalLinkedRepository(cwd));
|
|
70
|
-
return
|
|
156
|
+
return withResolvedHub(
|
|
157
|
+
makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined),
|
|
158
|
+
);
|
|
71
159
|
}
|
|
72
160
|
|
|
73
161
|
const match = await new CompanionResolver(globalConfigStore).resolve(cwd);
|
|
74
162
|
if (match) {
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
163
|
+
return withResolvedHub(
|
|
164
|
+
makeContext(
|
|
165
|
+
match.companionPath,
|
|
166
|
+
"working-repo",
|
|
167
|
+
(await findRepoLocalLinkedRepository(cwd)) ?? undefined,
|
|
168
|
+
),
|
|
79
169
|
);
|
|
80
170
|
}
|
|
81
171
|
|
|
82
172
|
const localDir = path.join(cwd, `.${FRAMEWORK_NAME}`);
|
|
83
|
-
const localLegacyDirs = frameworkConfig.legacyNames.map((n) => path.join(cwd, `.${n}`));
|
|
84
|
-
await migrateConfigDir(localDir, localLegacyDirs);
|
|
85
173
|
|
|
86
174
|
const localConfigPath = path.join(localDir, "config", "framework.yaml");
|
|
87
175
|
try {
|
|
88
176
|
await fs.access(localConfigPath);
|
|
89
|
-
return makeContext(cwd, "companion-root");
|
|
90
177
|
} catch {
|
|
91
178
|
// no local config
|
|
179
|
+
throw new RepositoryNotFoundError(`No companion found for current directory: ${cwd}`);
|
|
92
180
|
}
|
|
93
|
-
|
|
94
|
-
throw new RepositoryNotFoundError(`No companion found for current directory: ${cwd}`);
|
|
181
|
+
return withResolvedHub(makeContext(cwd, "companion-root"));
|
|
95
182
|
}
|
|
96
183
|
|
|
97
184
|
// Resolves context for agent-launch commands. Returns a LaunchContext with repositoryId.
|
|
@@ -108,8 +195,11 @@ export async function resolveForLaunch(
|
|
|
108
195
|
if (envCompanionPath) {
|
|
109
196
|
const repository = repositoryFromEnvironment() ?? (await findRepoLocalLinkedRepository(cwd));
|
|
110
197
|
const repositoryId = process.env.MATE_REPO_ID ?? repository?.id ?? "";
|
|
198
|
+
const context = await withResolvedHub(
|
|
199
|
+
makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined),
|
|
200
|
+
);
|
|
111
201
|
return {
|
|
112
|
-
...
|
|
202
|
+
...context,
|
|
113
203
|
repositoryId,
|
|
114
204
|
};
|
|
115
205
|
}
|
|
@@ -123,8 +213,11 @@ export async function resolveForLaunch(
|
|
|
123
213
|
|
|
124
214
|
if (resolution.match) {
|
|
125
215
|
const repository = (await findRepoLocalLinkedRepository(cwd)) ?? undefined;
|
|
216
|
+
const context = await withResolvedHub(
|
|
217
|
+
makeContext(resolution.match.companionPath, "working-repo", repository),
|
|
218
|
+
);
|
|
126
219
|
return {
|
|
127
|
-
...
|
|
220
|
+
...context,
|
|
128
221
|
repositoryId: resolution.match.repositoryId,
|
|
129
222
|
};
|
|
130
223
|
}
|
|
@@ -143,7 +236,9 @@ export async function resolveForCapability(
|
|
|
143
236
|
const envCompanionPath = process.env.MATE_ARTIFACT_PATH;
|
|
144
237
|
if (envCompanionPath) {
|
|
145
238
|
const repository = repositoryFromEnvironment() ?? (await findRepoLocalLinkedRepository(cwd));
|
|
146
|
-
const ctx =
|
|
239
|
+
const ctx = await withResolvedHub(
|
|
240
|
+
makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined),
|
|
241
|
+
);
|
|
147
242
|
const repositoryId = process.env.MATE_REPO_ID;
|
|
148
243
|
if (repositoryId) {
|
|
149
244
|
return { ...ctx, repositoryId };
|
|
@@ -158,12 +253,15 @@ export async function resolveForCapability(
|
|
|
158
253
|
|
|
159
254
|
const match = await new CompanionResolver(globalConfigStore).resolve(cwd);
|
|
160
255
|
if (match) {
|
|
161
|
-
|
|
162
|
-
|
|
256
|
+
const context = await withResolvedHub(
|
|
257
|
+
makeContext(
|
|
163
258
|
match.companionPath,
|
|
164
259
|
"working-repo",
|
|
165
260
|
(await findRepoLocalLinkedRepository(cwd)) ?? undefined,
|
|
166
261
|
),
|
|
262
|
+
);
|
|
263
|
+
return {
|
|
264
|
+
...context,
|
|
167
265
|
repositoryId: match.repositoryId,
|
|
168
266
|
};
|
|
169
267
|
}
|
|
@@ -171,16 +269,14 @@ export async function resolveForCapability(
|
|
|
171
269
|
// Fallback: cwd is the companion directory — resolve from its local config.
|
|
172
270
|
// This is specific to mate cap; resolveForLaunch does NOT get this fallback.
|
|
173
271
|
const localDir = path.join(cwd, `.${FRAMEWORK_NAME}`);
|
|
174
|
-
const localLegacyDirs = frameworkConfig.legacyNames.map((n) => path.join(cwd, `.${n}`));
|
|
175
|
-
await migrateConfigDir(localDir, localLegacyDirs);
|
|
176
272
|
|
|
177
273
|
const localConfigPath = path.join(localDir, "config", "framework.yaml");
|
|
178
274
|
try {
|
|
179
275
|
await fs.access(localConfigPath);
|
|
180
|
-
return { ...makeContext(cwd, "companion-root"), repositoryId: process.env.MATE_REPO_ID ?? "" };
|
|
181
276
|
} catch {
|
|
182
277
|
// no local config
|
|
278
|
+
throw new WorkingRepoRequiredError("cap");
|
|
183
279
|
}
|
|
184
|
-
|
|
185
|
-
|
|
280
|
+
const context = await withResolvedHub(makeContext(cwd, "companion-root"));
|
|
281
|
+
return { ...context, repositoryId: process.env.MATE_REPO_ID ?? "" };
|
|
186
282
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import { FRAMEWORK_NAME
|
|
4
|
+
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
5
|
import { YamlFileStore } from "./yaml-file-store";
|
|
6
|
-
import { migrateConfigDir } from "./migration";
|
|
7
6
|
|
|
8
7
|
interface CompanionEntry {
|
|
9
8
|
path: string;
|
|
@@ -35,9 +34,6 @@ export class GlobalConfigStore extends YamlFileStore<GlobalConfig> {
|
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
override async load(): Promise<GlobalConfig> {
|
|
38
|
-
const currentDir = path.dirname(this.configPath);
|
|
39
|
-
const legacyDirs = frameworkConfig.legacyNames.map((n) => path.join(os.homedir(), `.${n}`));
|
|
40
|
-
await migrateConfigDir(currentDir, legacyDirs);
|
|
41
37
|
return normalizeGlobalConfig(await super.load());
|
|
42
38
|
}
|
|
43
39
|
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
// oxlint-disable no-await-in-loop
|
|
2
1
|
import fs from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
2
|
|
|
5
3
|
import { parse, stringify } from "yaml";
|
|
6
4
|
|
|
@@ -18,24 +16,3 @@ export async function migrateRegistryData(filePath: string): Promise<void> {
|
|
|
18
16
|
await fs.writeFile(filePath, stringify(rest), "utf8");
|
|
19
17
|
}
|
|
20
18
|
}
|
|
21
|
-
|
|
22
|
-
export async function migrateConfigDir(currentDir: string, legacyDirs: string[]): Promise<void> {
|
|
23
|
-
try {
|
|
24
|
-
await fs.access(currentDir);
|
|
25
|
-
return;
|
|
26
|
-
} catch {
|
|
27
|
-
// current dir missing — check legacy locations
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
for (const legacyDir of legacyDirs) {
|
|
31
|
-
try {
|
|
32
|
-
await fs.access(legacyDir);
|
|
33
|
-
await fs.mkdir(path.dirname(currentDir), { recursive: true });
|
|
34
|
-
await fs.rename(legacyDir, currentDir);
|
|
35
|
-
process.stderr.write(`[migration] ${legacyDir} → ${currentDir}\n`);
|
|
36
|
-
return;
|
|
37
|
-
} catch {
|
|
38
|
-
// this legacy dir also absent, try next
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { parse } from "yaml";
|
|
5
5
|
|
|
6
|
-
import { FRAMEWORK_NAME
|
|
6
|
+
import { FRAMEWORK_NAME } from "../../framework";
|
|
7
7
|
import { fileExists } from "../fs-utils";
|
|
8
8
|
import { GlobalConfigStore } from "./global-config-store";
|
|
9
9
|
import { CompanionResolver, type CompanionMatch } from "./companion-resolver";
|
|
@@ -126,20 +126,7 @@ export async function looksLikeWorkingRepo(cwd: string): Promise<boolean> {
|
|
|
126
126
|
|
|
127
127
|
export async function hasLocalCompanionConfig(cwd: string): Promise<boolean> {
|
|
128
128
|
const resolvedCwd = path.resolve(cwd);
|
|
129
|
-
|
|
130
|
-
path.join(resolvedCwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
|
|
131
|
-
...frameworkConfig.legacyNames.map((name) =>
|
|
132
|
-
path.join(resolvedCwd, `.${name}`, "config", "framework.yaml"),
|
|
133
|
-
),
|
|
134
|
-
];
|
|
135
|
-
|
|
136
|
-
for (const candidatePath of candidatePaths) {
|
|
137
|
-
if (await fileExists(candidatePath)) {
|
|
138
|
-
return true;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return false;
|
|
129
|
+
return fileExists(path.join(resolvedCwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
|
|
143
130
|
}
|
|
144
131
|
|
|
145
132
|
async function hasWorkingRepoConfig(cwd: string): Promise<boolean> {
|
|
@@ -190,7 +177,7 @@ export async function inspectSetupPreflight(
|
|
|
190
177
|
|
|
191
178
|
export function formatSetupGuardrailError(cwd: string, match: CompanionMatch): string {
|
|
192
179
|
return [
|
|
193
|
-
`\`${
|
|
194
|
-
`Run \`${
|
|
180
|
+
`\`${FRAMEWORK_NAME} setup\` cannot run here: ${path.resolve(cwd)} is inside linked working repo \`${match.repositoryId}\`.`,
|
|
181
|
+
`Run \`${FRAMEWORK_NAME} setup\` from the companion repo instead: ${match.companionPath}`,
|
|
195
182
|
].join("\n");
|
|
196
183
|
}
|
|
@@ -74,6 +74,26 @@ export interface CapabilityConfig {
|
|
|
74
74
|
schemaProfile?: OpenSpecSchemaProfile;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export type FrameworkType = "working" | "companion" | "hub";
|
|
78
|
+
|
|
79
|
+
export interface HubMemberSource {
|
|
80
|
+
kind: "git" | "local";
|
|
81
|
+
url?: string;
|
|
82
|
+
ref?: string;
|
|
83
|
+
path?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface HubMember {
|
|
87
|
+
id: string;
|
|
88
|
+
path: string;
|
|
89
|
+
source: HubMemberSource;
|
|
90
|
+
materializedCommit?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface HubConfig {
|
|
94
|
+
companions: HubMember[];
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
/**
|
|
78
98
|
* Distribution-name-keyed semver ranges (e.g. `{ mate: ">=0.15.0" }` or
|
|
79
99
|
* `{ "acme-mate": ">=1.2.0" }`). A running CLI only checks the key matching
|
|
@@ -88,9 +108,10 @@ export type EngineConstraints = Record<string, string>;
|
|
|
88
108
|
export type PluginDeclarationPolicy = "default" | "optional";
|
|
89
109
|
|
|
90
110
|
/**
|
|
91
|
-
* A companion-declared npm plugin: installed by setup/install into
|
|
92
|
-
* `.mate/
|
|
93
|
-
*
|
|
111
|
+
* A companion-declared npm plugin: installed by setup/install into the
|
|
112
|
+
* companion's own shared plugin workspace (`.mate/plugins/`) and loaded
|
|
113
|
+
* from there on every invocation. Declaration registers the plugin;
|
|
114
|
+
* enablement stays in the `capabilities` list.
|
|
94
115
|
*/
|
|
95
116
|
export interface PluginDeclaration {
|
|
96
117
|
/** npm package name (e.g. `@acme/custom-plugin`). */
|
|
@@ -104,8 +125,9 @@ export interface PluginDeclaration {
|
|
|
104
125
|
}
|
|
105
126
|
|
|
106
127
|
export interface FrameworkConfig {
|
|
107
|
-
type?:
|
|
128
|
+
type?: FrameworkType;
|
|
108
129
|
git?: GitModeProfile;
|
|
130
|
+
hub?: HubConfig;
|
|
109
131
|
profiles: Record<string, PolicyProfile>;
|
|
110
132
|
capabilities?: CapabilityConfig[];
|
|
111
133
|
plugins?: PluginDeclaration[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
-
import { FRAMEWORK_NAME
|
|
3
|
+
import { FRAMEWORK_NAME } from "../../framework";
|
|
4
4
|
import { migrateRegistryData } from "./migration";
|
|
5
5
|
import { YamlFileStore } from "./yaml-file-store";
|
|
6
6
|
import { ConfigError, type WorkingRepoConfig } from "./types";
|
|
@@ -21,7 +21,7 @@ export class WorkingRepoStore extends YamlFileStore<WorkingRepoConfig> {
|
|
|
21
21
|
|
|
22
22
|
protected async onMissing(): Promise<WorkingRepoConfig> {
|
|
23
23
|
throw new ConfigError(
|
|
24
|
-
`Working repo config not found. Please run \`${
|
|
24
|
+
`Working repo config not found. Please run \`${FRAMEWORK_NAME} companion setup\` to initialize the framework.`,
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -2,7 +2,7 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { getActiveDistribution, type DistributionUpdateConfig } from "../distribution";
|
|
5
|
-
import { FRAMEWORK_NAME
|
|
5
|
+
import { FRAMEWORK_NAME } from "../framework";
|
|
6
6
|
import { fetchPublicPackageVersion, PUBLIC_NPM_REGISTRY } from "./public-npm";
|
|
7
7
|
import { YamlFileStore } from "./orchestrator/yaml-file-store";
|
|
8
8
|
|
|
@@ -77,9 +77,9 @@ export async function showUpdateBannerIfAvailable(store: UpdateStateStore): Prom
|
|
|
77
77
|
const current = getCurrentVersion();
|
|
78
78
|
if (!isNewer(state.latestVersion, current)) return;
|
|
79
79
|
process.stderr.write(
|
|
80
|
-
`\n${
|
|
80
|
+
`\n${FRAMEWORK_NAME}: update available (${current} → ${state.latestVersion})\n`,
|
|
81
81
|
);
|
|
82
|
-
process.stderr.write(` Run \`${
|
|
82
|
+
process.stderr.write(` Run \`${FRAMEWORK_NAME} update\` to upgrade.\n\n`);
|
|
83
83
|
} catch {
|
|
84
84
|
// never block the main command
|
|
85
85
|
}
|
|
@@ -98,9 +98,9 @@ export async function enforceUpdateIfRequired(store: UpdateStateStore): Promise<
|
|
|
98
98
|
const current = getCurrentVersion();
|
|
99
99
|
if (!isNewer(state.latestVersion, current)) return false;
|
|
100
100
|
process.stderr.write(
|
|
101
|
-
`\n${
|
|
101
|
+
`\n${FRAMEWORK_NAME}: update required (${current} → ${state.latestVersion})\n`,
|
|
102
102
|
);
|
|
103
|
-
process.stderr.write(` Run \`${
|
|
103
|
+
process.stderr.write(` Run \`${FRAMEWORK_NAME} update\` before continuing.\n\n`);
|
|
104
104
|
return true;
|
|
105
105
|
} catch {
|
|
106
106
|
return false;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
-
import { FRAMEWORK_NAME
|
|
3
|
+
import { FRAMEWORK_NAME } from "../framework";
|
|
4
4
|
import type { AdapterContext } from "../lib/orchestrator/adapters/base";
|
|
5
5
|
import {
|
|
6
6
|
hasGraphifyCapability,
|
|
@@ -35,7 +35,7 @@ export function buildCodebaseExplorationGuidanceSection(
|
|
|
35
35
|
2. If tokensave is empty/file-only/irrelevant, run graphify query "<question>"; use graphify path/explain to deepen.
|
|
36
36
|
3. Use grep/glob/read only after tokensave and graphify were tried.</order>
|
|
37
37
|
<notes>Dirty graph files are expected. Use wiki/index.md for broad navigation; GRAPH_REPORT.md only if query/path/explain fall short.</notes>
|
|
38
|
-
<post-edit>After code changes, run ${
|
|
38
|
+
<post-edit>After code changes, run ${FRAMEWORK_NAME} cap index.</post-edit>
|
|
39
39
|
</codebase-exploration-rules>`;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -48,7 +48,7 @@ export function buildCodebaseExplorationGuidanceSection(
|
|
|
48
48
|
2. Use graphify path "<A>" "<B>" or graphify explain "<concept>" to deepen.
|
|
49
49
|
3. Use grep/glob/read only after graphify was tried.</order>
|
|
50
50
|
<notes>Dirty graph files are expected. Use wiki/index.md for broad navigation; GRAPH_REPORT.md only if query/path/explain fall short.</notes>
|
|
51
|
-
<post-edit>After code changes, run ${
|
|
51
|
+
<post-edit>After code changes, run ${FRAMEWORK_NAME} cap index --graphify.</post-edit>
|
|
52
52
|
</codebase-exploration-rules>`;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -57,7 +57,7 @@ export function buildCodebaseExplorationGuidanceSection(
|
|
|
57
57
|
<order>tokensave -> grep/glob/read. MUST try tokensave before raw source.
|
|
58
58
|
1. tokensave_context first.
|
|
59
59
|
2. Use grep/glob/read only after tokensave is empty or irrelevant.</order>
|
|
60
|
-
<post-edit>After code changes, run ${
|
|
60
|
+
<post-edit>After code changes, run ${FRAMEWORK_NAME} cap index --tokensave.</post-edit>
|
|
61
61
|
</codebase-exploration-rules>`;
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -88,7 +88,7 @@ export function buildCompanionPolicyXml(
|
|
|
88
88
|
" <cli-tools>",
|
|
89
89
|
` <cli name="openspec" type="wrapper" invokeAs="${path.join(wrapperBinPath, "openspec")}" />`,
|
|
90
90
|
` <cli name="graphify" type="wrapper" invokeAs="${path.join(wrapperBinPath, "graphify")}" />`,
|
|
91
|
-
` <cli name="${FRAMEWORK_NAME}" type="global" invokeAs="${
|
|
91
|
+
` <cli name="${FRAMEWORK_NAME}" type="global" invokeAs="${FRAMEWORK_NAME}" />`,
|
|
92
92
|
" </cli-tools>",
|
|
93
93
|
` <linked-repository id="${context.repository.id}" profile="${context.repository.profile}" />`,
|
|
94
94
|
" </context>",
|
|
@@ -103,7 +103,7 @@ export function buildCompanionPolicyXml(
|
|
|
103
103
|
|
|
104
104
|
if (hasOpenspecCapability(context.capabilities)) {
|
|
105
105
|
lines.push(
|
|
106
|
-
` <rule id="openspec-finish" severity="critical">Finish OpenSpec changes ONLY with: ${
|
|
106
|
+
` <rule id="openspec-finish" severity="critical">Finish OpenSpec changes ONLY with: ${FRAMEWORK_NAME} artifact finish "<name>" --json — never hand-commit or hand-tag a finish. Finishing a still-active change applies its delta specs itself, so do not pre-apply them to openspec/specs right before finishing. Finishing an already-archived change resumes without re-applying delta specs, so an archive flow that already synced specs (e.g. openspec-sync-specs) composes fine with a finish afterwards.</rule>`,
|
|
107
107
|
);
|
|
108
108
|
}
|
|
109
109
|
|
package/src/runtime/env.ts
CHANGED
|
@@ -4,12 +4,17 @@ set -u
|
|
|
4
4
|
input_file=$(mktemp "${TMPDIR:-/tmp}/mate-artifact-finish.XXXXXX")
|
|
5
5
|
trap 'rm -f "$input_file"' EXIT
|
|
6
6
|
cat >"$input_file"
|
|
7
|
+
companion_path="${MATE_ARTIFACT_PATH:-}"
|
|
7
8
|
|
|
8
9
|
node -e '
|
|
9
10
|
const fs = require("node:fs");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
const { spawnSync } = require("node:child_process");
|
|
10
13
|
|
|
11
14
|
const inputFile = process.argv[1];
|
|
15
|
+
const companionPath = process.argv[2] || "";
|
|
12
16
|
const archivePathPattern = /(?:^|[\s\/"\x27`,(])openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2}-[^\/\s"\x27`,)]+)/;
|
|
17
|
+
const archiveEntryPattern = /^\d{4}-\d{2}-\d{2}-.+$/;
|
|
13
18
|
|
|
14
19
|
function shellSplit(command) {
|
|
15
20
|
const parts = [];
|
|
@@ -133,11 +138,83 @@ function isClearFailure(response) {
|
|
|
133
138
|
return typeof exitCode === "number" && exitCode !== 0;
|
|
134
139
|
}
|
|
135
140
|
|
|
141
|
+
// A change is finished once `mate artifact finish` has tagged it; this never
|
|
142
|
+
// runs, denies, or auto-invokes the finish pipeline itself — it only checks
|
|
143
|
+
// whether that already happened, so it cannot be steered by injected content.
|
|
144
|
+
function hasFinishTag(entryName) {
|
|
145
|
+
if (!companionPath) return true; // cannot verify — do not block on it
|
|
146
|
+
const result = spawnSync(
|
|
147
|
+
"git",
|
|
148
|
+
["-C", companionPath, "rev-parse", "-q", "--verify", "refs/tags/openspec/" + entryName],
|
|
149
|
+
{ stdio: "ignore" },
|
|
150
|
+
);
|
|
151
|
+
return result.status === 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function handleStop(input) {
|
|
155
|
+
if (!companionPath) return;
|
|
156
|
+
const archiveDir = path.join(companionPath, "openspec", "changes", "archive");
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = fs.readdirSync(archiveDir, { withFileTypes: true })
|
|
160
|
+
.filter((entry) => entry.isDirectory() && archiveEntryPattern.test(entry.name))
|
|
161
|
+
.map((entry) => entry.name);
|
|
162
|
+
} catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const unfinished = entries.filter((name) => !hasFinishTag(name));
|
|
166
|
+
if (unfinished.length === 0) return;
|
|
167
|
+
|
|
168
|
+
// Warn about each unfinished change at most once per session: if the user
|
|
169
|
+
// declines to finish now, repeating the block on every later Stop would
|
|
170
|
+
// make the session impossible to end.
|
|
171
|
+
const sessionId = typeof input.session_id === "string" && input.session_id ? input.session_id : null;
|
|
172
|
+
const stateDir = path.join(companionPath, ".claude", "state");
|
|
173
|
+
const stateFile = path.join(
|
|
174
|
+
stateDir,
|
|
175
|
+
"mate-artifact-finish-stop." +
|
|
176
|
+
(sessionId ? sessionId.replace(/[^a-zA-Z0-9_-]/g, "_") : "archive-snapshot") +
|
|
177
|
+
".json",
|
|
178
|
+
);
|
|
179
|
+
let flagged = [];
|
|
180
|
+
try {
|
|
181
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
182
|
+
if (state && Array.isArray(state.flagged)) flagged = state.flagged;
|
|
183
|
+
} catch {}
|
|
184
|
+
const flaggedSet = new Set(flagged);
|
|
185
|
+
const toWarn = unfinished.filter((name) => !flaggedSet.has(name));
|
|
186
|
+
if (toWarn.length === 0) return;
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
190
|
+
fs.writeFileSync(
|
|
191
|
+
stateFile,
|
|
192
|
+
JSON.stringify({ version: 1, flagged: [...flaggedSet, ...toWarn] }) + "\n",
|
|
193
|
+
);
|
|
194
|
+
} catch {
|
|
195
|
+
return; // cannot record the warning — do not block without being able to dedupe it
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const names = toWarn.map((name) => name.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
|
|
199
|
+
const reason =
|
|
200
|
+
"Archived OpenSpec change(s) not yet finished (no dated finish tag): " + names.join(", ") +
|
|
201
|
+
". Invoke the mate-artifact-finish skill now — it will ask you to confirm before it " +
|
|
202
|
+
"commits, tags, and pushes — then run `mate artifact finish \"<name>\" --json` for each. " +
|
|
203
|
+
"Do not hand-commit or hand-tag. If the user declines, you may end the turn; this check " +
|
|
204
|
+
"will not repeat for the same change(s) in this session.";
|
|
205
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason }));
|
|
206
|
+
}
|
|
207
|
+
|
|
136
208
|
let input = {};
|
|
137
209
|
try {
|
|
138
210
|
input = JSON.parse(fs.readFileSync(inputFile, "utf8") || "{}");
|
|
139
211
|
} catch {}
|
|
140
212
|
|
|
213
|
+
if (input.hook_event_name === "Stop") {
|
|
214
|
+
handleStop(input);
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
217
|
+
|
|
141
218
|
if (input.hook_event_name !== "PostToolUse" || input.tool_name !== "Bash") process.exit(0);
|
|
142
219
|
const command = input.tool_input && typeof input.tool_input.command === "string"
|
|
143
220
|
? input.tool_input.command
|
|
@@ -146,10 +223,9 @@ const change = extractArchiveCommand(command) || extractMoveCommand(command);
|
|
|
146
223
|
if (!change) process.exit(0);
|
|
147
224
|
if (isClearFailure(input.tool_response)) process.exit(0);
|
|
148
225
|
|
|
149
|
-
const mateCommand = process.env.MATE_COMMAND || "mate";
|
|
150
226
|
const context =
|
|
151
227
|
"OpenSpec change " + change + " was just archived. Invoke the mate-artifact-finish " +
|
|
152
|
-
"skill, then run `
|
|
228
|
+
"skill, then run `mate artifact finish \"" + change + "\" --json` to complete the " +
|
|
153
229
|
"finish workflow (commit, tag, and push).";
|
|
154
230
|
process.stdout.write(JSON.stringify({
|
|
155
231
|
hookSpecificOutput: {
|
|
@@ -157,5 +233,5 @@ process.stdout.write(JSON.stringify({
|
|
|
157
233
|
additionalContext: context,
|
|
158
234
|
},
|
|
159
235
|
}));
|
|
160
|
-
' "$input_file"
|
|
236
|
+
' "$input_file" "$companion_path"
|
|
161
237
|
exit 0
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mate-artifact-finish
|
|
3
|
-
description: Finish a completed artifact in one step via `
|
|
4
|
-
allowed-tools: Bash(
|
|
3
|
+
description: Finish a completed artifact in one step via `mate artifact finish`. Use when the user wants to finish, ship, or archive-and-push a completed artifact and anchor it with a dated revert tag.
|
|
4
|
+
allowed-tools: Bash(mate:*), Bash(git:*), Bash(openspec:*)
|
|
5
5
|
license: MIT
|
|
6
|
-
compatibility: Requires the
|
|
6
|
+
compatibility: Requires the mate CLI and the openspec capability enabled.
|
|
7
7
|
metadata:
|
|
8
8
|
author: mate
|
|
9
9
|
version: "1.2"
|
|
@@ -13,7 +13,7 @@ Finish a completed artifact as one deterministic mate workflow and leave a dated
|
|
|
13
13
|
|
|
14
14
|
## Workflow
|
|
15
15
|
|
|
16
|
-
`
|
|
16
|
+
`mate artifact finish` is the deterministic, non-interactive finish pipeline.
|
|
17
17
|
|
|
18
18
|
The CLI performs normal work; only conflict recovery requires agent judgment.
|
|
19
19
|
|
|
@@ -24,10 +24,10 @@ The CLI performs normal work; only conflict recovery requires agent judgment.
|
|
|
24
24
|
2. **Run the CLI.**
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
|
|
27
|
+
mate artifact finish "<artifact-name>" --json
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Run it from the companion repository. Finish mutates only that repository; the linked working repository is capability-indexing context. Do not manually invoke `
|
|
30
|
+
Run it from the companion repository. Finish mutates only that repository; the linked working repository is capability-indexing context. Do not manually invoke `mate cap index`.
|
|
31
31
|
|
|
32
32
|
Add `--force` only if the user explicitly wants to override the not-complete guard (validation is never bypassable). Unrelated companion changes are preserved and do not require `--force`. Add `--no-push` for a local-only finish.
|
|
33
33
|
|
|
@@ -44,8 +44,8 @@ The CLI performs normal work; only conflict recovery requires agent judgment.
|
|
|
44
44
|
|
|
45
45
|
- **CRITICAL — no manual finishing**: Never hand-commit or hand-tag instead of this skill. For a still-active change the finish pipeline applies delta specs itself (via `openspec archive`) — do not pre-apply them, or produce fails with "already exists". A change whose specs were already synced (e.g. via `openspec-sync-specs`) must be archived first; finish then resumes from the archive without re-applying delta specs.
|
|
46
46
|
- Always pass `--json` and parse the result; do not scrape human-readable output.
|
|
47
|
-
- Never re-run `
|
|
47
|
+
- Never re-run `mate artifact finish` blindly after a `conflict`.
|
|
48
48
|
- Never auto-resolve a provider-specific conflict you do not understand — ask the user.
|
|
49
49
|
- Use the JSON fields the CLI returns; do not recompute names, dates, or tags by hand.
|
|
50
|
-
- Invoke the CLI as `
|
|
50
|
+
- Invoke the CLI as `mate`, never through a companion-local wrapper.
|
|
51
51
|
- Only the companion repository is a finish Git target; the working repository is an index input.
|