@uniqbit/mate-core 0.15.4-canary.10 → 0.15.4-canary.12
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 +1 -1
- package/src/cli/commands/cap/openspec.ts +20 -1
- package/src/cli/commands/companion/hub.ts +1 -0
- package/src/cli/commands/plugin/install.ts +15 -3
- package/src/hooks/artifact-finish-nudge.ts +35 -3
- package/src/lib/orchestrator/companion-git-sync.ts +67 -8
- package/src/lib/orchestrator/companion-hub.ts +28 -6
- package/src/lib/orchestrator/types.ts +2 -2
- package/src/opencode/companion-hooks.ts +33 -12
- package/src/playbooks/companion-guidance.ts +1 -1
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-openspec-backfill/SKILL.md +65 -0
- package/src/templates/root/TEMPLATE_AGENTS.md +1 -1
- package/src/templates/root/TEMPLATE_CLAUDE.md +1 -1
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +113 -42
- package/src/tools/setup/capabilities/openspec.ts +11 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +15 -1
- package/src/tools/setup/engine.ts +4 -1
- package/src/tools/setup/mate.ts +10 -2
- package/src/tools/setup/plugin.ts +26 -2
- package/src/tools/setup/providers/claude.ts +53 -8
- package/src/tools/setup/providers/opencode.ts +51 -8
- package/src/tools/setup.ts +7 -56
package/package.json
CHANGED
|
@@ -79,7 +79,26 @@ export async function runOpenspecCapCommand(
|
|
|
79
79
|
readOpenSpecProjectSchema(companionPath),
|
|
80
80
|
);
|
|
81
81
|
|
|
82
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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;
|
|
@@ -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
|
|
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
|
|
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 =
|
|
378
|
-
|
|
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
|
|
100
|
-
*
|
|
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`). */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
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
|
|
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
|
|
package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-openspec-backfill/SKILL.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mate-openspec-backfill
|
|
3
|
+
description: Reverse-engineer an OpenSpec spec for one existing feature and emit a ready-to-finish backfill change. Use when the user wants to backfill, document, or spec existing or legacy behavior that has no spec yet.
|
|
4
|
+
allowed-tools: Bash(openspec:*), Bash(mate:*)
|
|
5
|
+
license: MIT
|
|
6
|
+
compatibility: Requires the mate CLI and the openspec capability enabled.
|
|
7
|
+
metadata:
|
|
8
|
+
author: mate
|
|
9
|
+
version: "1.0"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
Create a spec for one feature that already exists in the working repository. The run ends with a standard ready-to-finish change — it never edits main specs and never finishes.
|
|
13
|
+
|
|
14
|
+
## Scope rules
|
|
15
|
+
|
|
16
|
+
- **One named feature per run.** Refuse Area-wide or repository-wide sweeps; ask the user to name a single feature and run the skill once per feature.
|
|
17
|
+
- **Interactive by design.** Every ambiguity and every suspected bug becomes a user question. Do not run this skill unattended.
|
|
18
|
+
|
|
19
|
+
## Steps
|
|
20
|
+
|
|
21
|
+
1. **Scope.** Map the named feature to code: entry points, callees, tests. Use whatever exploration tooling this project has enabled (code-graph or index tools when present, otherwise search and targeted reading) — assume no specific capability is installed. Then check `openspec/specs/` for an existing capability covering this domain — prefer extending it (`MODIFIED`/`ADDED` deltas) over minting a new capability id.
|
|
22
|
+
|
|
23
|
+
2. **Sweep.** Extract candidate behaviors and tag each finding:
|
|
24
|
+
- `[test-backed]` — an existing test verifies it (strongest; scenarios translate almost directly from tests)
|
|
25
|
+
- `[code-only]` — observable in code but untested
|
|
26
|
+
- `[inferred]` — assumed intent without direct evidence
|
|
27
|
+
|
|
28
|
+
Every candidate requirement needs at least one citation: a test name or `file:line`. Docs and comments corroborate but never stand alone. `[inferred]` findings are not requirements — they become questions for step 3.
|
|
29
|
+
|
|
30
|
+
3. **Ask.** Batch the open questions to the user:
|
|
31
|
+
- Behavior that looks unintended → the user rules **spec the actual behavior** or **spec the intent** (with a follow-up fix change). Suspected bugs never silently become requirements.
|
|
32
|
+
- `[inferred]` findings → confirm, demote to out-of-scope, or convert to a question the emitted proposal records as open.
|
|
33
|
+
|
|
34
|
+
4. **Emit.** Create the change and build its artifacts in dependency order:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
openspec new change "backfill-spec-<capability>"
|
|
38
|
+
openspec status --change "backfill-spec-<capability>" --json
|
|
39
|
+
openspec instructions <artifact-id> --change "backfill-spec-<capability>" --json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The artifact set comes from the active schema (`schemaName` in the status JSON) — never assume a fixed artifact list. Follow each artifact's returned instructions and template, and state the active schema in the proposal so reviewers know which workflow produced the change. Map the backfill roles onto whatever artifacts the schema defines:
|
|
43
|
+
|
|
44
|
+
| Backfill role | Typical artifact (mate-v1 example) |
|
|
45
|
+
| ---------------------------------------------------------------------------------------------- | ---------------------------------- |
|
|
46
|
+
| Scope decisions and rulings from step 3 | explore-brief.md |
|
|
47
|
+
| "Documents existing behavior, no code changes" + open questions | proposal.md |
|
|
48
|
+
| `ADDED`/`MODIFIED` requirements, behavior only, one citation each | specs/ |
|
|
49
|
+
| As-built evidence dossier: entry points, test inventory, `file:line` citations per requirement | design.md |
|
|
50
|
+
| Verification checklist: one task per requirement, "confirm behavior at <citation>" | tasks.md |
|
|
51
|
+
|
|
52
|
+
The verification task artifact MUST open with this rule, verbatim, so the applying agent sees it without knowing this skill: "These are verification tasks for a docs-only backfill change. If a requirement fails verification, update the delta spec (reword, drop, or re-cite the requirement) — never modify code in this change. A real bug found here becomes a separate fix change."
|
|
53
|
+
|
|
54
|
+
Requirements state observable contracts, never implementation detail ("propagates the child exit code", not "uses spawnSync").
|
|
55
|
+
|
|
56
|
+
5. **Stop.** Report the change as ready-to-finish and hand off:
|
|
57
|
+
- Verify: `openspec-apply-change` works through tasks.md, checking each requirement against the code.
|
|
58
|
+
- Finish: `mate-artifact-finish` applies the deltas to main specs and anchors the change.
|
|
59
|
+
|
|
60
|
+
## Guardrails
|
|
61
|
+
|
|
62
|
+
- Never write files under `openspec/specs/` — main specs change only through finished changes.
|
|
63
|
+
- Never invoke any finish flow (`mate artifact finish`, `openspec archive`); stop at ready-to-finish.
|
|
64
|
+
- Never emit a requirement without a citation, and never spec a suspected bug without the user's ruling.
|
|
65
|
+
- Keep capability ids opaque kebab-case; extend existing capabilities before creating new ones.
|
|
@@ -12,6 +12,6 @@ This block is kept for AGENTS.md compatibility and must not restate that policy.
|
|
|
12
12
|
- Code comments: JSDoc format only (`/** ... */`), never `//`. Sparse — only non-obvious invariants or constraints, never restated artifact rationale.
|
|
13
13
|
- Never add a `Co-Authored-By: <model>` trailer or model-attribution footer to commit messages.
|
|
14
14
|
- Never commit, push, or open a pull request in the working repo (`$MATE_REPO_PATH`) unless the user explicitly asks for it.
|
|
15
|
-
- Never
|
|
15
|
+
- Never connect to a database (local or remote), touch live/external systems (deploys, infra), or take destructive/irreversible actions without asking the user first.
|
|
16
16
|
|
|
17
17
|
<!-- MATE:COMPANION:END -->
|
|
@@ -14,6 +14,6 @@ This block is kept for Claude/AGENTS.md compatibility and must not restate that
|
|
|
14
14
|
- The root `CLAUDE.md` in the companion repo is intentional and loaded by Mate. Do not create another project-level `CLAUDE.md` in the working repo unless the user explicitly asks for one.
|
|
15
15
|
- Never add a `Co-Authored-By: <model>` trailer or model-attribution footer to commit messages.
|
|
16
16
|
- Never commit, push, or open a pull request in the working repo (`$MATE_REPO_PATH`) unless the user explicitly asks for it.
|
|
17
|
-
- Never
|
|
17
|
+
- Never connect to a database (local or remote), touch live/external systems (deploys, infra), or take destructive/irreversible actions without asking the user first.
|
|
18
18
|
|
|
19
19
|
<!-- MATE:COMPANION:END -->
|