@uniqbit/mate-core 0.15.4-canary.9 → 0.15.5-canary.0

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 (32) hide show
  1. package/claude-plugin/hooks/artifact-finish-nudge.mjs +5 -3
  2. package/claude-plugin/hooks/session-banner.mjs +5 -3
  3. package/claude-plugin/hooks/ts-loader.mjs +28 -0
  4. package/claude-plugin/hooks/validate-artifact-path.mjs +5 -3
  5. package/package.json +1 -1
  6. package/src/cli/commands/cap/openspec.ts +20 -1
  7. package/src/cli/commands/companion/hub.ts +1 -0
  8. package/src/cli/commands/plugin/install.ts +15 -3
  9. package/src/hooks/artifact-finish-nudge.ts +35 -3
  10. package/src/lib/context-mode-package.ts +1 -1
  11. package/src/lib/orchestrator/companion-git-sync.ts +67 -8
  12. package/src/lib/orchestrator/companion-hub.ts +28 -6
  13. package/src/lib/orchestrator/types.ts +2 -2
  14. package/src/lib/package-paths.ts +1 -0
  15. package/src/opencode/companion-hooks.ts +33 -12
  16. package/src/playbooks/companion-guidance.ts +1 -1
  17. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-openspec-backfill/SKILL.md +65 -0
  18. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +41 -14
  19. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +9 -6
  20. package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
  21. package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
  22. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +155 -45
  23. package/src/tools/setup/capabilities/context-mode.ts +9 -0
  24. package/src/tools/setup/capabilities/openspec.ts +11 -0
  25. package/src/tools/setup/dynamic-plugins/hydrate.ts +15 -1
  26. package/src/tools/setup/engine.ts +4 -1
  27. package/src/tools/setup/mate.ts +10 -2
  28. package/src/tools/setup/plugin.ts +26 -2
  29. package/src/tools/setup/plugins/gitignore.ts +7 -4
  30. package/src/tools/setup/providers/claude.ts +53 -8
  31. package/src/tools/setup/providers/opencode.ts +51 -8
  32. package/src/tools/setup.ts +7 -56
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Thin shim: hook logic lives in src/hooks/artifact-finish-nudge.ts
3
- // (loaded via node's native TypeScript type stripping, engines node >= 24).
4
- import { run } from "../../src/hooks/artifact-finish-nudge.ts";
2
+ // Thin shim: hook logic lives in src/hooks/artifact-finish-nudge.ts, loaded
3
+ // through ts-loader.mjs because the installed plugin sits inside node_modules
4
+ // where node's native type stripping is disabled.
5
+ import "./ts-loader.mjs";
5
6
 
7
+ const { run } = await import("../../src/hooks/artifact-finish-nudge.ts");
6
8
  process.exitCode = await run();
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Thin shim: hook logic lives in src/hooks/session-banner.ts
3
- // (loaded via node's native TypeScript type stripping, engines node >= 24).
4
- import { run } from "../../src/hooks/session-banner.ts";
2
+ // Thin shim: hook logic lives in src/hooks/session-banner.ts, loaded through
3
+ // ts-loader.mjs because the installed plugin sits inside node_modules where
4
+ // node's native type stripping is disabled.
5
+ import "./ts-loader.mjs";
5
6
 
7
+ const { run } = await import("../../src/hooks/session-banner.ts");
6
8
  process.exitCode = run();
@@ -0,0 +1,28 @@
1
+ // Synchronous module loader that strips TypeScript types itself. Node's
2
+ // built-in .ts loading refuses files under node_modules
3
+ // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), which is exactly where the
4
+ // published plugin lives — so the hook shims register this loader before
5
+ // importing their src/hooks/*.ts implementation.
6
+ import fs from "node:fs";
7
+ import { registerHooks, stripTypeScriptTypes } from "node:module";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ // stripTypeScriptTypes emits an ExperimentalWarning; hooks must keep stderr
11
+ // clean because Claude Code surfaces it to the user on non-zero exits.
12
+ const emitWarning = process.emitWarning;
13
+ process.emitWarning = (warning, ...args) => {
14
+ if (String(warning).includes("stripTypeScriptTypes")) return;
15
+ emitWarning.call(process, warning, ...args);
16
+ };
17
+
18
+ registerHooks({
19
+ load(url, context, next) {
20
+ if (!url.endsWith(".ts")) return next(url, context);
21
+ const source = fs.readFileSync(fileURLToPath(url), "utf8");
22
+ return {
23
+ format: "module",
24
+ source: stripTypeScriptTypes(source),
25
+ shortCircuit: true,
26
+ };
27
+ },
28
+ });
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Thin shim: hook logic lives in src/hooks/validate-artifact-path.ts
3
- // (loaded via node's native TypeScript type stripping, engines node >= 24).
4
- import { run } from "../../src/hooks/validate-artifact-path.ts";
2
+ // Thin shim: hook logic lives in src/hooks/validate-artifact-path.ts, loaded
3
+ // through ts-loader.mjs because the installed plugin sits inside node_modules
4
+ // where node's native type stripping is disabled.
5
+ import "./ts-loader.mjs";
5
6
 
7
+ const { run } = await import("../../src/hooks/validate-artifact-path.ts");
6
8
  process.exitCode = await run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.15.4-canary.9",
3
+ "version": "0.15.5-canary.0",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -79,7 +79,26 @@ export async function runOpenspecCapCommand(
79
79
  readOpenSpecProjectSchema(companionPath),
80
80
  );
81
81
 
82
- const result = spawnSync("openspec", forwardedArgs, { cwd, stdio: "inherit" });
82
+ if (forwardedArgs[0] === "init" || forwardedArgs[0] === "update") {
83
+ const reset = spawnSync("openspec", ["config", "reset", "--all", "-y"], {
84
+ cwd,
85
+ stdio: "inherit",
86
+ });
87
+ if (reset.error) {
88
+ process.stderr.write(`Failed to reset openspec config: ${reset.error.message}\n`);
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+ if (reset.status !== 0) {
93
+ process.exitCode = reset.status ?? 1;
94
+ return;
95
+ }
96
+ }
97
+
98
+ const result = spawnSync("openspec", forwardedArgs, {
99
+ cwd,
100
+ stdio: "inherit",
101
+ });
83
102
  if (result.error) {
84
103
  process.stderr.write(`Failed to run openspec: ${result.error.message}\n`);
85
104
  process.exitCode = 1;
@@ -50,6 +50,7 @@ async function selectRegisteredCompanion(): Promise<string> {
50
50
  async function runHubInit(argv: string[]): Promise<void> {
51
51
  const folder = positionalArgs(argv)[0] ?? process.cwd();
52
52
  const hubPath = await initializeCompanionHub(folder);
53
+ await updateHubPlugins(hubPath);
53
54
  console.log(`Initialized companion hub: ${hubPath}`);
54
55
  }
55
56
 
@@ -2,8 +2,10 @@ import path from "node:path";
2
2
 
3
3
  import { FRAMEWORK_NAME } from "../../../framework";
4
4
  import { resolveInstallContext } from "../../../lib/install";
5
+ import { backfillHubAllowedAgents } from "../../../lib/orchestrator/companion-hub";
5
6
  import { ConfigStore } from "../../../lib/orchestrator/config-store";
6
- import type { PluginDeclaration } from "../../../lib/orchestrator/types";
7
+ import type { FrameworkConfig, PluginDeclaration } from "../../../lib/orchestrator/types";
8
+ import { applySetupCompatibilities } from "../../../tools/setup";
7
9
  import { hydrateDynamicPlugins } from "../../../tools/setup/dynamic-plugins/hydrate";
8
10
  import {
9
11
  installDeclaredPlugins,
@@ -33,6 +35,7 @@ function withDeclaration(
33
35
  export interface PluginInstallCommandDeps {
34
36
  cwd?: string;
35
37
  installDeps?: PluginInstallDeps;
38
+ setupHub?: (companionPath: string, config: FrameworkConfig) => Promise<void>;
36
39
  }
37
40
 
38
41
  /**
@@ -78,8 +81,10 @@ export async function runPluginInstallCommand(
78
81
  );
79
82
  const store = new ConfigStore(configPath);
80
83
  const nextPlugins = withDeclaration(context.config.plugins ?? [], packageName, version);
81
- if (nextPlugins !== context.config.plugins) {
82
- await store.save({ ...context.config, plugins: nextPlugins });
84
+ const nextConfig = { ...context.config, plugins: nextPlugins };
85
+ const agentsBackfilled = context.kind === "hub" && backfillHubAllowedAgents(nextConfig);
86
+ if (nextPlugins !== context.config.plugins || agentsBackfilled) {
87
+ await store.save(nextConfig);
83
88
  }
84
89
 
85
90
  const results = await installDeclaredPlugins(
@@ -89,6 +94,13 @@ export async function runPluginInstallCommand(
89
94
  );
90
95
  reportPluginInstallResults(results);
91
96
  await hydrateDynamicPlugins({ companionPath: context.companionPath });
97
+ if (context.kind === "hub") {
98
+ await (
99
+ deps.setupHub ??
100
+ ((companionPath, config) =>
101
+ applySetupCompatibilities(companionPath, config, "sync", undefined, undefined, "hub"))
102
+ )(context.companionPath, nextConfig);
103
+ }
92
104
 
93
105
  return results.find((result) => result.package === packageName)?.status !== "failed";
94
106
  }
@@ -79,6 +79,36 @@ function extractChangeFromPath(value: string): string | null {
79
79
  return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
80
80
  }
81
81
 
82
+ // Best-effort shell variable substitution, NOT a shell interpreter: a change
83
+ // name is often built up through variables (DEST="...archive/$TARGET"; mv a
84
+ // "$DEST") rather than appearing as a literal path next to mv/archive. This
85
+ // resolves $NAME/${NAME} references against preceding literal NAME=value
86
+ // assignment tokens in the same command string; unresolved references are
87
+ // left as-is, which keeps prior (silent) behavior for anything it can't follow.
88
+ const ASSIGNMENT_PATTERN = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
89
+ const VARIABLE_REFERENCE_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
90
+
91
+ function substituteVariables(value: string, vars: Map<string, string>): string {
92
+ return value.replace(VARIABLE_REFERENCE_PATTERN, (match, braced: string, bare: string) => {
93
+ const name = braced ?? bare;
94
+ return vars.has(name) ? vars.get(name)! : match;
95
+ });
96
+ }
97
+
98
+ // Assignments are collected in left-to-right token order so a later
99
+ // assignment (e.g. DEST) can reference an earlier one (e.g. TARGET) the same
100
+ // way the shell would resolve it at that point in the script.
101
+ function collectVariableAssignments(parts: string[]): Map<string, string> {
102
+ const vars = new Map<string, string>();
103
+ for (const part of parts) {
104
+ const match = part.match(ASSIGNMENT_PATTERN);
105
+ if (!match) continue;
106
+ const [, name, rawValue] = match;
107
+ vars.set(name, substituteVariables(rawValue, vars));
108
+ }
109
+ return vars;
110
+ }
111
+
82
112
  // Shell syntax tokens are never change-name positionals: separators end the
83
113
  // archive invocation, redirections are skipped (a bare operator also consumes
84
114
  // its target token).
@@ -95,6 +125,7 @@ export function extractArchiveCommand(command: string): string | null {
95
125
  (parts[position - 1] === "openspec" || parts[position - 1].endsWith("/openspec")),
96
126
  );
97
127
  if (index < 0) return null;
128
+ const vars = collectVariableAssignments(parts);
98
129
  for (let position = index + 1; position < parts.length; position += 1) {
99
130
  const part = parts[position];
100
131
  if (!part || part === "--") continue;
@@ -107,7 +138,7 @@ export function extractArchiveCommand(command: string): string | null {
107
138
  if (part === "--store") position += 1;
108
139
  continue;
109
140
  }
110
- return part;
141
+ return substituteVariables(part, vars);
111
142
  }
112
143
  return null;
113
144
  }
@@ -130,15 +161,16 @@ function isPython(part: string): boolean {
130
161
 
131
162
  export function extractMoveCommand(command: string): string | null {
132
163
  const parts = shellSplit(command);
164
+ const vars = collectVariableAssignments(parts);
133
165
  for (let index = 0; index < parts.length; index += 1) {
134
166
  if (MOVE_COMMANDS.has(parts[index].toLowerCase())) {
135
167
  for (const part of parts.slice(index + 1)) {
136
- const change = extractChangeFromPath(part);
168
+ const change = extractChangeFromPath(substituteVariables(part, vars));
137
169
  if (change) return change;
138
170
  }
139
171
  }
140
172
  if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(command)) {
141
- return extractChangeFromPath(command);
173
+ return extractChangeFromPath(substituteVariables(command, vars));
142
174
  }
143
175
  }
144
176
  return null;
@@ -20,7 +20,7 @@ export function isContextModePackageReference(value: unknown): value is string {
20
20
  }
21
21
 
22
22
  export function getContextModeInstallDir(companionPath: string): string {
23
- return path.join(companionPath, ".mate", "dependencies", CONTEXT_MODE_PACKAGE_NAME);
23
+ return path.join(companionPath, ".mate", "plugins", ".local");
24
24
  }
25
25
 
26
26
  export function getContextModePackageRoot(companionPath: string): string {
@@ -9,6 +9,9 @@ import { LaunchPreflightError } from "./types";
9
9
 
10
10
  const execFile = promisify(execFileCallback);
11
11
 
12
+ /** Bounded but far above any diagnostic output; default 1 MiB kills git mid-merge on large trees. */
13
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
14
+
12
15
  export interface GitCommandResult {
13
16
  status: number;
14
17
  stdout: string;
@@ -26,22 +29,30 @@ export interface CompanionGitSyncResult {
26
29
  export class CompanionGitSyncError extends LaunchPreflightError {
27
30
  readonly companionPath: string;
28
31
  readonly conflictingPaths: string[];
32
+ readonly reason: string;
33
+ readonly recovery: string;
34
+ readonly stashRef: string | undefined;
29
35
 
30
36
  constructor(
31
37
  companionPath: string,
32
38
  reason: string,
33
39
  conflictingPaths: string[] = [],
34
40
  recovery = "Resolve or abort the Git operation, then retry the launch.",
41
+ stashRef?: string,
35
42
  ) {
36
43
  const conflicts = conflictingPaths.length
37
44
  ? `\n Conflicting paths:\n${conflictingPaths.map((entry) => ` - ${entry}`).join("\n")}`
38
45
  : "";
46
+ const stash = stashRef
47
+ ? ` Local changes were stashed as ${stashRef}. Recover with \`git stash apply ${stashRef}\` in the companion.`
48
+ : "";
39
49
  super(
40
50
  [
41
51
  "mate: companion Git synchronization failed.",
42
52
  ` Companion: ${companionPath}`,
43
53
  ` ${reason}`,
44
54
  conflicts,
55
+ stash,
45
56
  ` ${recovery}`,
46
57
  " Bypass: `mate claude -- --no-git` or `mate opencode -- --no-git`.",
47
58
  ]
@@ -51,6 +62,9 @@ export class CompanionGitSyncError extends LaunchPreflightError {
51
62
  this.name = "CompanionGitSyncError";
52
63
  this.companionPath = companionPath;
53
64
  this.conflictingPaths = conflictingPaths;
65
+ this.reason = reason;
66
+ this.recovery = recovery;
67
+ this.stashRef = stashRef;
54
68
  }
55
69
  }
56
70
 
@@ -68,6 +82,7 @@ export const companionGitSyncDeps: { runGit: GitRunner } = {
68
82
  cwd,
69
83
  encoding: "utf8",
70
84
  env: gitEnv,
85
+ maxBuffer: GIT_MAX_BUFFER,
71
86
  });
72
87
  return { status: 0, stdout: String(result.stdout), stderr: String(result.stderr) };
73
88
  } catch (error) {
@@ -106,6 +121,21 @@ function outputLines(stdout: string): string[] {
106
121
  .filter(Boolean);
107
122
  }
108
123
 
124
+ /** Matches Git progress-meter lines such as `Updating files: 76% (11340/14790)` or `..., done.` */
125
+ const GIT_PROGRESS_LINE = /^\S.*?: +\d+% \(\d+\/\d+\)(?:, done\.)?$/;
126
+
127
+ function stripGitProgress(text: string): string {
128
+ return text
129
+ .split(/\r\n|\r|\n/)
130
+ .filter((line) => !GIT_PROGRESS_LINE.test(line.trim()))
131
+ .join("\n")
132
+ .trim();
133
+ }
134
+
135
+ export function describeGitFailure(result: GitCommandResult): string {
136
+ return stripGitProgress(result.stderr) || stripGitProgress(result.stdout) || "unknown Git error";
137
+ }
138
+
109
139
  export class CompanionGitSync {
110
140
  constructor(private readonly runGit: GitRunner = companionGitSyncDeps.runGit) {}
111
141
 
@@ -160,7 +190,42 @@ export class CompanionGitSync {
160
190
  stashRef = ref.stdout.trim();
161
191
  }
162
192
 
163
- const merge = await this.command(companionPath, ["merge", target.ref, "--no-edit"]);
193
+ const headBefore = await this.command(companionPath, ["rev-parse", "HEAD"]);
194
+ try {
195
+ await this.mergeAndRestore(companionPath, target, stashRef);
196
+ } catch (error) {
197
+ if (stashRef && error instanceof CompanionGitSyncError && !error.stashRef) {
198
+ throw new CompanionGitSyncError(
199
+ error.companionPath,
200
+ error.reason,
201
+ error.conflictingPaths,
202
+ error.recovery,
203
+ stashRef,
204
+ );
205
+ }
206
+ throw error;
207
+ }
208
+
209
+ const headAfter = await this.command(companionPath, ["rev-parse", "HEAD"]);
210
+ return {
211
+ skipped: false,
212
+ changed: headBefore.stdout.trim() !== headAfter.stdout.trim(),
213
+ companionPath,
214
+ };
215
+ }
216
+
217
+ private async mergeAndRestore(
218
+ companionPath: string,
219
+ target: SyncTarget,
220
+ stashRef: string | undefined,
221
+ ): Promise<void> {
222
+ const merge = await this.command(companionPath, [
223
+ "merge",
224
+ target.ref,
225
+ "--no-stat",
226
+ "--no-progress",
227
+ "--no-edit",
228
+ ]);
164
229
  if (merge.status !== 0) {
165
230
  await this.resolveManagedConflicts(companionPath, target.ref);
166
231
  const conflicts = await this.unresolvedPaths(companionPath);
@@ -220,12 +285,6 @@ export class CompanionGitSync {
220
285
  // Keep the stash if dropping it fails; it remains a recoverable backup.
221
286
  await this.command(companionPath, ["stash", "drop", stashRef]);
222
287
  }
223
-
224
- return {
225
- skipped: false,
226
- changed: merge.status === 0 && merge.stdout.trim() !== "",
227
- companionPath,
228
- };
229
288
  }
230
289
 
231
290
  private async assertSafeCompanionRoot(
@@ -410,7 +469,7 @@ export class CompanionGitSync {
410
469
  }
411
470
 
412
471
  private detail(result: GitCommandResult): string {
413
- return (result.stderr || result.stdout || "unknown Git error").trim();
472
+ return describeGitFailure(result);
414
473
  }
415
474
 
416
475
  private failure(
@@ -8,12 +8,14 @@ import { FRAMEWORK_NAME } from "../../framework";
8
8
  import { ConfigStore, validateHubConfig } from "./config-store";
9
9
  import { GlobalConfigStore } from "./global-config-store";
10
10
  import type { FrameworkConfig, HubMember, HubMemberSource } from "./types";
11
+ import { listSetupProviderCompatibilities } from "./setup-compatibilities";
11
12
  import {
12
13
  installDeclaredPlugins,
13
14
  type PluginInstallDeps,
14
15
  type PluginInstallResult,
15
16
  } from "../../tools/setup/dynamic-plugins/install";
16
17
  import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
18
+ import { applySetupCompatibilities } from "../../tools/setup";
17
19
  import { findRepoLocalRegistryFile } from "./repo-local-registry";
18
20
 
19
21
  export interface GitCommandResult {
@@ -41,6 +43,7 @@ export interface HubSyncResult {
41
43
  export interface HubPluginSyncDeps {
42
44
  installDeps?: PluginInstallDeps;
43
45
  hydrate?: (options: { companionPath: string }) => Promise<void>;
46
+ setup?: (companionPath: string, config: FrameworkConfig, mode: "setup" | "sync") => Promise<void>;
44
47
  }
45
48
 
46
49
  export function defaultGitCommand(cwd: string, args: string[]): GitCommandResult {
@@ -123,7 +126,7 @@ export async function initializeCompanionHub(
123
126
  } else {
124
127
  const config: FrameworkConfig = {
125
128
  type: "hub",
126
- allowedAgents: [],
129
+ allowedAgents: listSetupProviderCompatibilities().map((entry) => entry.agent),
127
130
  packageManagers: [],
128
131
  capabilities: [],
129
132
  hub: { companions: [] },
@@ -365,16 +368,35 @@ export async function syncHub(
365
368
  return results;
366
369
  }
367
370
 
371
+ /**
372
+ * Hubs persisted before provider bootstrapping carry `allowedAgents: []`;
373
+ * an empty list on a hub means "all built-in agents", not "none".
374
+ */
375
+ export function backfillHubAllowedAgents(config: FrameworkConfig): boolean {
376
+ if (config.type !== "hub" || config.allowedAgents.length > 0) return false;
377
+ config.allowedAgents = listSetupProviderCompatibilities().map((entry) => entry.agent);
378
+ return true;
379
+ }
380
+
368
381
  export async function updateHubPlugins(
369
382
  hubPath: string,
370
383
  deps: HubPluginSyncDeps = {},
371
384
  ): Promise<PluginInstallResult[]> {
372
- const { config } = await assertHubRoot(hubPath);
385
+ const { config, store } = await assertHubRoot(hubPath);
386
+ if (backfillHubAllowedAgents(config)) await store.save(config);
373
387
  const declarations = config.plugins ?? [];
374
- if (declarations.length === 0) return [];
375
-
376
388
  const resolvedHubPath = path.resolve(hubPath);
377
- const results = await installDeclaredPlugins(resolvedHubPath, declarations, deps.installDeps);
378
- await (deps.hydrate ?? hydrateDynamicPlugins)({ companionPath: resolvedHubPath });
389
+ const results =
390
+ declarations.length > 0
391
+ ? await installDeclaredPlugins(resolvedHubPath, declarations, deps.installDeps)
392
+ : [];
393
+ if (declarations.length > 0) {
394
+ await (deps.hydrate ?? hydrateDynamicPlugins)({ companionPath: resolvedHubPath });
395
+ }
396
+ await (
397
+ deps.setup ??
398
+ ((companionPath, setupConfig, mode) =>
399
+ applySetupCompatibilities(companionPath, setupConfig, mode, undefined, undefined, "hub"))
400
+ )(resolvedHubPath, config, "sync");
379
401
  return results;
380
402
  }
@@ -96,8 +96,8 @@ export type PluginDeclarationPolicy = "default" | "optional";
96
96
  /**
97
97
  * A companion-declared npm plugin: installed by setup/install into the
98
98
  * companion's own shared plugin workspace (`.mate/plugins/`) and loaded
99
- * from there on every invocation. Declaration registers the plugin;
100
- * enablement stays in the `capabilities` list.
99
+ * from there on every invocation. Declaration both registers and activates
100
+ * the plugin; dynamic plugins do not need a separate `capabilities` entry.
101
101
  */
102
102
  export interface PluginDeclaration {
103
103
  /** npm package name (e.g. `@acme/custom-plugin`). */
@@ -46,6 +46,7 @@ export function validateClaudePluginAssets(pluginRoot = getClaudePluginRoot()):
46
46
  const expected = [
47
47
  path.join(".claude-plugin", "plugin.json"),
48
48
  path.join("hooks", "hooks.json"),
49
+ path.join("hooks", "ts-loader.mjs"),
49
50
  ...CLAUDE_PLUGIN_HOOK_SHIMS.map((shim) => path.join("hooks", shim)),
50
51
  ];
51
52
  const missing = expected.filter((asset) => !fs.existsSync(path.join(pluginRoot, asset)));
@@ -179,15 +179,12 @@ function detectCommandArchive(input: unknown, output: unknown): string | null {
179
179
 
180
180
  function detectNewlyArchivedChanges(archiveDir: string, snapshot: Set<string>): string[] {
181
181
  const current = readArchiveEntries(archiveDir);
182
- const newlyArchived = [...current].filter((entry) => !snapshot.has(entry)).toSorted();
183
- snapshot.clear();
184
- for (const entry of current) snapshot.add(entry);
185
- return newlyArchived;
182
+ return [...current].filter((entry) => !snapshot.has(entry)).toSorted();
186
183
  }
187
184
 
188
185
  function appendOpenSpecFinishNudge(
189
186
  context: CompanionContext,
190
- archiveSnapshot: Set<string>,
187
+ archiveSnapshot: Set<string> | undefined,
191
188
  nudgedCommandChanges: Set<string>,
192
189
  input: { tool?: unknown },
193
190
  output: { output?: string },
@@ -196,8 +193,10 @@ function appendOpenSpecFinishNudge(
196
193
 
197
194
  const archiveDir = path.join(context.companionPath, "openspec", "changes", "archive");
198
195
  const changes: string[] = [];
199
- for (const entry of detectNewlyArchivedChanges(archiveDir, archiveSnapshot)) {
200
- changes.push(entry.slice("YYYY-MM-DD-".length));
196
+ if (archiveSnapshot) {
197
+ for (const entry of detectNewlyArchivedChanges(archiveDir, archiveSnapshot)) {
198
+ changes.push(entry.slice("YYYY-MM-DD-".length));
199
+ }
201
200
  }
202
201
  const commandChange =
203
202
  String(input.tool ?? "").toLowerCase() === "bash" ? detectCommandArchive(input, output) : null;
@@ -313,22 +312,36 @@ async function runReactDoctorScan(
313
312
  }
314
313
 
315
314
  type PluginEventInput = Parameters<NonNullable<Hooks["event"]>>[0];
315
+ type ToolBeforeInput = Parameters<NonNullable<Hooks["tool.execute.before"]>>[0];
316
+ type ToolBeforeOutput = Parameters<NonNullable<Hooks["tool.execute.before"]>>[1];
316
317
  type ToolAfterInput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[0];
317
318
  type ToolAfterOutput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[1];
318
319
 
320
+ interface ArchiveCallSnapshot {
321
+ sessionID: string;
322
+ entries: Set<string>;
323
+ }
324
+
319
325
  export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInput) => {
320
326
  const { client, $ } = pluginInput;
321
327
  const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
322
328
  if (!context.companionPath || !context.repositoryPath) return {};
323
329
 
324
330
  const archiveDir = path.join(context.companionPath, "openspec", "changes", "archive");
325
- const archiveSnapshot = readArchiveEntries(archiveDir);
331
+ const archiveCallSnapshots = new Map<string, ArchiveCallSnapshot>();
326
332
  const nudgedCommandChanges = new Set<string>();
327
333
  const dirtyReactDoctorSessions = new Set<string>();
328
334
  const reactDoctorScansInFlight = new Set<string>();
329
335
 
330
336
  return {
331
337
  event: async ({ event }: PluginEventInput) => {
338
+ if (event.type === "session.deleted") {
339
+ const sessionID = event.properties.info.id;
340
+ for (const [callID, snapshot] of archiveCallSnapshots) {
341
+ if (snapshot.sessionID === sessionID) archiveCallSnapshots.delete(callID);
342
+ }
343
+ return;
344
+ }
332
345
  if (event.type !== "session.idle") return;
333
346
  const sessionID = event.properties.sessionID;
334
347
  if (
@@ -340,10 +353,7 @@ export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInp
340
353
  }
341
354
  await runReactDoctorScan(context, client, $, sessionID, reactDoctorScansInFlight);
342
355
  },
343
- "tool.execute.before": async (
344
- input: { tool: unknown },
345
- output: { args: { filePath?: unknown; patchText?: unknown } | undefined },
346
- ) => {
356
+ "tool.execute.before": async (input: ToolBeforeInput, output: ToolBeforeOutput) => {
347
357
  const toolName = String(input.tool ?? "");
348
358
  const args = output.args ?? {};
349
359
  if (["write", "edit"].includes(toolName)) {
@@ -359,13 +369,24 @@ export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInp
359
369
  }
360
370
  }
361
371
  }
372
+ if (context.gitAutoModeEnabled) {
373
+ archiveCallSnapshots.set(input.callID, {
374
+ sessionID: input.sessionID,
375
+ entries: readArchiveEntries(archiveDir),
376
+ });
377
+ }
362
378
  },
363
379
  "tool.execute.after": async (input: ToolAfterInput, output: ToolAfterOutput) => {
364
380
  if (context.reactDoctorEnabled && REACT_DOCTOR_EDIT_TOOLS.has(input.tool)) {
365
381
  dirtyReactDoctorSessions.add(input.sessionID);
366
382
  }
383
+ const archiveSnapshot = archiveCallSnapshots.get(input.callID)?.entries;
384
+ archiveCallSnapshots.delete(input.callID);
367
385
  appendOpenSpecFinishNudge(context, archiveSnapshot, nudgedCommandChanges, input, output);
368
386
  },
387
+ dispose: async () => {
388
+ archiveCallSnapshots.clear();
389
+ },
369
390
  };
370
391
  };
371
392
 
@@ -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: ${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>`,
106
+ ` <rule id="openspec-finish" severity="critical">Finish OpenSpec changes by archiving them: the archive triggers a nudge directing you to run ${FRAMEWORK_NAME} artifact finish "<name>" --json — if no nudge arrives, invoke that command yourself. It is the only sanctioned completion; never hand-commit or hand-tag a finish. Finishing a still-active change archives it and 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