@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
@@ -7,6 +7,7 @@ import {
7
7
  type StudioAccess,
8
8
  type StudioInvocation,
9
9
  } from "./access";
10
+ import { MERMAID_RUNTIME_URL } from "../report/renderer";
10
11
  import { collectStudioInventory, type StudioInventory } from "./inventory";
11
12
  import { assembleCompanionPayload, type StudioCompanionResponse } from "./payload";
12
13
  import { createReportStore, isReportId, REPORT_LIMITS, type ReportStore } from "./reports";
@@ -189,11 +190,14 @@ function refused(reason: string, status: number): Response {
189
190
  });
190
191
  }
191
192
 
192
- /** Sent with every hosted report: opaque origin, no connections, no Studio cookie or storage. */
193
+ /**
194
+ * Sent with every hosted report: opaque origin, no Studio cookie or storage, and
195
+ * no connections beyond the one pinned, integrity-checked diagram runtime.
196
+ */
193
197
  const REPORT_CSP = [
194
198
  "sandbox allow-scripts allow-modals",
195
199
  "default-src 'none'",
196
- "script-src 'unsafe-inline'",
200
+ `script-src 'unsafe-inline' ${MERMAID_RUNTIME_URL}`,
197
201
  "style-src 'unsafe-inline'",
198
202
  "img-src data:",
199
203
  "font-src data:",
@@ -3,9 +3,11 @@ import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../../framework";
5
5
  import { ConfigStore, mergeWithDefaults } from "../../../lib/orchestrator/config-store";
6
+ import { currentAudienceLayer } from "../../../runtime/audience-layer";
6
7
  import {
7
8
  effectiveStudioAgent,
8
9
  readAudienceSelection,
10
+ resolveAudience,
9
11
  } from "../../../tools/setup/dynamic-plugins/audiences";
10
12
  import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
11
13
  import { findAction, promptArgs, skillInstalledFor, SUBJECT_PATTERN } from "./actions";
@@ -51,7 +53,8 @@ export interface DefaultAgentOutcome {
51
53
  /**
52
54
  * The companion's effective default agent for one provider: the active
53
55
  * audience's `studio.terminal.agent` (Studio's own `MATE_AUDIENCE`, never the
54
- * browser's), else the base one. Never throws and never echoes the configured
56
+ * browser's), else the base one. Its definition may come from the companion
57
+ * or from the active audience's rendered layer. Never throws and never echoes the configured
55
58
  * value into the notice.
56
59
  */
57
60
  export async function defaultAgentFor(
@@ -62,11 +65,13 @@ export async function defaultAgentFor(
62
65
  const configPath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
63
66
  if (!fs.existsSync(configPath)) return {};
64
67
  let name: unknown;
68
+ let layer: string | null = null;
65
69
  try {
66
- name = effectiveStudioAgent(
67
- await new ConfigStore(configPath).load(),
68
- readAudienceSelection(env),
69
- );
70
+ const config = await new ConfigStore(configPath).load();
71
+ const audience = resolveAudience(config, readAudienceSelection(env));
72
+ const active = audience.ok ? audience.active : null;
73
+ name = effectiveStudioAgent(config, active);
74
+ if (active) layer = currentAudienceLayer(companionPath, active);
70
75
  } catch {
71
76
  return {};
72
77
  }
@@ -76,8 +81,12 @@ export async function defaultAgentFor(
76
81
  notice: "Default agent not applied: studio.terminal.agent is not a valid agent name.",
77
82
  };
78
83
  }
79
- const definition = path.join(companionPath, AGENT_DEFINITION_DIRS[agent], `${name}.md`);
80
- if (!fs.existsSync(definition)) {
84
+ /** The audience's own agents live in its launch layer, which the terminal's launch attaches. */
85
+ const roots = layer ? [companionPath, layer] : [companionPath];
86
+ const defined = roots.some((root) =>
87
+ fs.existsSync(path.join(root, AGENT_DEFINITION_DIRS[agent], `${name}.md`)),
88
+ );
89
+ if (!defined) {
81
90
  return {
82
91
  notice: `Default agent "${name}" not applied: this companion has no ${agent} definition for it.`,
83
92
  };
@@ -1,6 +1,9 @@
1
1
  import { FRAMEWORK_NAME } from "../../framework";
2
- import { findRepoLocalLinkedRepository } from "../../lib/orchestrator/repo-local-registry";
2
+ import { attendedLockOptions } from "../../lib/orchestrator/companion-git-sync";
3
+ import { findRepoLocalLinkedRepository } from "../../lib/orchestrator/repository-link";
3
4
  import { unwrapWorkingRuntimeDocuments } from "../../lib/orchestrator/working-repo-projection";
5
+ import { withCompanionLock } from "../../runtime/companion-lock";
6
+ import { readProjectionFile } from "../../runtime/projection";
4
7
 
5
8
  export interface UnwrapCommandDependencies {
6
9
  findRepoLocalLinkedRepository?: typeof findRepoLocalLinkedRepository;
@@ -48,7 +51,24 @@ export async function runUnwrapCommand(
48
51
  return;
49
52
  }
50
53
 
51
- const result = await unwrap(repository.path);
54
+ /**
55
+ * Under the lock of the companion the projection names, so a prepare or sync
56
+ * for it cannot rewrite the manifest mid-withdrawal. No companion, no lock:
57
+ * unwrapping must still work when the companion is gone.
58
+ */
59
+ const companionPath = readProjectionFile(repository.path)?.projection.companionPath;
60
+ const locked = companionPath
61
+ ? await withCompanionLock(companionPath, attendedLockOptions(companionPath), () =>
62
+ unwrap(repository.path),
63
+ )
64
+ : { acquired: true as const, value: await unwrap(repository.path) };
65
+ if (!locked.acquired) {
66
+ console.error(`${FRAMEWORK_NAME}: another companion synchronization is still running.`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+
71
+ const result = locked.value;
52
72
  if (result.kind === "failed") {
53
73
  console.error(
54
74
  `${FRAMEWORK_NAME}: failed to withdraw ${result.document} for ${repository.id}: ${result.error.message}`,
@@ -66,5 +86,10 @@ export async function runUnwrapCommand(
66
86
  for (const document of result.documents) {
67
87
  console.log(` Withdrawn: ${document}`);
68
88
  }
69
- console.log(`\`${FRAMEWORK_NAME} claude\` and \`${FRAMEWORK_NAME} opencode\` work here again.`);
89
+ if (result.copies.length > 0) {
90
+ console.log(` Withdrawn: ${result.copies.length} companion copies under .opencode/`);
91
+ }
92
+ console.log(
93
+ `The next \`${FRAMEWORK_NAME} claude\` or \`${FRAMEWORK_NAME} opencode\` wraps this repository again.`,
94
+ );
70
95
  }
@@ -1,48 +1,61 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import { FRAMEWORK_NAME } from "../../framework";
4
- import { ConfigStore, mergeWithDefaults } from "../../lib/orchestrator/config-store";
5
- import { findRepoLocalLinkedRepository } from "../../lib/orchestrator/repo-local-registry";
6
- import {
7
- projectWorkingRepository,
8
- projectWorkingRuntimeDocuments,
9
- } from "../../lib/orchestrator/working-repo-projection";
10
- import { renderWorkingRuntimeDocuments } from "../../tools/setup";
4
+ import { findRepoLocalLinkedRepository } from "../../lib/orchestrator/repository-link";
5
+ import type { ProjectionChangedPaths } from "../../lib/orchestrator/projection-types";
6
+ import { attendedLockOptions } from "../../lib/orchestrator/companion-git-sync";
7
+ import { readCompanionConfig } from "../../lib/orchestrator/config-store";
8
+ import { runWrapPass, type WrapPassResult } from "../../lib/orchestrator/wrap-pass";
9
+ import { withCompanionLock } from "../../runtime/companion-lock";
10
+ import { WRAP_RUNTIMES } from "../../runtime/wrap-state";
11
11
  import { launchAmbiguityDeps } from "./shared/companion-selection";
12
12
 
13
13
  export interface WrapCommandDependencies {
14
14
  findRepoLocalLinkedRepository?: typeof findRepoLocalLinkedRepository;
15
- projectWorkingRepository?: typeof projectWorkingRepository;
16
- projectWorkingRuntimeDocuments?: typeof projectWorkingRuntimeDocuments;
17
- renderWorkingRuntimeDocuments?: typeof renderWorkingRuntimeDocuments;
15
+ runWrapPass?: typeof runWrapPass;
18
16
  resolveLinkedCompanions?: typeof launchAmbiguityDeps.resolveLinkedCompanions;
19
17
  }
20
18
 
21
19
  export const wrapCommandDeps: Required<WrapCommandDependencies> = {
22
20
  findRepoLocalLinkedRepository,
23
- projectWorkingRepository,
24
- projectWorkingRuntimeDocuments,
25
- renderWorkingRuntimeDocuments,
21
+ runWrapPass,
26
22
  resolveLinkedCompanions: (cwd: string) => launchAmbiguityDeps.resolveLinkedCompanions(cwd),
27
23
  };
28
24
 
29
- /** The companion's own selection, read from files: wrapping runs no setup pass. */
30
- async function readCompanionConfig(companionPath: string) {
31
- return mergeWithDefaults(
32
- await new ConfigStore(
33
- path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
34
- ).load(),
35
- );
25
+ /** Counts rather than names: a companion folder can hold dozens of files. */
26
+ function reportChangedPaths(changed: ProjectionChangedPaths): void {
27
+ if (changed.written.length > 0 || changed.removed.length > 0) {
28
+ console.log(
29
+ ` Copies: ${changed.written.length} written, ${changed.removed.length} withdrawn under .opencode/`,
30
+ );
31
+ }
36
32
  }
37
33
 
38
34
  /**
39
35
  * `--companion` is consumed by the dispatch gate before the command runs, so it
40
- * is parsed there too; this only rejects anything else.
36
+ * is parsed there too. `--audience` names the audience the checkout's bare
37
+ * sessions get beside base.
41
38
  */
42
- export function parseWrapArgs(argv: string[]): { companion?: string } | { error: string } {
39
+ export function parseWrapArgs(
40
+ argv: string[],
41
+ ): { companion?: string; audience?: string } | { error: string } {
43
42
  let companion: string | undefined;
43
+ let audience: string | undefined;
44
44
  for (let index = 0; index < argv.length; index += 1) {
45
45
  const arg = argv[index]!;
46
+ if (arg === "--audience") {
47
+ const value = argv[index + 1];
48
+ if (!value || value.startsWith("--")) return { error: "--audience requires a name" };
49
+ audience = value;
50
+ index += 1;
51
+ continue;
52
+ }
53
+ if (arg.startsWith("--audience=")) {
54
+ const value = arg.slice("--audience=".length);
55
+ if (!value) return { error: "--audience requires a name" };
56
+ audience = value;
57
+ continue;
58
+ }
46
59
  if (arg === "--companion") {
47
60
  const value = argv[index + 1];
48
61
  if (!value || value.startsWith("--")) return { error: "--companion requires a path" };
@@ -58,7 +71,10 @@ export function parseWrapArgs(argv: string[]): { companion?: string } | { error:
58
71
  }
59
72
  return { error: `unknown wrap option: ${arg}` };
60
73
  }
61
- return companion === undefined ? {} : { companion };
74
+ return {
75
+ ...(companion === undefined ? {} : { companion }),
76
+ ...(audience === undefined ? {} : { audience }),
77
+ };
62
78
  }
63
79
 
64
80
  /**
@@ -76,11 +92,7 @@ export async function runWrapCommand(
76
92
  ): Promise<void> {
77
93
  const findRepository =
78
94
  deps.findRepoLocalLinkedRepository ?? wrapCommandDeps.findRepoLocalLinkedRepository;
79
- const project = deps.projectWorkingRepository ?? wrapCommandDeps.projectWorkingRepository;
80
- const projectDocuments =
81
- deps.projectWorkingRuntimeDocuments ?? wrapCommandDeps.projectWorkingRuntimeDocuments;
82
- const renderDocuments =
83
- deps.renderWorkingRuntimeDocuments ?? wrapCommandDeps.renderWorkingRuntimeDocuments;
95
+ const wrap = deps.runWrapPass ?? wrapCommandDeps.runWrapPass;
84
96
  const resolveLinked = deps.resolveLinkedCompanions ?? wrapCommandDeps.resolveLinkedCompanions;
85
97
 
86
98
  const parsed = parseWrapArgs(argv);
@@ -105,8 +117,8 @@ export async function runWrapCommand(
105
117
  * here — a single Linked Companion needs no answer to have been recorded.
106
118
  */
107
119
  const companionPath =
108
- (parsed.companion && path.resolve(parsed.companion)) ??
109
- process.env.MATE_ARTIFACT_PATH ??
120
+ (parsed.companion && path.resolve(parsed.companion)) ||
121
+ process.env.MATE_ARTIFACT_PATH ||
110
122
  (await resolveLinked(cwd))[0]?.companionPath;
111
123
  if (!companionPath) {
112
124
  console.error(`${FRAMEWORK_NAME}: no registered companion links ${repository.id}.`);
@@ -115,50 +127,57 @@ export async function runWrapCommand(
115
127
  return;
116
128
  }
117
129
 
118
- const result = await project(companionPath, repository);
119
- if (result.kind === "failed") {
120
- console.error(
121
- `${FRAMEWORK_NAME}: failed to write the projection for ${repository.id}: ${result.error.message}`,
122
- );
123
- process.exitCode = 1;
124
- return;
125
- }
126
-
130
+ /** Serialized with Session Prepare and every sync, which rewrite the same manifest. */
127
131
  /**
128
- * After the projection, never instead of it: a runtime document that cannot
129
- * be written fails the command while the projection already written stays.
132
+ * The flag is the only way to give a checkout's bare sessions an audience;
133
+ * without it the wrap is base and any recorded audience is withdrawn.
130
134
  */
131
- const config = await readCompanionConfig(result.companionPath);
132
- const documents = await projectDocuments(
133
- result.companionPath,
134
- repository,
135
- config,
136
- await renderDocuments(result.companionPath, config, repository.path),
135
+ const locked = await withCompanionLock(companionPath, attendedLockOptions(companionPath), () =>
136
+ wrap(companionPath, repository, { audience: parsed.audience ?? null }),
137
137
  );
138
- if (documents.kind === "failed") {
139
- console.error(
140
- `${FRAMEWORK_NAME}: failed to reconcile ${documents.document} for ${repository.id}: ${documents.error.message}`,
141
- );
138
+ const result: WrapPassResult = locked.acquired
139
+ ? locked.value
140
+ : { kind: "failed", message: "another companion synchronization is still running." };
141
+ if (result.kind === "failed") {
142
+ console.error(`${FRAMEWORK_NAME}: ${result.message}`);
142
143
  process.exitCode = 1;
143
144
  return;
144
145
  }
145
146
 
146
- const changed = result.kind === "written" || documents.kind === "written";
147
147
  console.log(
148
- changed
148
+ result.kind === "written"
149
149
  ? `Wrapped ${repository.id} at ${result.projectionRoot}`
150
150
  : `Already wrapped: ${result.projectionRoot}`,
151
151
  );
152
152
  console.log(` Companion: ${result.companionPath}`);
153
- for (const document of documents.documents) {
153
+ if (result.audience) console.log(` Audience: ${result.audience}`);
154
+ for (const document of result.documents) {
154
155
  console.log(` Runtime: ${document}`);
155
156
  }
156
- /**
157
- * Said at the moment the mode is switched, not left to be discovered at the
158
- * next launch: wrapping makes `mate claude` and `mate opencode` refuse here,
159
- * and the operator has no other way to learn that from a successful command.
160
- */
157
+ for (const warning of result.warnings) {
158
+ console.error(`${FRAMEWORK_NAME}: warning: ${warning}`);
159
+ }
160
+ reportChangedPaths(result.changed);
161
+ const { runtimes } = result;
162
+ const tools = WRAP_RUNTIMES.filter((runtime) => runtimes.has(runtime));
163
+ if (tools.length === 0) return;
164
+ const plain = tools.map((tool) => `\`${tool}\``).join(", ");
165
+ const managed = tools.map((tool) => `\`${FRAMEWORK_NAME} ${tool}\``).join(", ");
161
166
  console.log(
162
- `Start sessions yourself now: \`claude\` or \`opencode\`. \`${FRAMEWORK_NAME} claude\` and \`${FRAMEWORK_NAME} opencode\` do not run in a wrapped repository — \`${FRAMEWORK_NAME} unwrap\` reverses this.`,
167
+ `Start sessions yourself (${plain}) or through ${managed}; both work here. \`${FRAMEWORK_NAME} unwrap\` reverses this.`,
163
168
  );
169
+ if (runtimes.has("claude") && (await contextModeEnabled(result.companionPath))) {
170
+ console.log(
171
+ `Note: context-mode loads only in sessions started with \`${FRAMEWORK_NAME} claude\`.`,
172
+ );
173
+ }
174
+ }
175
+
176
+ async function contextModeEnabled(companionPath: string): Promise<boolean> {
177
+ try {
178
+ const config = await readCompanionConfig(companionPath);
179
+ return (config.capabilities ?? []).some((capability) => capability.name === "context-mode");
180
+ } catch {
181
+ return false;
182
+ }
164
183
  }
@@ -3,7 +3,7 @@ import React, { useEffect } from "react";
3
3
  import { SelectMenu, type SelectMenuItem } from "../lib/components/select-menu";
4
4
  import { WizardFooter } from "../lib/components/wizard-footer";
5
5
  import { WizardHeader } from "../lib/components/wizard-header";
6
- import type { CompanionMatch } from "../lib/orchestrator/companion-resolver";
6
+ import type { CompanionMatch } from "../lib/orchestrator/repository-link";
7
7
 
8
8
  export interface CompanionSelectorOptions {
9
9
  stdin?: NodeJS.ReadStream;
package/src/cli/main.ts CHANGED
@@ -16,6 +16,7 @@ import { runLaunchClaudeCommand } from "./commands/launch/claude";
16
16
  import { runLaunchOpenCodeCommand } from "./commands/launch/opencode";
17
17
  import { runPluginCommand } from "./commands/plugin/plugin";
18
18
  import { runReportCommand } from "./commands/report";
19
+ import { runSessionCommand } from "./commands/session/prepare";
19
20
  import { runStudioCommand } from "./commands/studio";
20
21
  import { runUpdateCommand } from "./commands/update";
21
22
  import { runUnwrapCommand } from "./commands/unwrap";
@@ -33,6 +34,7 @@ import {
33
34
  ensureUnambiguousCompanion,
34
35
  type CompanionSelectionOptions,
35
36
  } from "./commands/shared/companion-selection";
37
+ import { ensureRuntimeDirectory, refreshRuntimeDirectory } from "../lib/runtime-directory-refresh";
36
38
  import { hydrateDynamicPlugins } from "../tools/setup/dynamic-plugins/hydrate";
37
39
  import { findPluginCliCommand } from "./plugin-commands";
38
40
  import { usage } from "./usage";
@@ -60,6 +62,7 @@ export interface MainDeps {
60
62
  repairInstallState?: typeof repairInstallState;
61
63
  hydrateDynamicPlugins: typeof hydrateDynamicPlugins;
62
64
  resolveRootContext: typeof resolveRootContext;
65
+ refreshRuntimeDirectory?: typeof refreshRuntimeDirectory;
63
66
  }
64
67
 
65
68
  const mainDeps: MainDeps = {
@@ -68,11 +71,17 @@ const mainDeps: MainDeps = {
68
71
  repairInstallState,
69
72
  hydrateDynamicPlugins,
70
73
  resolveRootContext,
74
+ refreshRuntimeDirectory,
71
75
  };
72
76
 
73
77
  export async function main(argv = process.argv, deps: MainDeps = mainDeps): Promise<void> {
74
78
  const [, , command, subcommand, ...rest] = argv;
75
79
 
80
+ if (!(await ensureRuntimeDirectory(command, deps.refreshRuntimeDirectory))) {
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+
76
85
  if (command === "-v" || command === "--version") {
77
86
  console.log(getActiveDistribution().config.version);
78
87
  return;
@@ -292,6 +301,14 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
292
301
  await runWrapCommand(wrapArgs);
293
302
  return;
294
303
  }
304
+ case "session":
305
+ /**
306
+ * Spawned by the OpenCode plugin for a bare session: no update prompt,
307
+ * no companion picker, no install repair may block it.
308
+ */
309
+ if (!(await gate({}))) return;
310
+ await runSessionCommand(subcommand, rest);
311
+ return;
295
312
  case "unwrap":
296
313
  // A recovery path: it withdraws what a wrap placed, so it must never be
297
314
  // gated on a companion or an installation it does not consult.
package/src/cli/usage.ts CHANGED
@@ -21,8 +21,9 @@ export function usage(): string {
21
21
  ` ${n} companion open`,
22
22
  ` ${n} companion tui`,
23
23
  ` ${n} working cleanup`,
24
- ` ${n} wrap [--companion PATH]`,
24
+ ` ${n} wrap [--companion PATH] [--audience NAME]`,
25
25
  ` ${n} unwrap`,
26
+ ` ${n} session prepare --repo <dir> [--json] [--sync]`,
26
27
  ` ${n} hub init [folder]`,
27
28
  ` ${n} hub add [source] [--id ID] [--path PATH]`,
28
29
  ` ${n} hub sync [--json] (companions + hub plugins)`,
@@ -53,7 +54,9 @@ export function usage(): string {
53
54
  "",
54
55
  "Wrapped repositories:",
55
56
  ` ${n} wrap configures the repository so sessions you start yourself load the companion`,
56
- ` ${n} claude and ${n} opencode do not run while a repository is wrapped`,
57
+ ` ${n} wrap --audience NAME gives those sessions one audience beside base; plain wrap returns to base`,
58
+ ` ${n} claude does not run while a repository is wrapped for Claude`,
59
+ ` ${n} opencode wraps the repository for OpenCode itself, then starts plain \`opencode\``,
57
60
  ` ${n} unwrap withdraws the wrap and restores managed launches; the link is kept`,
58
61
  "",
59
62
  "Doctor states:",
@@ -1,5 +1,6 @@
1
1
  // Show the active Mate companion paths in Claude Code, and repair the
2
2
  // companion's Git state before the session reads it.
3
+ import { unmanagedClaudeDelivery } from "../runtime/claude-delivery-plan";
3
4
  import {
4
5
  syncCompanionUnattended,
5
6
  unattendedSyncStalenessLines,
@@ -44,17 +45,25 @@ export async function buildBanner(
44
45
  if (!context.repositoryPath || !context.companionPath) return { exitCode: 0, stdout: "" };
45
46
 
46
47
  /**
47
- * Session start is the only moment at which the companion may move without
48
- * invalidating a read the session has already taken, so the repair happens
49
- * here and nowhere later. The launch-environment check comes first, so a
50
- * Managed Session pays nothing at all — not the freshness read, not the
51
- * policy read; and the consent gate is the operation's own.
48
+ * Repaired before the session reads the companion, so its first reads are
49
+ * current. The companion can still move later — a plain OpenCode session's
50
+ * background sync fast-forwards it — but a fast-forward never overwrites a
51
+ * local edit, so the worst case is a stale read of an upstream change. Only
52
+ * tries the companion lock: a held lock means another sync is already on it,
53
+ * and a session start must not stall behind it. The launch-environment check
54
+ * comes first, so a Managed Session pays nothing at all — not the freshness
55
+ * read, not the policy read; and the consent gate is the operation's own.
56
+ * Who owns it otherwise is the delivery plan's call, so a session start
57
+ * fetches once.
52
58
  */
53
- const gitNotes = hasLaunchEnvironment(env)
54
- ? []
55
- : unattendedSyncStalenessLines(
56
- await syncCompanionUnattended(context.companionPath, syncOptions),
57
- );
59
+ const ownsGitSync =
60
+ !hasLaunchEnvironment(env) &&
61
+ unmanagedClaudeDelivery(projection?.repoRoot).gitSync === "banner";
62
+ const gitNotes = ownsGitSync
63
+ ? unattendedSyncStalenessLines(
64
+ await syncCompanionUnattended(context.companionPath, { lock: "try", ...syncOptions }),
65
+ )
66
+ : [];
58
67
 
59
68
  const lines = [
60
69
  `mate v${env.MATE_VERSION || mateVersion()}`,
@@ -1,5 +1,8 @@
1
1
  // Deliver the companion guidance to a Claude session Mate did not launch.
2
+ import { currentAudienceLayer, readLayerGuidanceSync } from "../runtime/audience-layer";
3
+ import { resolveProjection } from "../runtime/projection";
2
4
  import { buildProjectedGuidance } from "../runtime/projected-guidance";
5
+ import { readWrapStateSync } from "../runtime/wrap-state";
3
6
  import type { HookEnv } from "./validate-artifact-path";
4
7
 
5
8
  export interface GuidanceOutcome {
@@ -7,6 +10,19 @@ export interface GuidanceOutcome {
7
10
  stdout: string;
8
11
  }
9
12
 
13
+ /**
14
+ * The guidance of the audience this checkout is wrapped for, from its layer.
15
+ * The wrap is the only source: an inherited `MATE_AUDIENCE` selects nothing.
16
+ */
17
+ function wrappedAudienceGuidance(cwd: string): string {
18
+ const projection = resolveProjection(cwd);
19
+ if (!projection) return "";
20
+ const audience = readWrapStateSync(projection.repoRoot).audience;
21
+ if (!audience) return "";
22
+ const layer = currentAudienceLayer(projection.companionPath, audience);
23
+ return layer ? readLayerGuidanceSync(layer, "CLAUDE.md") : "";
24
+ }
25
+
10
26
  /**
11
27
  * `SessionStart.additionalContext` is the file-based form of the managed
12
28
  * launch's `--append-system-prompt`: a settings document can declare a hook,
@@ -18,8 +34,10 @@ export interface GuidanceOutcome {
18
34
  * repository must read exactly as it did before the hook existed.
19
35
  */
20
36
  export function buildSessionGuidance(env: HookEnv, cwd: string = process.cwd()): GuidanceOutcome {
21
- const guidance = buildProjectedGuidance(env, cwd);
22
- if (!guidance) return { exitCode: 0, stdout: "" };
37
+ const companionGuidance = buildProjectedGuidance(env, cwd);
38
+ if (!companionGuidance) return { exitCode: 0, stdout: "" };
39
+ const layer = wrappedAudienceGuidance(cwd);
40
+ const guidance = layer ? `${companionGuidance}\n\n${layer}` : companionGuidance;
23
41
 
24
42
  return {
25
43
  exitCode: 0,
@@ -0,0 +1,101 @@
1
+ /** Prepares a plain Claude session in a Claude-wrapped repository as `mate claude` would. */
2
+ import fs from "node:fs";
3
+
4
+ import { unmanagedClaudeDelivery } from "../runtime/claude-delivery-plan";
5
+ import { hasLaunchEnvironment } from "../runtime/env";
6
+ import { resolveProjection } from "../runtime/projection";
7
+ import { runtimeDirectoryPath, runtimeMateBin } from "../runtime/runtime-directory";
8
+ import {
9
+ PREPARE_TIMEOUT_MS,
10
+ spawnPrepareRunner,
11
+ startDetachedPrepareSync,
12
+ type PrepareRun,
13
+ type PrepareRunner,
14
+ } from "../runtime/session-prepare-runner";
15
+ import type { HookEnv } from "./validate-artifact-path";
16
+
17
+ export interface SessionPrepareHookOutcome {
18
+ exitCode: number;
19
+ stdout: string;
20
+ }
21
+
22
+ export interface SessionPrepareHookDeps {
23
+ /** The runtime-directory launcher; `null` until a `mate` run wrote it. */
24
+ mateBin: () => string | null;
25
+ runner: (mateBin: string, repoPath: string) => PrepareRunner;
26
+ startSync: (mateBin: string, repoPath: string) => void;
27
+ timeoutMs: number;
28
+ }
29
+
30
+ export const sessionPrepareHookDeps: SessionPrepareHookDeps = {
31
+ mateBin: () => {
32
+ const launcher = runtimeMateBin();
33
+ return fs.existsSync(launcher) ? launcher : null;
34
+ },
35
+ runner: spawnPrepareRunner,
36
+ startSync: startDetachedPrepareSync,
37
+ timeoutMs: PREPARE_TIMEOUT_MS,
38
+ };
39
+
40
+ const INERT: SessionPrepareHookOutcome = { exitCode: 0, stdout: "" };
41
+
42
+ /** `systemMessage` only: the model must never be told to repair the companion. */
43
+ function operatorMessage(lines: string[]): SessionPrepareHookOutcome {
44
+ if (lines.length === 0) return INERT;
45
+ return { exitCode: 0, stdout: `${JSON.stringify({ systemMessage: lines.join("\n") })}\n` };
46
+ }
47
+
48
+ async function runLocalPhase(
49
+ deps: SessionPrepareHookDeps,
50
+ mateBin: string,
51
+ projectDir: string,
52
+ ): Promise<PrepareRun> {
53
+ const pending = deps.runner(mateBin, projectDir)({ sync: false, timeoutMs: deps.timeoutMs });
54
+ try {
55
+ return await pending;
56
+ } catch (error) {
57
+ return { ok: false, notes: [error instanceof Error ? error.message : String(error)] };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Inert under a launch environment or where the plan gives it nothing to own.
63
+ * Awaits the local phase within a bound, then starts Git sync detached. Always
64
+ * exits zero: a failed prepare must not block the session.
65
+ */
66
+ export async function runSessionPrepareHook(
67
+ env: HookEnv,
68
+ cwd: string = process.cwd(),
69
+ deps: SessionPrepareHookDeps = sessionPrepareHookDeps,
70
+ ): Promise<SessionPrepareHookOutcome> {
71
+ if (hasLaunchEnvironment(env)) return INERT;
72
+ const projectDir = env.CLAUDE_PROJECT_DIR || cwd;
73
+ const projection = resolveProjection(projectDir);
74
+ if (!projection) return INERT;
75
+ const plan = unmanagedClaudeDelivery(projection.repoRoot);
76
+ const reconciles = plan.reconcile === "prepare-hook";
77
+ const syncs = plan.gitSync === "prepare-hook";
78
+ if (!reconciles && !syncs) return INERT;
79
+
80
+ const mateBin = deps.mateBin();
81
+ if (!mateBin) {
82
+ return operatorMessage([
83
+ `mate: session prepare skipped: ${runtimeDirectoryPath()} is missing; run \`mate\` once to repair it.`,
84
+ ]);
85
+ }
86
+
87
+ const local = reconciles
88
+ ? await runLocalPhase(deps, mateBin, projectDir)
89
+ : { ok: true, notes: [] };
90
+ if (syncs) deps.startSync(mateBin, projectDir);
91
+
92
+ const verdict = local.ok ? "warning" : "failed";
93
+ return operatorMessage(local.notes.map((note) => `mate: session prepare ${verdict}: ${note}`));
94
+ }
95
+
96
+ /** Plugin-shim entry. */
97
+ export async function run(): Promise<number> {
98
+ const outcome = await runSessionPrepareHook(process.env);
99
+ if (outcome.stdout) process.stdout.write(outcome.stdout);
100
+ return outcome.exitCode;
101
+ }
@@ -33,3 +33,16 @@ export async function pathIsDirectory(candidatePath: string): Promise<boolean> {
33
33
  return false;
34
34
  }
35
35
  }
36
+
37
+ /**
38
+ * Real path, so a Windows junction (read back `\\?\`-prefixed) or an aliased
39
+ * spelling compares equal; resolved text when dangling.
40
+ */
41
+ export async function realOrResolved(candidate: string): Promise<string> {
42
+ return fs.realpath(candidate).catch(() => path.resolve(candidate));
43
+ }
44
+
45
+ /** {@link realOrResolved} for a link whose raw `readlink` text is `target`. */
46
+ export async function realLinkTarget(link: string, target: string): Promise<string> {
47
+ return fs.realpath(link).catch(() => path.resolve(path.dirname(link), target));
48
+ }