@uniqbit/mate-core 0.18.3-canary.1 → 0.18.4
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/add-dir.ts +10 -76
- package/opencode-plugin/src/audience-layer.ts +91 -0
- package/opencode-plugin/src/companion.ts +70 -18
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +60 -8
- 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/cap/index-cmd.ts +2 -11
- 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 +87 -5
- package/src/cli/commands/install.ts +8 -0
- 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/studio/terminal-launch.ts +16 -7
- package/src/cli/commands/unwrap.ts +28 -3
- package/src/cli/commands/wrap.ts +79 -60
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/main.ts +17 -0
- package/src/cli/usage.ts +5 -2
- package/src/hooks/session-banner.ts +19 -10
- package/src/hooks/session-guidance.ts +20 -2
- package/src/hooks/session-prepare.ts +101 -0
- package/src/lib/fs-utils.ts +13 -0
- package/src/lib/orchestrator/adapters/base.ts +45 -9
- package/src/lib/orchestrator/adapters/claude.ts +241 -30
- package/src/lib/orchestrator/adapters/opencode.ts +72 -34
- 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 +83 -1
- package/src/lib/orchestrator/framework-context.ts +5 -29
- package/src/lib/orchestrator/launcher.ts +36 -171
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
- package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
- package/src/lib/orchestrator/projection-entries.ts +124 -11
- package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -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 +47 -34
- 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 +503 -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 +73 -160
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +222 -0
- package/src/lib/runtime-directory-refresh.ts +186 -0
- package/src/lib/update-checker.ts +6 -0
- package/src/opencode/companion-hooks.ts +30 -135
- package/src/opencode/companion-policy.ts +26 -10
- package/src/opencode/index.ts +1 -7
- package/src/opencode/projected-guidance.ts +6 -13
- 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/audience-layer.ts +84 -0
- 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/guidance.ts +17 -0
- package/src/runtime/opencode-audience-layer.ts +176 -0
- package/src/runtime/policy.ts +14 -1
- package/src/runtime/projection.ts +1 -1
- package/src/runtime/runtime-directory.ts +43 -0
- package/src/runtime/runtime-document-manifest.ts +352 -0
- package/src/runtime/session-prepare-runner.ts +148 -0
- package/src/runtime/wrap-state.ts +94 -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/audience-isolation.fixture.ts +325 -0
- package/src/tools/setup/audience-layer.ts +207 -0
- package/src/tools/setup/audience-registrations.ts +52 -0
- package/src/tools/setup/capabilities/openspec.ts +9 -0
- package/src/tools/setup/capabilities/react-doctor.ts +34 -50
- package/src/tools/setup/capabilities/tokensave.ts +11 -0
- package/src/tools/setup/contribution-ledger.ts +207 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
- package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
- package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
- package/src/tools/setup/engine.ts +152 -10
- package/src/tools/setup/isolation-diagnostics.ts +139 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/plugin.ts +19 -4
- package/src/tools/setup/plugins/gitignore.ts +7 -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 +50 -8
- package/src/tools/setup/providers/opencode.ts +10 -0
- package/src/tools/setup/registry.ts +24 -2
- package/src/tools/setup/runtime-documents.ts +152 -32
- package/src/tools/setup/surface-target.ts +13 -4
- package/src/tools/setup/upgrade-sweep.ts +153 -0
- package/src/tools/setup/working-repo-cleanup.ts +2 -0
- package/src/tools/setup/working-repo-local-state.ts +91 -14
- package/src/tools/setup.ts +11 -8
- package/wrappers/bin/openspec +42 -1
- 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,5 +1,14 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
+
import semver from "semver";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
BASE_AUDIENCE,
|
|
7
|
+
BASE_AUDIENCE_KEYS,
|
|
8
|
+
normalizeBaseAudience,
|
|
9
|
+
} from "../../tools/setup/dynamic-plugins/audiences";
|
|
10
|
+
|
|
11
|
+
import { getActiveDistribution } from "../../distribution";
|
|
3
12
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
4
13
|
import {
|
|
5
14
|
getDefaultSetupSelections,
|
|
@@ -291,22 +300,95 @@ function validateAudiences(config: FrameworkConfig): void {
|
|
|
291
300
|
}
|
|
292
301
|
}
|
|
293
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The release that introduces the `audiences.base` shape. An older Mate would
|
|
305
|
+
* read a saved file as an empty base plus an audience called `base`, so every
|
|
306
|
+
* save in the new shape raises the companion's `engines` floor to it — capped
|
|
307
|
+
* at the running release, so a pre-release build never locks itself out.
|
|
308
|
+
*/
|
|
309
|
+
export const BASE_AUDIENCE_MIN_VERSION = "0.19.0";
|
|
310
|
+
|
|
311
|
+
/** The floor version a save writes: the introducing release, or the running one when older. */
|
|
312
|
+
export function baseAudienceFloorVersion(
|
|
313
|
+
runningVersion: string = getActiveDistribution().config.version,
|
|
314
|
+
): string {
|
|
315
|
+
const running = semver.coerce(runningVersion)?.version;
|
|
316
|
+
return running && semver.lt(running, BASE_AUDIENCE_MIN_VERSION)
|
|
317
|
+
? running
|
|
318
|
+
: BASE_AUDIENCE_MIN_VERSION;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Only companions carry audiences; a hub or a working repository keeps the root shape. */
|
|
322
|
+
function usesAudiences(config: FrameworkConfig): boolean {
|
|
323
|
+
return config.type === undefined || config.type === "companion";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The floor that admits the base audience shape, keeping a higher one. */
|
|
327
|
+
function raisedEngineFloor(range: string | undefined): string {
|
|
328
|
+
const version = baseAudienceFloorVersion();
|
|
329
|
+
const floor = `>=${version}`;
|
|
330
|
+
if (!range || semver.validRange(range) === null) return range ?? floor;
|
|
331
|
+
const minimum = semver.minVersion(range);
|
|
332
|
+
return minimum && semver.gte(minimum, version) ? range : floor;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The file shape of a companion config: the base set's audience-scoped keys
|
|
337
|
+
* under `audiences.base`, first among the audiences. The in-memory config
|
|
338
|
+
* keeps them at the root, so its readers never see the difference.
|
|
339
|
+
*/
|
|
340
|
+
function toFileShape(config: FrameworkConfig): FrameworkConfig {
|
|
341
|
+
if (!usesAudiences(config)) return config;
|
|
342
|
+
const base: Record<string, unknown> = {};
|
|
343
|
+
const rest: Record<string, unknown> = { ...config };
|
|
344
|
+
for (const key of BASE_AUDIENCE_KEYS) {
|
|
345
|
+
if (rest[key] === undefined) continue;
|
|
346
|
+
base[key] = rest[key];
|
|
347
|
+
delete rest[key];
|
|
348
|
+
}
|
|
349
|
+
if (Object.keys(base).length === 0) return config;
|
|
350
|
+
return {
|
|
351
|
+
...(rest as unknown as FrameworkConfig),
|
|
352
|
+
audiences: { [BASE_AUDIENCE]: base, ...config.audiences } as FrameworkConfig["audiences"],
|
|
353
|
+
engines: {
|
|
354
|
+
...config.engines,
|
|
355
|
+
[FRAMEWORK_NAME]: raisedEngineFloor(config.engines?.[FRAMEWORK_NAME]),
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
294
360
|
export class ConfigStore extends YamlFileStore<FrameworkConfig> {
|
|
295
361
|
constructor(configPath = process.env.MATE_CONFIG ?? defaultConfigPath()) {
|
|
296
362
|
super(path.resolve(configPath));
|
|
297
363
|
}
|
|
298
364
|
|
|
365
|
+
/** Either file shape loads as the base set at the root; the new shape persists on the next save. */
|
|
299
366
|
override async load(): Promise<FrameworkConfig> {
|
|
300
|
-
const merged = mergeWithDefaults(
|
|
367
|
+
const merged = mergeWithDefaults(
|
|
368
|
+
migrateProfilesToAllowedAgents(normalizeBaseAudience(await super.load())),
|
|
369
|
+
);
|
|
301
370
|
validateHubConfig(merged);
|
|
302
371
|
validatePluginDeclarations(merged);
|
|
303
372
|
validateAudiences(merged);
|
|
304
373
|
return merged;
|
|
305
374
|
}
|
|
306
375
|
|
|
376
|
+
override async save(config: FrameworkConfig): Promise<void> {
|
|
377
|
+
await super.save(toFileShape(config));
|
|
378
|
+
}
|
|
379
|
+
|
|
307
380
|
protected async onMissing(): Promise<FrameworkConfig> {
|
|
308
381
|
const config = defaultConfig();
|
|
309
382
|
await this.save(config);
|
|
310
383
|
return config;
|
|
311
384
|
}
|
|
312
385
|
}
|
|
386
|
+
|
|
387
|
+
/** The companion's own selection, read from files: wrapping runs no setup pass. */
|
|
388
|
+
export async function readCompanionConfig(companionPath: string): Promise<FrameworkConfig> {
|
|
389
|
+
return mergeWithDefaults(
|
|
390
|
+
await new ConfigStore(
|
|
391
|
+
path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
|
|
392
|
+
).load(),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
@@ -4,12 +4,13 @@ import path from "node:path";
|
|
|
4
4
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
5
|
import { parse } from "yaml";
|
|
6
6
|
import { CompanionRegistryStore } from "./companion-registry-store";
|
|
7
|
-
import { CompanionResolver } from "./companion-resolver";
|
|
8
|
-
import { companionRootedStore } from "./companion-store";
|
|
9
|
-
import { readCompanionRegistry } from "./companion-registry-reader";
|
|
10
7
|
import { ConfigStore } from "./config-store";
|
|
11
8
|
import { GlobalConfigStore } from "./global-config-store";
|
|
12
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
backfillCompanionRegistration,
|
|
11
|
+
CompanionResolver,
|
|
12
|
+
findRepoLocalLinkedRepository,
|
|
13
|
+
} from "./repository-link";
|
|
13
14
|
import { projectWorkingRepositoryBestEffort } from "./working-repo-projection";
|
|
14
15
|
import {
|
|
15
16
|
AmbiguousCompanionError,
|
|
@@ -257,31 +258,6 @@ export async function resolveForLaunch(
|
|
|
257
258
|
throw new WorkingRepoRequiredError();
|
|
258
259
|
}
|
|
259
260
|
|
|
260
|
-
/**
|
|
261
|
-
* Self-heals repos linked before the companion-side registry write existed
|
|
262
|
-
* (or whose companion registry was reset): backfills the companion-side
|
|
263
|
-
* entry from the already-trusted repo-local pointer on every launch
|
|
264
|
-
* resolution. Best-effort — never blocks a launch.
|
|
265
|
-
*/
|
|
266
|
-
async function backfillCompanionRegistration(
|
|
267
|
-
companionPath: string,
|
|
268
|
-
repository: LinkedRepository,
|
|
269
|
-
): Promise<void> {
|
|
270
|
-
try {
|
|
271
|
-
const { repos } = await readCompanionRegistry(companionPath).catch(() => ({ repos: [] }));
|
|
272
|
-
const existing = repos.find((repo) => repo.id === repository.id);
|
|
273
|
-
if (existing?.path === repository.path) return;
|
|
274
|
-
|
|
275
|
-
await companionRootedStore(companionPath).registerRepository(repository);
|
|
276
|
-
} catch (error) {
|
|
277
|
-
console.error(
|
|
278
|
-
`${FRAMEWORK_NAME}: warning: failed to backfill companion registry for ${repository.id}: ${
|
|
279
|
-
error instanceof Error ? error.message : String(error)
|
|
280
|
-
}`,
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
261
|
// Resolves context for `mate cap` commands. Unlike resolveForLaunch, this allows
|
|
286
262
|
// running from the companion directory itself — cap commands manage companion state
|
|
287
263
|
// and don't need a working-repo cwd. resolveForLaunch intentionally lacks this
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { parse as parseYaml } from "yaml";
|
|
8
|
+
|
|
9
|
+
import { MATE_ENV } from "../../runtime/env-names";
|
|
10
|
+
import { runtimeWrapperBinPath } from "../../runtime/runtime-directory";
|
|
11
|
+
import { resolveCommandOnPath } from "../../tools/setup/utils";
|
|
12
|
+
import { getWrapperBinPath } from "../package-paths";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The Companion Repository as an OpenSpec store. OpenSpec reports the Working
|
|
16
|
+
* Repository as an edit root only when the CLI runs inside a project whose
|
|
17
|
+
* `openspec/config.yaml` declares a registered store, so the store has to be
|
|
18
|
+
* registered before any pointer may name it.
|
|
19
|
+
*
|
|
20
|
+
* Registration is checked by reading OpenSpec's registry file, never by
|
|
21
|
+
* spawning: the check runs on every session refresh. The CLI is spawned only to
|
|
22
|
+
* register, which OpenSpec answers authoritatively.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
|
|
27
|
+
/** OpenSpec's one id grammar, shared by store and change ids. */
|
|
28
|
+
const KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
29
|
+
|
|
30
|
+
const STORE_METADATA_FILE = path.join(".openspec-store", "store.yaml");
|
|
31
|
+
|
|
32
|
+
/** Injectable so the suite never touches the developer's registry or binary. */
|
|
33
|
+
export const openSpecStoreDeps = {
|
|
34
|
+
env: (): NodeJS.ProcessEnv => process.env,
|
|
35
|
+
platform: (): NodeJS.Platform => process.platform,
|
|
36
|
+
homedir: (): string => os.homedir(),
|
|
37
|
+
/** Mate's own `openspec` wrappers, which must never answer a registration. */
|
|
38
|
+
wrapperBinDirs: (): string[] => {
|
|
39
|
+
const launched = openSpecStoreDeps.env()[MATE_ENV.wrapperBinPath];
|
|
40
|
+
return [getWrapperBinPath(), runtimeWrapperBinPath(), ...(launched ? [launched] : [])];
|
|
41
|
+
},
|
|
42
|
+
resolveBinary: (): string | undefined => {
|
|
43
|
+
const excluded = new Set(openSpecStoreDeps.wrapperBinDirs().map((dir) => path.resolve(dir)));
|
|
44
|
+
const searchPath = (openSpecStoreDeps.env().PATH ?? "")
|
|
45
|
+
.split(path.delimiter)
|
|
46
|
+
.filter((dir) => dir && !excluded.has(path.resolve(dir)))
|
|
47
|
+
.join(path.delimiter);
|
|
48
|
+
return resolveCommandOnPath("openspec", searchPath);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type StoreRegistrationCheck =
|
|
53
|
+
| { state: "registered" }
|
|
54
|
+
| { state: "absent" }
|
|
55
|
+
| { state: "conflict"; path: string }
|
|
56
|
+
| { state: "unreadable" };
|
|
57
|
+
|
|
58
|
+
export type StoreRegistration =
|
|
59
|
+
| { state: "written" | "current"; id: string }
|
|
60
|
+
| { state: "skipped"; reason: string };
|
|
61
|
+
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function canonicalPath(candidate: string): Promise<string> {
|
|
67
|
+
return fs.realpath(candidate).catch(() => path.resolve(candidate));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function isDirectory(candidate: string): Promise<boolean> {
|
|
71
|
+
return fs
|
|
72
|
+
.stat(candidate)
|
|
73
|
+
.then((stats) => stats.isDirectory())
|
|
74
|
+
.catch(() => false);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sanitizeStoreId(name: string): string {
|
|
78
|
+
return name
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
81
|
+
.replace(/^-+|-+$/g, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The id a registration already recorded beside the companion, else the
|
|
86
|
+
* companion folder name made into a valid id. Machine-local on purpose: no
|
|
87
|
+
* artifact embeds it, and a committed one would collide across clones.
|
|
88
|
+
*/
|
|
89
|
+
export async function deriveStoreId(companionPath: string): Promise<string> {
|
|
90
|
+
const metadata = await fs
|
|
91
|
+
.readFile(path.join(companionPath, STORE_METADATA_FILE), "utf8")
|
|
92
|
+
.then((content) => parseYaml(content) as unknown)
|
|
93
|
+
.catch(() => null);
|
|
94
|
+
if (isRecord(metadata) && typeof metadata.id === "string" && KEBAB_ID.test(metadata.id)) {
|
|
95
|
+
return metadata.id;
|
|
96
|
+
}
|
|
97
|
+
const folder = path.basename(path.resolve(companionPath));
|
|
98
|
+
const id = sanitizeStoreId(folder);
|
|
99
|
+
if (!KEBAB_ID.test(id)) {
|
|
100
|
+
throw new Error(`cannot derive an OpenSpec store id from the companion folder "${folder}"`);
|
|
101
|
+
}
|
|
102
|
+
return id;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Mirrors OpenSpec's `getGlobalDataDir`, so Mate reads the registry OpenSpec writes. */
|
|
106
|
+
export function openSpecDataDir(
|
|
107
|
+
env: NodeJS.ProcessEnv,
|
|
108
|
+
platform: NodeJS.Platform,
|
|
109
|
+
homedir: string,
|
|
110
|
+
): string {
|
|
111
|
+
if (env.XDG_DATA_HOME) return path.join(env.XDG_DATA_HOME, "openspec");
|
|
112
|
+
if (platform === "win32") {
|
|
113
|
+
return path.join(env.LOCALAPPDATA ?? path.join(homedir, "AppData", "Local"), "openspec");
|
|
114
|
+
}
|
|
115
|
+
return path.join(homedir, ".local", "share", "openspec");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function registryPath(): string {
|
|
119
|
+
return path.join(
|
|
120
|
+
openSpecDataDir(
|
|
121
|
+
openSpecStoreDeps.env(),
|
|
122
|
+
openSpecStoreDeps.platform(),
|
|
123
|
+
openSpecStoreDeps.homedir(),
|
|
124
|
+
),
|
|
125
|
+
"stores",
|
|
126
|
+
"registry.yaml",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read-only: what the registry says about `id`, compared by canonical path. */
|
|
131
|
+
export async function checkStoreRegistration(
|
|
132
|
+
companionPath: string,
|
|
133
|
+
id: string,
|
|
134
|
+
): Promise<StoreRegistrationCheck> {
|
|
135
|
+
let content: string;
|
|
136
|
+
try {
|
|
137
|
+
content = await fs.readFile(registryPath(), "utf8");
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT"
|
|
140
|
+
? { state: "absent" }
|
|
141
|
+
: { state: "unreadable" };
|
|
142
|
+
}
|
|
143
|
+
let registry: unknown;
|
|
144
|
+
try {
|
|
145
|
+
registry = parseYaml(content);
|
|
146
|
+
} catch {
|
|
147
|
+
return { state: "unreadable" };
|
|
148
|
+
}
|
|
149
|
+
if (!isRecord(registry) || !isRecord(registry.stores)) return { state: "unreadable" };
|
|
150
|
+
const entry = registry.stores[id];
|
|
151
|
+
if (entry === undefined) return { state: "absent" };
|
|
152
|
+
const localPath =
|
|
153
|
+
isRecord(entry) && isRecord(entry.backend) ? entry.backend.local_path : undefined;
|
|
154
|
+
if (typeof localPath !== "string") return { state: "unreadable" };
|
|
155
|
+
return (await canonicalPath(localPath)) === (await canonicalPath(companionPath))
|
|
156
|
+
? { state: "registered" }
|
|
157
|
+
: { state: "conflict", path: localPath };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function commandOutput(error: unknown): string {
|
|
161
|
+
const { stderr, stdout, message } = error as {
|
|
162
|
+
stderr?: string;
|
|
163
|
+
stdout?: string;
|
|
164
|
+
message: string;
|
|
165
|
+
};
|
|
166
|
+
return (stderr || stdout || message).trim();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Registers the companion when the registry lacks it. A conflicting id fails
|
|
171
|
+
* rather than silently picking another one; OpenSpec without stores, or a
|
|
172
|
+
* companion that is no OpenSpec root, is skipped.
|
|
173
|
+
*/
|
|
174
|
+
export async function ensureStoreRegistration(companionPath: string): Promise<StoreRegistration> {
|
|
175
|
+
const companion = await canonicalPath(companionPath);
|
|
176
|
+
if (!(await isDirectory(path.join(companion, "openspec")))) {
|
|
177
|
+
return { state: "skipped", reason: `${companion} is not an OpenSpec root` };
|
|
178
|
+
}
|
|
179
|
+
const id = await deriveStoreId(companion);
|
|
180
|
+
const check = await checkStoreRegistration(companion, id);
|
|
181
|
+
if (check.state === "registered") return { state: "current", id };
|
|
182
|
+
if (check.state === "conflict") {
|
|
183
|
+
throw new Error(`OpenSpec store id "${id}" is already registered at ${check.path}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const bin = openSpecStoreDeps.resolveBinary();
|
|
187
|
+
if (!bin) return { state: "skipped", reason: "the openspec CLI is not on PATH" };
|
|
188
|
+
try {
|
|
189
|
+
await execFileAsync(bin, ["store", "register", companion, "--id", id, "--yes"], {
|
|
190
|
+
cwd: companion,
|
|
191
|
+
env: openSpecStoreDeps.env(),
|
|
192
|
+
timeout: 30_000,
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const output = commandOutput(error);
|
|
196
|
+
if (/unknown command/i.test(output)) {
|
|
197
|
+
return { state: "skipped", reason: "the openspec CLI does not support stores" };
|
|
198
|
+
}
|
|
199
|
+
throw new Error(`openspec store register failed: ${output}`, { cause: error });
|
|
200
|
+
}
|
|
201
|
+
return { state: "written", id };
|
|
202
|
+
}
|
|
@@ -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
|