@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.
Files changed (52) hide show
  1. package/package.json +3 -2
  2. package/src/cli/commands/companion/hub.ts +99 -0
  3. package/src/cli/commands/companion/tui.ts +3 -1
  4. package/src/cli/commands/doctor.ts +3 -6
  5. package/src/cli/commands/install.ts +10 -10
  6. package/src/cli/commands/launch/shared.ts +4 -4
  7. package/src/cli/commands/plugin/install.ts +94 -0
  8. package/src/cli/commands/plugin/plugin.ts +17 -0
  9. package/src/cli/commands/setup.ts +2 -2
  10. package/src/cli/commands/update.ts +9 -11
  11. package/src/cli/main.ts +25 -9
  12. package/src/cli/plugin-commands.ts +4 -4
  13. package/src/cli/usage.ts +7 -3
  14. package/src/distribution.ts +3 -5
  15. package/src/framework.ts +4 -36
  16. package/src/index.ts +4 -0
  17. package/src/lib/install.ts +1 -5
  18. package/src/lib/orchestrator/adapters/base.ts +3 -4
  19. package/src/lib/orchestrator/companion-hub.ts +374 -0
  20. package/src/lib/orchestrator/config-store.ts +72 -0
  21. package/src/lib/orchestrator/editor.ts +4 -20
  22. package/src/lib/orchestrator/framework-context.ts +119 -23
  23. package/src/lib/orchestrator/global-config-store.ts +1 -5
  24. package/src/lib/orchestrator/migration.ts +0 -23
  25. package/src/lib/orchestrator/setup-preflight.ts +4 -17
  26. package/src/lib/orchestrator/types.ts +26 -4
  27. package/src/lib/orchestrator/working-repo-store.ts +2 -2
  28. package/src/lib/update-checker.ts +5 -5
  29. package/src/playbooks/companion-guidance.ts +6 -6
  30. package/src/runtime/env.ts +0 -1
  31. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +79 -3
  32. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/SKILL.md +8 -8
  33. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/references/openspec.md +3 -3
  34. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +58 -0
  35. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +139 -0
  36. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +5 -5
  37. package/src/tools/setup/capabilities/openspec.ts +1 -1
  38. package/src/tools/setup/capabilities/tokensave.ts +4 -4
  39. package/src/tools/setup/dynamic-plugins/host.ts +2 -1
  40. package/src/tools/setup/dynamic-plugins/hydrate.ts +3 -13
  41. package/src/tools/setup/dynamic-plugins/install.ts +124 -109
  42. package/src/tools/setup/dynamic-plugins/loader.ts +23 -58
  43. package/src/tools/setup/dynamic-plugins/paths.ts +8 -19
  44. package/src/tools/setup/dynamic-plugins/registry-hint.ts +14 -0
  45. package/src/tools/setup/mate.ts +28 -31
  46. package/src/tools/setup/plugins/gitignore.ts +15 -8
  47. package/src/tools/setup/plugins/guidance.ts +1 -5
  48. package/src/tools/setup/providers/claude.ts +20 -8
  49. package/src/tools/setup/providers/opencode.ts +3 -4
  50. package/src/tools/setup.ts +2 -3
  51. package/src/tui.ts +5 -0
  52. 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, frameworkConfig } from "../../framework";
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 makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined);
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 makeContext(
76
- match.companionPath,
77
- "working-repo",
78
- (await findRepoLocalLinkedRepository(cwd)) ?? undefined,
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
- ...makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined),
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
- ...makeContext(resolution.match.companionPath, "working-repo", repository),
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 = makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined);
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
- return {
162
- ...makeContext(
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
- throw new WorkingRepoRequiredError("cap");
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, frameworkConfig } from "../../framework";
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, frameworkCommandName, frameworkConfig } from "../../framework";
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
- const candidatePaths = [
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
- `\`${frameworkCommandName()} setup\` cannot run here: ${path.resolve(cwd)} is inside linked working repo \`${match.repositoryId}\`.`,
194
- `Run \`${frameworkCommandName()} setup\` from the companion repo instead: ${match.companionPath}`,
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/dependencies/plugins/` and loaded on every invocation. Declaration
93
- * registers the plugin; enablement stays in the `capabilities` list.
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?: "working" | "companion";
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, frameworkCommandName } from "../../framework";
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 \`${frameworkCommandName()} companion setup\` to initialize the framework.`,
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, frameworkCommandName } from "../framework";
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${frameworkCommandName()}: update available (${current} → ${state.latestVersion})\n`,
80
+ `\n${FRAMEWORK_NAME}: update available (${current} → ${state.latestVersion})\n`,
81
81
  );
82
- process.stderr.write(` Run \`${frameworkCommandName()} update\` to upgrade.\n\n`);
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${frameworkCommandName()}: update required (${current} → ${state.latestVersion})\n`,
101
+ `\n${FRAMEWORK_NAME}: update required (${current} → ${state.latestVersion})\n`,
102
102
  );
103
- process.stderr.write(` Run \`${frameworkCommandName()} update\` before continuing.\n\n`);
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, frameworkCommandName } from "../framework";
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 ${frameworkCommandName()} cap index.</post-edit>
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 ${frameworkCommandName()} cap index --graphify.</post-edit>
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 ${frameworkCommandName()} cap index --tokensave.</post-edit>
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="${frameworkCommandName()}" />`,
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: ${frameworkCommandName()} 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>`,
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
 
@@ -6,7 +6,6 @@
6
6
  */
7
7
  export const MATE_ENV = {
8
8
  frameworkName: "MATE_NAME",
9
- commandName: "MATE_COMMAND",
10
9
  version: "MATE_VERSION",
11
10
  companionPath: "MATE_ARTIFACT_PATH",
12
11
  wrapperBinPath: "MATE_WRAPPER_BIN_PATH",
@@ -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 `" + mateCommand + " artifact finish \"" + change + "\" --json` to complete the " +
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 `{{MATE_COMMAND}} 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_COMMAND}}:*), Bash(git:*), Bash(openspec:*)
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 {{MATE_COMMAND}} CLI and the openspec capability enabled.
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
- `{{MATE_COMMAND}} artifact finish` is the deterministic, non-interactive finish pipeline.
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
- {{MATE_COMMAND}} artifact finish "<artifact-name>" --json
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 `{{MATE_COMMAND}} cap index`.
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 `{{MATE_COMMAND}} artifact finish` blindly after a `conflict`.
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 `{{MATE_COMMAND}}`, never through a companion-local wrapper.
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.