@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.
Files changed (127) hide show
  1. package/claude-plugin/hooks/hooks.json +10 -1
  2. package/claude-plugin/hooks/session-banner.mjs +4 -3
  3. package/claude-plugin/hooks/session-prepare.mjs +9 -0
  4. package/opencode-plugin/src/add-dir.ts +10 -76
  5. package/opencode-plugin/src/audience-layer.ts +91 -0
  6. package/opencode-plugin/src/companion.ts +70 -18
  7. package/opencode-plugin/src/launch-environment.ts +29 -0
  8. package/opencode-plugin/src/server.ts +60 -8
  9. package/package.json +3 -3
  10. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  11. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  12. package/src/cli/commands/cap/index-cmd.ts +2 -11
  13. package/src/cli/commands/companion/link.ts +16 -11
  14. package/src/cli/commands/companion/list.ts +4 -5
  15. package/src/cli/commands/companion/open.ts +4 -5
  16. package/src/cli/commands/companion/tui.ts +1 -1
  17. package/src/cli/commands/doctor.ts +87 -5
  18. package/src/cli/commands/install.ts +8 -0
  19. package/src/cli/commands/report/renderer.ts +9 -19
  20. package/src/cli/commands/session/prepare.ts +119 -0
  21. package/src/cli/commands/shared/companion-selection.ts +2 -2
  22. package/src/cli/commands/studio/reports.ts +0 -1
  23. package/src/cli/commands/studio/server.ts +6 -2
  24. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  25. package/src/cli/commands/unwrap.ts +28 -3
  26. package/src/cli/commands/wrap.ts +79 -60
  27. package/src/cli/companion-selector.tsx +1 -1
  28. package/src/cli/main.ts +17 -0
  29. package/src/cli/usage.ts +5 -2
  30. package/src/hooks/session-banner.ts +19 -10
  31. package/src/hooks/session-guidance.ts +20 -2
  32. package/src/hooks/session-prepare.ts +101 -0
  33. package/src/lib/fs-utils.ts +13 -0
  34. package/src/lib/orchestrator/adapters/base.ts +45 -9
  35. package/src/lib/orchestrator/adapters/claude.ts +241 -30
  36. package/src/lib/orchestrator/adapters/opencode.ts +72 -34
  37. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  38. package/src/lib/orchestrator/companion-hub.ts +2 -1
  39. package/src/lib/orchestrator/config-store.ts +83 -1
  40. package/src/lib/orchestrator/framework-context.ts +5 -29
  41. package/src/lib/orchestrator/launcher.ts +36 -171
  42. package/src/lib/orchestrator/openspec-store.ts +202 -0
  43. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  44. package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
  45. package/src/lib/orchestrator/projection-entries.ts +124 -11
  46. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  47. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  48. package/src/lib/orchestrator/projection-record.ts +7 -1
  49. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  50. package/src/lib/orchestrator/projection-types.ts +47 -34
  51. package/src/lib/orchestrator/repository-link.ts +473 -0
  52. package/src/lib/orchestrator/root-context.ts +6 -2
  53. package/src/lib/orchestrator/session-prepare.ts +503 -0
  54. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  55. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  56. package/src/lib/orchestrator/working-repo-projection.ts +73 -160
  57. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  58. package/src/lib/orchestrator/wrap-pass.ts +222 -0
  59. package/src/lib/runtime-directory-refresh.ts +186 -0
  60. package/src/lib/update-checker.ts +6 -0
  61. package/src/opencode/companion-hooks.ts +30 -135
  62. package/src/opencode/companion-policy.ts +26 -10
  63. package/src/opencode/index.ts +1 -7
  64. package/src/opencode/projected-guidance.ts +6 -13
  65. package/src/opencode/session-notes.ts +76 -0
  66. package/src/opencode/session-prepare.ts +139 -0
  67. package/src/opencode/tui.tsx +56 -9
  68. package/src/runtime/audience-layer.ts +84 -0
  69. package/src/runtime/claude-delivery-plan.ts +73 -0
  70. package/src/runtime/claude-hook-group.ts +21 -0
  71. package/src/runtime/companion-lock.ts +121 -0
  72. package/src/runtime/companion-sync.ts +66 -9
  73. package/src/runtime/guidance.ts +17 -0
  74. package/src/runtime/opencode-audience-layer.ts +176 -0
  75. package/src/runtime/policy.ts +14 -1
  76. package/src/runtime/projection.ts +1 -1
  77. package/src/runtime/runtime-directory.ts +43 -0
  78. package/src/runtime/runtime-document-manifest.ts +352 -0
  79. package/src/runtime/session-prepare-runner.ts +148 -0
  80. package/src/runtime/wrap-state.ts +94 -0
  81. package/src/runtime/write-atomic.ts +27 -0
  82. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  83. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  84. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  85. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  86. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  87. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  88. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  89. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  90. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  91. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  92. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  93. package/src/tools/setup/audience-layer.ts +207 -0
  94. package/src/tools/setup/audience-registrations.ts +52 -0
  95. package/src/tools/setup/capabilities/openspec.ts +9 -0
  96. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  97. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  98. package/src/tools/setup/contribution-ledger.ts +207 -0
  99. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  100. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  101. package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
  102. package/src/tools/setup/engine.ts +152 -10
  103. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  104. package/src/tools/setup/mate.ts +1 -1
  105. package/src/tools/setup/plugin.ts +19 -4
  106. package/src/tools/setup/plugins/gitignore.ts +7 -1
  107. package/src/tools/setup/providers/claude-format.ts +11 -18
  108. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  109. package/src/tools/setup/providers/claude.ts +50 -8
  110. package/src/tools/setup/providers/opencode.ts +10 -0
  111. package/src/tools/setup/registry.ts +24 -2
  112. package/src/tools/setup/runtime-documents.ts +152 -32
  113. package/src/tools/setup/surface-target.ts +13 -4
  114. package/src/tools/setup/upgrade-sweep.ts +153 -0
  115. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  116. package/src/tools/setup/working-repo-local-state.ts +91 -14
  117. package/src/tools/setup.ts +11 -8
  118. package/wrappers/bin/openspec +42 -1
  119. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  120. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  121. package/src/lib/orchestrator/companion-store.ts +0 -94
  122. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  123. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  124. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  125. package/src/templates/report-assets/README.md +0 -32
  126. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  127. 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(migrateProfilesToAllowedAgents(await super.load()));
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 { findRepoLocalLinkedRepository } from "./repo-local-registry";
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 { CapabilityPlugin, LaunchPreflightContext } from "../../tools/setup/plugin";
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 "./companion-store";
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
- syncCompanionFiles,
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
- * A wrapped Working Repository delivers the companion to sessions the operator
72
- * starts themselves, through documents the Agent Runtime discovers by its own
73
- * upward walk. A Managed Session loads those *and* everything the launch
74
- * injects through `--settings`, `--mcp-config` and `--plugin-dir`, which
75
- * ADR-014 established can never be withdrawn. Two delivery channels that cannot
76
- * be merged must therefore not both be live for one repository, so wrapping and
77
- * launching are exclusive rather than reconciled (ADR-015, superseding
78
- * ADR-014's judgement that the duplication is bounded enough to accept).
79
- *
80
- * Raised before every write the launch itself performs: the companion Git sync,
81
- * the companion file sync, the launch scope's projection and the runtime
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
- ["claude", new ClaudeAdapter()],
108
- ["opencode", new OpenCodeAdapter()],
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
- if (state.repository && (await launcherDeps.isWrapped(state.repository.path))) {
127
- throw new LaunchPreflightError(wrappedRepositoryRefusal(state.repository));
128
- }
129
-
130
- if (!request.skipGit && state.config.git === "auto") {
131
- await launcherDeps.syncCompanionGit(
132
- state.companionPath,
133
- state.repository?.path,
134
- request.interactiveGit ?? false,
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(configStore, workingRepoStore);
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 the
56
- * guard group the `wrap` scope places: that one is managed by another entry,
57
- * and stripping it would disarm every Unmanaged Session on the next launch.
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(settings: ClaudeSettings): ClaudeSettings {
61
+ function withoutForeignManagedHookGroups(
62
+ settings: ClaudeSettings,
63
+ wrap: WrapState,
64
+ ): ClaudeSettings {
60
65
  const hooks = filterClaudeHookGroups(
61
66
  settings.hooks ?? {},
62
- (group) => isMateGuardHookGroup(group) || !isManagedHookGroup(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(parseSettings(raw));
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