@uniqbit/mate-core 0.18.3-canary.1 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-plugin/hooks/hooks.json +10 -1
- package/claude-plugin/hooks/session-banner.mjs +4 -3
- package/claude-plugin/hooks/session-prepare.mjs +9 -0
- package/opencode-plugin/src/companion.ts +47 -12
- package/opencode-plugin/src/server.ts +39 -4
- package/package.json +3 -3
- package/src/cli/commands/artifact/finish/openspec.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +60 -21
- package/src/cli/commands/companion/link.ts +16 -11
- package/src/cli/commands/companion/list.ts +4 -5
- package/src/cli/commands/companion/open.ts +4 -5
- package/src/cli/commands/companion/tui.ts +1 -1
- package/src/cli/commands/doctor.ts +1 -1
- package/src/cli/commands/report/renderer.ts +9 -19
- package/src/cli/commands/session/prepare.ts +119 -0
- package/src/cli/commands/shared/companion-selection.ts +2 -2
- package/src/cli/commands/studio/reports.ts +0 -1
- package/src/cli/commands/studio/server.ts +6 -2
- package/src/cli/commands/unwrap.ts +28 -3
- package/src/cli/commands/wrap.ts +53 -59
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/main.ts +17 -0
- package/src/cli/usage.ts +3 -1
- package/src/hooks/session-banner.ts +19 -10
- package/src/hooks/session-prepare.ts +101 -0
- package/src/lib/fs-utils.ts +13 -0
- package/src/lib/orchestrator/adapters/base.ts +43 -8
- package/src/lib/orchestrator/adapters/claude.ts +130 -29
- package/src/lib/orchestrator/adapters/opencode.ts +17 -19
- package/src/lib/orchestrator/companion-git-sync.ts +28 -1
- package/src/lib/orchestrator/companion-hub.ts +2 -1
- package/src/lib/orchestrator/config-store.ts +9 -0
- package/src/lib/orchestrator/framework-context.ts +5 -29
- package/src/lib/orchestrator/launcher.ts +36 -171
- package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
- package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
- package/src/lib/orchestrator/projection-entries.ts +78 -11
- package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
- package/src/lib/orchestrator/projection-record.ts +7 -1
- package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
- package/src/lib/orchestrator/projection-types.ts +38 -33
- package/src/lib/orchestrator/repository-link.ts +473 -0
- package/src/lib/orchestrator/root-context.ts +6 -2
- package/src/lib/orchestrator/session-prepare.ts +425 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +3 -1
- package/src/lib/orchestrator/working-repo-projection.ts +71 -159
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +131 -0
- package/src/lib/runtime-directory-refresh.ts +186 -0
- package/src/lib/update-checker.ts +6 -0
- package/src/opencode/companion-hooks.ts +24 -132
- package/src/opencode/companion-policy.ts +26 -10
- package/src/opencode/index.ts +1 -7
- package/src/opencode/session-notes.ts +76 -0
- package/src/opencode/session-prepare.ts +139 -0
- package/src/opencode/tui.tsx +56 -9
- package/src/runtime/claude-delivery-plan.ts +73 -0
- package/src/runtime/claude-hook-group.ts +21 -0
- package/src/runtime/companion-lock.ts +121 -0
- package/src/runtime/companion-sync.ts +66 -9
- package/src/runtime/runtime-directory.ts +43 -0
- package/src/runtime/runtime-document-manifest.ts +335 -0
- package/src/runtime/session-prepare-runner.ts +148 -0
- package/src/runtime/wrap-state.ts +90 -0
- package/src/runtime/write-atomic.ts +27 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
- package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
- package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
- package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
- package/src/tools/setup/capabilities/react-doctor.ts +34 -50
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
- package/src/tools/setup/engine.ts +15 -4
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +11 -18
- package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
- package/src/tools/setup/providers/claude.ts +23 -6
- package/src/tools/setup/runtime-documents.ts +152 -32
- package/src/tools/setup/working-repo-local-state.ts +82 -13
- package/src/tools/setup.ts +2 -2
- package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
- package/src/lib/orchestrator/companion-resolver.ts +0 -179
- package/src/lib/orchestrator/companion-store.ts +0 -94
- package/src/lib/orchestrator/repo-local-registry.ts +0 -143
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
- package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
- package/src/templates/report-assets/README.md +0 -32
- package/src/templates/report-assets/mermaid.LICENSE +0 -21
- package/src/templates/report-assets/mermaid.min.js +0 -4376
|
@@ -1,28 +1,13 @@
|
|
|
1
1
|
// oxlint-disable no-underscore-dangle
|
|
2
|
-
import { syncCompanionFiles } from "../../tools/setup";
|
|
3
|
-
import { getActiveDistribution } from "../../distribution";
|
|
4
2
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
|
-
import type {
|
|
6
|
-
import type { LaunchAdapter, AdapterContext } from "./adapters/base";
|
|
3
|
+
import type { AdapterContext, LaunchAdapter } from "./adapters/base";
|
|
7
4
|
import { ClaudeAdapter } from "./adapters/claude";
|
|
8
5
|
import { OpenCodeAdapter } from "./adapters/opencode";
|
|
9
|
-
import { CompanionStore } from "./
|
|
10
|
-
import { syncCompanionGit } from "./companion-git-sync";
|
|
6
|
+
import { CompanionStore } from "./repository-link";
|
|
11
7
|
import { resolveForLaunch, type LaunchContext } from "./framework-context";
|
|
12
|
-
import {
|
|
13
|
-
firstFailure,
|
|
14
|
-
type ProjectionEntryOutcome,
|
|
15
|
-
type ProjectionInput,
|
|
16
|
-
type ProjectionResult,
|
|
17
|
-
} from "./projection-types";
|
|
18
|
-
import {
|
|
19
|
-
isWorkingRepositoryWrapped,
|
|
20
|
-
project,
|
|
21
|
-
projectWorkingRepositoryBestEffort,
|
|
22
|
-
} from "./working-repo-projection";
|
|
8
|
+
import { prepareSession, type PrepareResult, type PrepareRuntime } from "./session-prepare";
|
|
23
9
|
import {
|
|
24
10
|
RepositoryNotSelectedError,
|
|
25
|
-
LaunchPreflightError,
|
|
26
11
|
ToolNotAllowedError,
|
|
27
12
|
type FrameworkConfig,
|
|
28
13
|
type LaunchRequest,
|
|
@@ -53,60 +38,27 @@ interface ResolvedLaunchState {
|
|
|
53
38
|
}
|
|
54
39
|
|
|
55
40
|
export const launcherDeps = {
|
|
56
|
-
|
|
57
|
-
/** The Managed Projection's launch scope; the working repo is written only here. */
|
|
58
|
-
projectWorkingRepo: (input: ProjectionInput) => project("launch", input),
|
|
59
|
-
/**
|
|
60
|
-
* The Projection Root a launch guarantees for itself. `resolveForLaunch` also
|
|
61
|
-
* writes it, but only on the branch that resolved a companion from the
|
|
62
|
-
* Working Repository; asking here makes the guarantee independent of which
|
|
63
|
-
* branch answered. Best-effort — a launch carries its own configuration.
|
|
64
|
-
*/
|
|
65
|
-
refreshProjectionRoot: projectWorkingRepositoryBestEffort,
|
|
66
|
-
syncCompanionGit,
|
|
67
|
-
isWrapped: isWorkingRepositoryWrapped,
|
|
41
|
+
prepareSession,
|
|
68
42
|
};
|
|
69
43
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
* invocation. Companion resolution has already refreshed the Projection Root by
|
|
83
|
-
* this point — `resolveForLaunch` writes it while resolving — but that write is
|
|
84
|
-
* idempotent and byte-identical to the one `mate wrap` performs, so it cannot
|
|
85
|
-
* change what the repository holds.
|
|
86
|
-
*/
|
|
87
|
-
function wrappedRepositoryRefusal(repository: LinkedRepository): string {
|
|
88
|
-
return [
|
|
89
|
-
`${FRAMEWORK_NAME}: ${repository.id} is wrapped — \`${FRAMEWORK_NAME} claude\` and \`${FRAMEWORK_NAME} opencode\` do not run in a wrapped working repository.`,
|
|
90
|
-
"Start the session yourself: `claude` or `opencode` — the wrap delivers the companion guidance.",
|
|
91
|
-
`Or restore managed launches: \`${FRAMEWORK_NAME} unwrap\`.`,
|
|
92
|
-
].join("\n");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The shape `projectWorkingRepositoryBestEffort` reports a failed projection
|
|
97
|
-
* in, narrowed to the entry that failed: the launch scope writes several, so
|
|
98
|
-
* "the projection" alone would not tell the operator what to fix.
|
|
99
|
-
*/
|
|
100
|
-
function projectionFailure(outcome: ProjectionEntryOutcome, repository: LinkedRepository): string {
|
|
101
|
-
const error = outcome.error ?? new Error("projection failed");
|
|
102
|
-
return `failed to write ${outcome.path} for ${repository.id}: ${error.message}`;
|
|
44
|
+
/** The wrap notice is informational: `process.stderr.write`, since Bun renders `console.error` red. */
|
|
45
|
+
function report(result: PrepareResult): void {
|
|
46
|
+
const wrapped = result.steps.find((step) => step.step === "working-target");
|
|
47
|
+
if (wrapped?.outcome === "ok" && wrapped.detail !== "current") {
|
|
48
|
+
const runtime = wrapped.runtimes?.includes("claude") ? "Claude" : "OpenCode";
|
|
49
|
+
process.stderr.write(
|
|
50
|
+
`${FRAMEWORK_NAME}: wrapped ${result.repositoryId} for ${runtime}: ${wrapped.detail}\n`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
for (const warning of result.warnings) {
|
|
54
|
+
console.error(`${FRAMEWORK_NAME}: warning: ${warning}`);
|
|
55
|
+
}
|
|
103
56
|
}
|
|
104
57
|
|
|
105
58
|
export class FrameworkLauncher {
|
|
106
|
-
private readonly adapters = new Map<string, LaunchAdapter>(
|
|
107
|
-
[
|
|
108
|
-
|
|
109
|
-
]);
|
|
59
|
+
private readonly adapters = new Map<string, LaunchAdapter>(
|
|
60
|
+
[new ClaudeAdapter(), new OpenCodeAdapter()].map((adapter) => [adapter.toolName, adapter]),
|
|
61
|
+
);
|
|
110
62
|
|
|
111
63
|
async resolveLaunchPreview(request: LaunchRequest): Promise<LaunchPreview> {
|
|
112
64
|
const state = await this.resolveLaunchState(request);
|
|
@@ -119,42 +71,25 @@ export class FrameworkLauncher {
|
|
|
119
71
|
};
|
|
120
72
|
}
|
|
121
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Session Prepare in its attended mode, then the adapter's share: it words a
|
|
76
|
+
* failed prepare, plans what the launch delivers, and runs its own checks.
|
|
77
|
+
* Each launch's plan travels in its own context, never on the adapter.
|
|
78
|
+
*/
|
|
122
79
|
async prepare(request: LaunchRequest): Promise<PreparedLaunch> {
|
|
123
80
|
const state = await this.resolveLaunchState(request);
|
|
124
|
-
const adapterContext = this.makeAdapterContext(state);
|
|
125
81
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (state.repository) {
|
|
138
|
-
await launcherDeps.syncCompanionFiles(
|
|
139
|
-
state.companionPath,
|
|
140
|
-
state.config,
|
|
141
|
-
state.repository.path,
|
|
142
|
-
);
|
|
143
|
-
} else {
|
|
144
|
-
await launcherDeps.syncCompanionFiles(state.companionPath, state.config);
|
|
145
|
-
}
|
|
146
|
-
if (state.repository) {
|
|
147
|
-
this.answerForProjection(
|
|
148
|
-
await launcherDeps.projectWorkingRepo({
|
|
149
|
-
repoPath: state.repository.path,
|
|
150
|
-
companionPath: state.companionPath,
|
|
151
|
-
config: state.config,
|
|
152
|
-
}),
|
|
153
|
-
state.repository,
|
|
154
|
-
);
|
|
155
|
-
await launcherDeps.refreshProjectionRoot(state.companionPath, state.repository);
|
|
156
|
-
}
|
|
157
|
-
await this.runCapabilityPreflight(state, request.tool);
|
|
82
|
+
const prepared = await launcherDeps.prepareSession({
|
|
83
|
+
runtime: request.tool as PrepareRuntime,
|
|
84
|
+
companionPath: state.companionPath,
|
|
85
|
+
repository: state.repository,
|
|
86
|
+
mode: "attended",
|
|
87
|
+
config: state.config,
|
|
88
|
+
skipGit: state.skipGit,
|
|
89
|
+
});
|
|
90
|
+
report(prepared);
|
|
91
|
+
if (!prepared.ok) throw state.adapter.launchFailure(prepared);
|
|
92
|
+
const adapterContext = await state.adapter.planLaunch(this.makeAdapterContext(state), prepared);
|
|
158
93
|
await state.adapter.validateLaunch(adapterContext);
|
|
159
94
|
|
|
160
95
|
return {
|
|
@@ -174,7 +109,7 @@ export class FrameworkLauncher {
|
|
|
174
109
|
repositoryId,
|
|
175
110
|
repository: localRepository,
|
|
176
111
|
} = await this.resolveConfig(request.scope);
|
|
177
|
-
const store = new CompanionStore(
|
|
112
|
+
const store = new CompanionStore(companionPath, workingRepoStore);
|
|
178
113
|
const scope = request.scope ?? "working-repo";
|
|
179
114
|
const repository =
|
|
180
115
|
scope === "companion"
|
|
@@ -205,28 +140,6 @@ export class FrameworkLauncher {
|
|
|
205
140
|
};
|
|
206
141
|
}
|
|
207
142
|
|
|
208
|
-
/**
|
|
209
|
-
* `project` reports its failures rather than throwing, so the launch scope's
|
|
210
|
-
* caller is the one that has to answer for them. A non-degradable entry is a
|
|
211
|
-
* guardrail the session was promised — writing the companion into the
|
|
212
|
-
* runtime's allow-list, for one — so the launch fails instead of starting
|
|
213
|
-
* without it. A degradable entry warns in the voice
|
|
214
|
-
* {@link projectWorkingRepositoryBestEffort} uses, and the launch proceeds.
|
|
215
|
-
*/
|
|
216
|
-
private answerForProjection(result: ProjectionResult, repository: LinkedRepository): void {
|
|
217
|
-
for (const outcome of result.outcomes) {
|
|
218
|
-
if (outcome.state !== "failed" || !outcome.degradable) continue;
|
|
219
|
-
console.error(`${FRAMEWORK_NAME}: warning: ${projectionFailure(outcome, repository)}`);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const failure = firstFailure(result.outcomes);
|
|
223
|
-
if (failure) {
|
|
224
|
-
throw new LaunchPreflightError(
|
|
225
|
-
`${FRAMEWORK_NAME}: ${projectionFailure(failure, repository)}`,
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
143
|
private makeAdapterContext(state: ResolvedLaunchState): AdapterContext {
|
|
231
144
|
return {
|
|
232
145
|
repository: state.repository,
|
|
@@ -239,54 +152,6 @@ export class FrameworkLauncher {
|
|
|
239
152
|
};
|
|
240
153
|
}
|
|
241
154
|
|
|
242
|
-
private async runCapabilityPreflight(
|
|
243
|
-
state: ResolvedLaunchState,
|
|
244
|
-
providerId: string,
|
|
245
|
-
): Promise<void> {
|
|
246
|
-
const context: LaunchPreflightContext = {
|
|
247
|
-
companionPath: state.companionPath,
|
|
248
|
-
config: state.config,
|
|
249
|
-
repository: state.repository,
|
|
250
|
-
providerId,
|
|
251
|
-
};
|
|
252
|
-
const preflights = getActiveDistribution()
|
|
253
|
-
.registry.getAll()
|
|
254
|
-
.reduce<
|
|
255
|
-
Array<{
|
|
256
|
-
capability: CapabilityPlugin;
|
|
257
|
-
preflight: NonNullable<NonNullable<CapabilityPlugin["forProvider"]>[string]["preflight"]>;
|
|
258
|
-
}>
|
|
259
|
-
>((entries, plugin) => {
|
|
260
|
-
if (plugin.kind !== "capability" || !plugin.isEnabled(state.config)) return entries;
|
|
261
|
-
const capability = plugin as CapabilityPlugin;
|
|
262
|
-
const preflight = capability.forProvider?.[providerId]?.preflight;
|
|
263
|
-
if (preflight) entries.push({ capability, preflight });
|
|
264
|
-
return entries;
|
|
265
|
-
}, []);
|
|
266
|
-
const results = await Promise.all(
|
|
267
|
-
preflights.map(async ({ capability, preflight }) => {
|
|
268
|
-
try {
|
|
269
|
-
return await preflight(context);
|
|
270
|
-
} catch (error) {
|
|
271
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
272
|
-
throw new LaunchPreflightError(
|
|
273
|
-
`Capability preflight failed for ${capability.id} on ${providerId}: ${detail}`,
|
|
274
|
-
{ cause: error },
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
}),
|
|
278
|
-
);
|
|
279
|
-
const diagnostics = results.flat();
|
|
280
|
-
|
|
281
|
-
if (diagnostics.length > 0) {
|
|
282
|
-
throw new LaunchPreflightError(
|
|
283
|
-
["Capability launch preflight failed.", ...diagnostics.map((item) => `- ${item}`)].join(
|
|
284
|
-
"\n",
|
|
285
|
-
),
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
155
|
private async resolveConfig(scope: LaunchScope = "working-repo"): Promise<LaunchContext> {
|
|
291
156
|
return resolveForLaunch(process.cwd(), undefined, scope);
|
|
292
157
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ClaudeSettings,
|
|
12
12
|
} from "../../tools/setup/providers/claude-format";
|
|
13
13
|
import { isMateGuardHookGroup } from "../../tools/setup/providers/claude-plugin-hooks";
|
|
14
|
+
import { readWrapState, type WrapState } from "../../runtime/wrap-state";
|
|
14
15
|
import { pruneEmptyAncestors } from "../../tools/setup/utils";
|
|
15
16
|
import { GlobalConfigStore } from "./global-config-store";
|
|
16
17
|
|
|
@@ -52,14 +53,21 @@ function parseSettings(raw: string | null): ClaudeSettings {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
* Strips the managed hook groups earlier releases wrote here, but leaves
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* Strips the managed hook groups earlier releases wrote here, but leaves every
|
|
57
|
+
* group the `wrap` scope places: those are owned by the wrap's own entry —
|
|
58
|
+
* Capability groups match a managed marker too — and stripping them would
|
|
59
|
+
* disarm every Unmanaged Session on the next launch.
|
|
58
60
|
*/
|
|
59
|
-
function withoutForeignManagedHookGroups(
|
|
61
|
+
function withoutForeignManagedHookGroups(
|
|
62
|
+
settings: ClaudeSettings,
|
|
63
|
+
wrap: WrapState,
|
|
64
|
+
): ClaudeSettings {
|
|
60
65
|
const hooks = filterClaudeHookGroups(
|
|
61
66
|
settings.hooks ?? {},
|
|
62
|
-
(group) =>
|
|
67
|
+
(group) =>
|
|
68
|
+
isMateGuardHookGroup(group) ||
|
|
69
|
+
wrap.carriesClaudeHookGroup(group) ||
|
|
70
|
+
!isManagedHookGroup(group),
|
|
63
71
|
);
|
|
64
72
|
const next = { ...settings };
|
|
65
73
|
if (Object.keys(hooks).length > 0) next.hooks = hooks;
|
|
@@ -81,7 +89,10 @@ export async function syncWorkingRepoClaudeAdditionalDirectories(
|
|
|
81
89
|
): Promise<"written" | "current"> {
|
|
82
90
|
const target = settingsPath(repoPath);
|
|
83
91
|
const raw = await readRaw(target);
|
|
84
|
-
const existing = withoutForeignManagedHookGroups(
|
|
92
|
+
const existing = withoutForeignManagedHookGroups(
|
|
93
|
+
parseSettings(raw),
|
|
94
|
+
await readWrapState(repoPath),
|
|
95
|
+
);
|
|
85
96
|
const permissions = { ...existing.permissions };
|
|
86
97
|
const existingAdditionalDirectories = Array.isArray(permissions.additionalDirectories)
|
|
87
98
|
? permissions.additionalDirectories
|
|
@@ -2,10 +2,23 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { readProjectionFile } from "../../runtime/projection";
|
|
5
|
+
import { realLinkTarget, realOrResolved } from "../fs-utils";
|
|
6
|
+
import {
|
|
7
|
+
links,
|
|
8
|
+
readManifest,
|
|
9
|
+
updateManifest,
|
|
10
|
+
withLinks,
|
|
11
|
+
type LinkRecord,
|
|
12
|
+
} from "../../runtime/runtime-document-manifest";
|
|
5
13
|
import { pruneEmptyAncestors } from "../../tools/setup/utils";
|
|
6
14
|
|
|
7
15
|
const CLAUDE_SKILLS_PATH = path.join(".claude", "skills");
|
|
8
16
|
|
|
17
|
+
/** The manifest key of a skill link: repo-relative, POSIX separators. */
|
|
18
|
+
function recordKey(name: string): string {
|
|
19
|
+
return `.claude/skills/${name}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
async function skillNames(root: string): Promise<string[]> {
|
|
10
23
|
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
11
24
|
const names: string[] = [];
|
|
@@ -25,13 +38,7 @@ async function skillNames(root: string): Promise<string[]> {
|
|
|
25
38
|
|
|
26
39
|
async function linkTarget(link: string): Promise<string | null> {
|
|
27
40
|
const target = await fs.readlink(link).catch(() => null);
|
|
28
|
-
return target === null ? null :
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function isManagedSkillLink(link: string, roots: string[]): Promise<boolean> {
|
|
32
|
-
const target = await linkTarget(link);
|
|
33
|
-
if (target === null) return false;
|
|
34
|
-
return roots.some((root) => path.dirname(target) === root);
|
|
41
|
+
return target === null ? null : realLinkTarget(link, target);
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
async function registeredSkillRoots(
|
|
@@ -45,17 +52,23 @@ async function registeredSkillRoots(
|
|
|
45
52
|
companionPath ?? "",
|
|
46
53
|
projection?.projection.companionPath ?? "",
|
|
47
54
|
];
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
const roots = await Promise.all(
|
|
56
|
+
candidates
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((candidate) => realOrResolved(path.resolve(candidate, CLAUDE_SKILLS_PATH))),
|
|
59
|
+
);
|
|
60
|
+
return [...new Set(roots)];
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
async function createSkillLink(target: string, link: string): Promise<void> {
|
|
56
64
|
await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Recorded link: Mate's while it resolves to its recorded target. Unrecorded:
|
|
69
|
+
* adopted only when it resolves into a registered, current or projected
|
|
70
|
+
* companion. Records follow the filesystem, so a partial failure re-adopts.
|
|
71
|
+
*/
|
|
59
72
|
export async function reconcileWorkingRepoClaudeSkillLinks(
|
|
60
73
|
repoPath: string,
|
|
61
74
|
companionPath: string | null,
|
|
@@ -65,23 +78,27 @@ export async function reconcileWorkingRepoClaudeSkillLinks(
|
|
|
65
78
|
const currentRoot = companionPath ? path.resolve(companionPath, CLAUDE_SKILLS_PATH) : null;
|
|
66
79
|
const roots = await registeredSkillRoots(repoPath, companionPath, registeredCompanionPaths);
|
|
67
80
|
const targetRoot = path.join(path.resolve(repoPath), CLAUDE_SKILLS_PATH);
|
|
81
|
+
const recorded = links(await readManifest(repoPath), "claude");
|
|
68
82
|
const existing = await fs.readdir(targetRoot, { withFileTypes: true }).catch(() => []);
|
|
69
83
|
const sourceNames: Set<string> =
|
|
70
84
|
currentRoot && enabled ? new Set(await skillNames(currentRoot)) : new Set<string>();
|
|
85
|
+
const records: Record<string, LinkRecord> = {};
|
|
71
86
|
let changed = false;
|
|
72
87
|
|
|
73
88
|
for (const entry of existing) {
|
|
74
89
|
if (!entry.isSymbolicLink()) continue;
|
|
75
90
|
const link = path.join(targetRoot, entry.name);
|
|
76
|
-
if (!(await isManagedSkillLink(link, roots))) continue;
|
|
77
|
-
|
|
78
91
|
const target = await linkTarget(link);
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
92
|
+
if (target === null) continue;
|
|
93
|
+
const record = recorded[recordKey(entry.name)];
|
|
94
|
+
const owned = record
|
|
95
|
+
? (await realOrResolved(record.target)) === target
|
|
96
|
+
: roots.some((root) => path.dirname(target) === root);
|
|
97
|
+
if (!owned) continue;
|
|
98
|
+
|
|
99
|
+
const wanted = currentRoot ? path.join(currentRoot, entry.name) : null;
|
|
100
|
+
if (wanted && sourceNames.has(entry.name) && (await realOrResolved(wanted)) === target) {
|
|
101
|
+
records[recordKey(entry.name)] = { runtime: "claude", target: wanted };
|
|
85
102
|
continue;
|
|
86
103
|
}
|
|
87
104
|
|
|
@@ -89,13 +106,15 @@ export async function reconcileWorkingRepoClaudeSkillLinks(
|
|
|
89
106
|
changed = true;
|
|
90
107
|
}
|
|
91
108
|
|
|
92
|
-
if (currentRoot &&
|
|
109
|
+
if (currentRoot && sourceNames.size > 0) {
|
|
93
110
|
await fs.mkdir(targetRoot, { recursive: true });
|
|
94
111
|
for (const name of sourceNames) {
|
|
95
112
|
const link = path.join(targetRoot, name);
|
|
96
113
|
const existingLink = await fs.lstat(link).catch(() => null);
|
|
97
114
|
if (existingLink) continue;
|
|
98
|
-
|
|
115
|
+
const target = path.join(currentRoot, name);
|
|
116
|
+
await createSkillLink(target, link);
|
|
117
|
+
records[recordKey(name)] = { runtime: "claude", target };
|
|
99
118
|
changed = true;
|
|
100
119
|
}
|
|
101
120
|
}
|
|
@@ -103,9 +122,22 @@ export async function reconcileWorkingRepoClaudeSkillLinks(
|
|
|
103
122
|
if (changed || !enabled) {
|
|
104
123
|
await pruneEmptyAncestors(targetRoot, path.resolve(repoPath));
|
|
105
124
|
}
|
|
125
|
+
if (JSON.stringify(sortedEntries(records)) !== JSON.stringify(sortedEntries(recorded))) {
|
|
126
|
+
await updateManifest(repoPath, (manifest) => withLinks(manifest, "claude", records));
|
|
127
|
+
changed = true;
|
|
128
|
+
}
|
|
106
129
|
return changed ? "written" : "current";
|
|
107
130
|
}
|
|
108
131
|
|
|
132
|
+
function sortedEntries(records: Record<string, LinkRecord>): [string, LinkRecord][] {
|
|
133
|
+
return Object.entries(records)
|
|
134
|
+
.map(([key, record]): [string, LinkRecord] => [
|
|
135
|
+
key,
|
|
136
|
+
{ runtime: record.runtime, target: record.target },
|
|
137
|
+
])
|
|
138
|
+
.toSorted(([a], [b]) => a.localeCompare(b));
|
|
139
|
+
}
|
|
140
|
+
|
|
109
141
|
export async function removeWorkingRepoClaudeSkillLinks(
|
|
110
142
|
repoPath: string,
|
|
111
143
|
registeredCompanionPaths: string[] = [],
|
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
repoLocalDirPath,
|
|
14
14
|
repoLocalRegistryPath,
|
|
15
15
|
} from "../../runtime/repo-local";
|
|
16
|
+
import {
|
|
17
|
+
documentRuntime,
|
|
18
|
+
documents as recordedDocuments,
|
|
19
|
+
readManifest,
|
|
20
|
+
} from "../../runtime/runtime-document-manifest";
|
|
16
21
|
import { TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES } from "../../tools/setup/capabilities/tokensave-shared";
|
|
17
22
|
import {
|
|
18
23
|
ensureWorkingRepoLocalExcludes,
|
|
@@ -26,6 +31,12 @@ import {
|
|
|
26
31
|
reconcileWorkingRepoClaudeSkillLinks,
|
|
27
32
|
removeWorkingRepoClaudeSkillLinks,
|
|
28
33
|
} from "./projection-claude-skills";
|
|
34
|
+
import {
|
|
35
|
+
companionCopiesPresent,
|
|
36
|
+
reconcileCompanionCopies,
|
|
37
|
+
removeCompanionCopies,
|
|
38
|
+
withdrawCompanionCopies,
|
|
39
|
+
} from "./projection-opencode-copies";
|
|
29
40
|
import { buildProjection } from "./projection-record";
|
|
30
41
|
import {
|
|
31
42
|
CLAUDE_WORKING_SETTINGS_PATH,
|
|
@@ -41,16 +52,22 @@ import {
|
|
|
41
52
|
CLAUDE_MCP_DOCUMENT,
|
|
42
53
|
CLAUDE_SETTINGS_DOCUMENT,
|
|
43
54
|
OPENCODE_CONFIG_DOCUMENT,
|
|
55
|
+
isExternalDocument,
|
|
44
56
|
placeRuntimeDocument,
|
|
45
57
|
removeRuntimeDocument,
|
|
46
|
-
|
|
58
|
+
trackedDocumentWarnings,
|
|
47
59
|
} from "./projection-runtime-documents";
|
|
48
60
|
import {
|
|
49
61
|
upsertRepoLocalCompanionPointer,
|
|
50
62
|
upsertRepoLocalLinkedRepository,
|
|
51
63
|
writeRepoLocalFrameworkConfig,
|
|
52
64
|
} from "./repo-local-store";
|
|
53
|
-
import type {
|
|
65
|
+
import type {
|
|
66
|
+
ProjectionEntry,
|
|
67
|
+
ProjectionInput,
|
|
68
|
+
ProjectionWriteReport,
|
|
69
|
+
ProjectionWriteState,
|
|
70
|
+
} from "./projection-types";
|
|
54
71
|
|
|
55
72
|
const WORKSPACE_DOCUMENT_FILE = "workspace.code-workspace";
|
|
56
73
|
|
|
@@ -68,6 +85,11 @@ async function pathExists(candidatePath: string): Promise<boolean> {
|
|
|
68
85
|
}
|
|
69
86
|
}
|
|
70
87
|
|
|
88
|
+
/** Present when Mate recorded regions for it, not merely when the file exists. */
|
|
89
|
+
async function runtimeDocumentRecorded(repoPath: string, documentPath: string): Promise<boolean> {
|
|
90
|
+
return recordedDocuments(await readManifest(repoPath)).includes(documentPath);
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
async function writeProjectionPairEntry(input: ProjectionInput): Promise<"written" | "current"> {
|
|
72
94
|
const { companionPath, repository } = input;
|
|
73
95
|
const resolvedCompanionPath = path.resolve(companionPath!);
|
|
@@ -96,6 +118,26 @@ async function writeProjectionPairEntry(input: ProjectionInput): Promise<"writte
|
|
|
96
118
|
return "written";
|
|
97
119
|
}
|
|
98
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Places one rendered document and, when this pass wrote Mate's regions into a
|
|
123
|
+
* file inside the repository, asks Git whether it tracks that file. Only a
|
|
124
|
+
* write is worth the question: an unchanged document was reported when it was
|
|
125
|
+
* written, and a removal puts no companion path into it.
|
|
126
|
+
*/
|
|
127
|
+
async function writeRuntimeDocumentEntry(
|
|
128
|
+
input: ProjectionInput,
|
|
129
|
+
documentPath: string,
|
|
130
|
+
): Promise<ProjectionWriteState | ProjectionWriteReport> {
|
|
131
|
+
const state = await placeRuntimeDocument(input.repoPath, documentPath, input.runtimeDocuments);
|
|
132
|
+
const rendered = input.runtimeDocuments?.some((document) => document.path === documentPath);
|
|
133
|
+
if (state !== "written" || !rendered || isExternalDocument(documentPath)) return state;
|
|
134
|
+
const repositoryId = input.repository?.id ?? path.basename(path.resolve(input.repoPath));
|
|
135
|
+
return {
|
|
136
|
+
state,
|
|
137
|
+
warnings: await trackedDocumentWarnings(input.repoPath, documentPath, repositoryId),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
99
141
|
function tokensaveEnabled(input: ProjectionInput): boolean {
|
|
100
142
|
return (input.config?.capabilities ?? []).some((capability) => capability.name === "tokensave");
|
|
101
143
|
}
|
|
@@ -198,7 +240,7 @@ function buildEntries(): readonly ProjectionEntry[] {
|
|
|
198
240
|
id: "projection-pair",
|
|
199
241
|
kind: "owned",
|
|
200
242
|
path: path.join(localDir, PROJECTION_YAML_FILE),
|
|
201
|
-
scopes: ["session"],
|
|
243
|
+
scopes: ["session", "wrap"],
|
|
202
244
|
write: async (input) =>
|
|
203
245
|
input.companionPath && input.repository ? writeProjectionPairEntry(input) : "skipped",
|
|
204
246
|
removal: { by: "entry", entry: "projection-root" },
|
|
@@ -208,6 +250,7 @@ function buildEntries(): readonly ProjectionEntry[] {
|
|
|
208
250
|
kind: "merged",
|
|
209
251
|
path: path.join(".claude", "skills"),
|
|
210
252
|
scopes: ["wrap"],
|
|
253
|
+
runtime: "claude",
|
|
211
254
|
removeOnUnwrap: true,
|
|
212
255
|
write: async (input) => {
|
|
213
256
|
if (!input.config) return "skipped";
|
|
@@ -236,7 +279,7 @@ function buildEntries(): readonly ProjectionEntry[] {
|
|
|
236
279
|
id: "capability-excludes",
|
|
237
280
|
kind: "merged",
|
|
238
281
|
path: path.join(".git", "info", "exclude"),
|
|
239
|
-
scopes: ["launch"],
|
|
282
|
+
scopes: ["launch", "wrap"],
|
|
240
283
|
write: async (input) => {
|
|
241
284
|
if (!input.config) return "skipped";
|
|
242
285
|
return (await reconcileWorkingRepoCapabilityExcludes(
|
|
@@ -259,6 +302,7 @@ function buildEntries(): readonly ProjectionEntry[] {
|
|
|
259
302
|
kind: "merged",
|
|
260
303
|
path: CLAUDE_WORKING_SETTINGS_PATH,
|
|
261
304
|
scopes: ["launch", "wrap"],
|
|
305
|
+
runtime: "claude",
|
|
262
306
|
write: async (input) =>
|
|
263
307
|
input.companionPath
|
|
264
308
|
? syncWorkingRepoClaudeAdditionalDirectories(
|
|
@@ -276,14 +320,14 @@ function buildEntries(): readonly ProjectionEntry[] {
|
|
|
276
320
|
},
|
|
277
321
|
/**
|
|
278
322
|
* Removal-only: Mate never writes this block, an external installer does.
|
|
279
|
-
* Its `write` is the same strip, so a launch heals a repository
|
|
280
|
-
* installer touched without waiting for a cleanup.
|
|
323
|
+
* Its `write` is the same strip, so a launch or a wrap heals a repository
|
|
324
|
+
* the installer touched without waiting for a cleanup.
|
|
281
325
|
*/
|
|
282
326
|
{
|
|
283
327
|
id: "legacy-tokensave-claude-md",
|
|
284
328
|
kind: "merged",
|
|
285
329
|
path: LEGACY_TOKENSAVE_CLAUDE_MD_PATH,
|
|
286
|
-
scopes: ["launch"],
|
|
330
|
+
scopes: ["launch", "wrap"],
|
|
287
331
|
write: async (input) =>
|
|
288
332
|
(await stripTokensaveClaudeMdAppend(input.repoPath)) === "removed" ? "written" : "current",
|
|
289
333
|
removal: { by: "self", remove: (input) => stripTokensaveClaudeMdAppend(input.repoPath) },
|
|
@@ -336,6 +380,7 @@ function buildEntries(): readonly ProjectionEntry[] {
|
|
|
336
380
|
kind: "merged",
|
|
337
381
|
path: CLAUDE_MCP_DOCUMENT,
|
|
338
382
|
scopes: ["launch", "wrap"],
|
|
383
|
+
runtime: "claude",
|
|
339
384
|
write: async (input) =>
|
|
340
385
|
(await removeRuntimeDocument(input.repoPath, CLAUDE_MCP_DOCUMENT)) === "removed"
|
|
341
386
|
? "written"
|
|
@@ -344,7 +389,7 @@ function buildEntries(): readonly ProjectionEntry[] {
|
|
|
344
389
|
by: "self",
|
|
345
390
|
remove: (input) => removeRuntimeDocument(input.repoPath, CLAUDE_MCP_DOCUMENT),
|
|
346
391
|
},
|
|
347
|
-
present: (repoPath) =>
|
|
392
|
+
present: (repoPath) => runtimeDocumentRecorded(repoPath, CLAUDE_MCP_DOCUMENT),
|
|
348
393
|
},
|
|
349
394
|
...(
|
|
350
395
|
[
|
|
@@ -357,15 +402,37 @@ function buildEntries(): readonly ProjectionEntry[] {
|
|
|
357
402
|
kind: "merged" as const,
|
|
358
403
|
path: documentPath.split("/").join(path.sep),
|
|
359
404
|
scopes: ["launch", "wrap"] as const,
|
|
360
|
-
|
|
361
|
-
|
|
405
|
+
runtime: documentRuntime(documentPath),
|
|
406
|
+
write: (input: ProjectionInput) => writeRuntimeDocumentEntry(input, documentPath),
|
|
362
407
|
removal: {
|
|
363
408
|
by: "self" as const,
|
|
364
409
|
remove: (input: { repoPath: string }) =>
|
|
365
410
|
removeRuntimeDocument(input.repoPath, documentPath),
|
|
366
411
|
},
|
|
367
|
-
present: (repoPath: string) =>
|
|
412
|
+
present: (repoPath: string) => runtimeDocumentRecorded(repoPath, documentPath),
|
|
368
413
|
})),
|
|
414
|
+
/**
|
|
415
|
+
* After the OpenCode document, so its copies arrive once the document that
|
|
416
|
+
* loads them is in place. A companion that dropped OpenCode has the copies
|
|
417
|
+
* an earlier wrap made withdrawn; the outcome names each copy written or
|
|
418
|
+
* withdrawn and warns about shadowed and locally edited ones.
|
|
419
|
+
*/
|
|
420
|
+
{
|
|
421
|
+
id: "opencode-companion-copies",
|
|
422
|
+
kind: "merged",
|
|
423
|
+
path: ".opencode",
|
|
424
|
+
scopes: ["wrap"],
|
|
425
|
+
runtime: "opencode",
|
|
426
|
+
removeOnUnwrap: true,
|
|
427
|
+
write: async (input) => {
|
|
428
|
+
if (!input.config || !input.companionPath) return "skipped";
|
|
429
|
+
return (input.config.allowedAgents?.includes("opencode") ?? false)
|
|
430
|
+
? reconcileCompanionCopies(input.repoPath, input.companionPath)
|
|
431
|
+
: withdrawCompanionCopies(input.repoPath);
|
|
432
|
+
},
|
|
433
|
+
removal: { by: "self", remove: (input) => removeCompanionCopies(input.repoPath) },
|
|
434
|
+
present: companionCopiesPresent,
|
|
435
|
+
},
|
|
369
436
|
];
|
|
370
437
|
}
|
|
371
438
|
|