@uniqbit/mate-core 0.15.5 → 0.16.0-canary.1

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 (186) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +2 -2
  2. package/claude-plugin/hooks/hooks.json +3 -6
  3. package/claude-plugin/hooks/session-guidance.mjs +8 -0
  4. package/claude-plugin/hooks/ts-loader.mjs +19 -0
  5. package/package.json +7 -5
  6. package/src/cli/commands/artifact/artifact.ts +19 -3
  7. package/src/cli/commands/artifact/finish/command.ts +183 -57
  8. package/src/cli/commands/artifact/finish/engine.ts +80 -91
  9. package/src/cli/commands/artifact/finish/finisher.ts +26 -33
  10. package/src/cli/commands/artifact/finish/git.ts +34 -23
  11. package/src/cli/commands/artifact/finish/index.ts +9 -3
  12. package/src/cli/commands/artifact/finish/openspec.ts +225 -97
  13. package/src/cli/commands/artifact/pending/command.ts +175 -0
  14. package/src/cli/commands/artifact/pending/discovery.ts +249 -0
  15. package/src/cli/commands/artifact/pending/index.ts +17 -0
  16. package/src/cli/commands/cap/index-cmd.ts +9 -1
  17. package/src/cli/commands/cap/index.ts +2 -6
  18. package/src/cli/commands/cap/tokensave.ts +4 -4
  19. package/src/cli/commands/companion/companion.ts +5 -1
  20. package/src/cli/commands/companion/link.ts +2 -2
  21. package/src/cli/commands/companion/sync.ts +92 -0
  22. package/src/cli/commands/doctor.ts +0 -3
  23. package/src/cli/commands/launch/shared.ts +23 -5
  24. package/src/cli/commands/report/collector.ts +72 -78
  25. package/src/cli/commands/report/contract.ts +40 -1
  26. package/src/cli/commands/report/highlight.ts +27 -0
  27. package/src/cli/commands/report/index.ts +11 -18
  28. package/src/cli/commands/report/renderer.ts +199 -2
  29. package/src/cli/commands/report/types.ts +26 -1
  30. package/src/cli/commands/shared/companion-selection.ts +107 -10
  31. package/src/cli/commands/studio/areas.ts +68 -0
  32. package/src/cli/commands/studio/index.ts +69 -0
  33. package/src/cli/commands/studio/inventory.ts +55 -0
  34. package/src/cli/commands/studio/mate-inventory.ts +43 -0
  35. package/src/cli/commands/studio/openspec-cli.ts +198 -0
  36. package/src/cli/commands/studio/payload.ts +184 -0
  37. package/src/cli/commands/studio/routes.ts +2 -0
  38. package/src/cli/commands/studio/selection.ts +61 -0
  39. package/src/cli/commands/studio/server.ts +201 -0
  40. package/src/cli/commands/studio/snapshot.ts +63 -0
  41. package/src/cli/commands/studio/topology.ts +199 -0
  42. package/src/cli/commands/studio/views/client.ts +197 -0
  43. package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
  44. package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
  45. package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
  46. package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
  47. package/src/cli/commands/studio/views/document.tsx +256 -0
  48. package/src/cli/commands/studio/views/error.tsx +20 -0
  49. package/src/cli/commands/studio/views/model.ts +34 -0
  50. package/src/cli/commands/studio/views/pairings.tsx +38 -0
  51. package/src/cli/commands/studio/views/skills/index.tsx +87 -0
  52. package/src/cli/commands/studio/views/specs/index.tsx +101 -0
  53. package/src/cli/commands/studio/views/styles.ts +389 -0
  54. package/src/cli/commands/studio/views/warnings.tsx +21 -0
  55. package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
  56. package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
  57. package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
  58. package/src/cli/commands/unwrap.ts +70 -0
  59. package/src/cli/commands/wrap.ts +164 -0
  60. package/src/cli/main.ts +68 -19
  61. package/src/cli/parse-flags.ts +36 -11
  62. package/src/cli/usage.ts +12 -3
  63. package/src/framework.ts +1 -7
  64. package/src/hooks/session-banner.ts +64 -11
  65. package/src/hooks/session-guidance.ts +40 -0
  66. package/src/hooks/validate-artifact-path.ts +108 -35
  67. package/src/lib/fs-utils.ts +9 -0
  68. package/src/lib/install.ts +33 -0
  69. package/src/lib/orchestrator/adapters/base.ts +14 -125
  70. package/src/lib/orchestrator/adapters/claude.ts +0 -11
  71. package/src/lib/orchestrator/adapters/opencode.ts +2 -32
  72. package/src/lib/orchestrator/companion-git-sync.ts +94 -84
  73. package/src/lib/orchestrator/config-store.ts +2 -21
  74. package/src/lib/orchestrator/editor.ts +12 -22
  75. package/src/lib/orchestrator/framework-context.ts +17 -6
  76. package/src/lib/orchestrator/global-config-store.ts +1 -1
  77. package/src/lib/orchestrator/launcher.ts +97 -7
  78. package/src/lib/orchestrator/opencode-guidance.ts +4 -56
  79. package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
  80. package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
  81. package/src/lib/orchestrator/projection-companion-link.ts +62 -0
  82. package/src/lib/orchestrator/projection-entries.ts +377 -0
  83. package/src/lib/orchestrator/projection-record.ts +56 -0
  84. package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
  85. package/src/lib/orchestrator/projection-types.ts +169 -0
  86. package/src/lib/orchestrator/repo-local-registry.ts +37 -133
  87. package/src/lib/orchestrator/repo-local-store.ts +96 -0
  88. package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
  89. package/src/lib/orchestrator/types.ts +1 -0
  90. package/src/lib/orchestrator/working-repo-projection.ts +366 -0
  91. package/src/lib/orchestrator/workspace-inventory.ts +1 -1
  92. package/src/lib/package-paths.ts +11 -1
  93. package/src/lib/public-npm.ts +2 -1
  94. package/src/lib/update-checker.ts +15 -9
  95. package/src/opencode/companion-hooks.ts +89 -245
  96. package/src/opencode/companion-policy.ts +35 -10
  97. package/src/opencode/index.ts +1 -0
  98. package/src/opencode/projected-guidance.ts +56 -0
  99. package/src/opencode/tui.tsx +13 -4
  100. package/src/playbooks/companion-guidance.ts +32 -116
  101. package/src/plugins.ts +0 -1
  102. package/src/runtime/companion-git-state.ts +156 -0
  103. package/src/runtime/companion-git.ts +203 -0
  104. package/src/runtime/companion-guidance.ts +222 -0
  105. package/src/runtime/companion-sync.ts +298 -0
  106. package/src/runtime/env-names.ts +30 -0
  107. package/src/runtime/env.ts +67 -35
  108. package/src/runtime/framework.ts +10 -0
  109. package/src/runtime/freshness.ts +58 -0
  110. package/src/runtime/index.ts +104 -0
  111. package/src/runtime/install.ts +30 -0
  112. package/src/runtime/policy.ts +66 -0
  113. package/src/runtime/projected-guidance.ts +45 -0
  114. package/src/runtime/projection.ts +224 -0
  115. package/src/runtime/repo-local.ts +64 -0
  116. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
  117. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
  118. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
  119. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
  120. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
  121. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
  122. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  123. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
  124. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
  125. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
  126. package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
  127. package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
  128. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
  129. package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  130. package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  131. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
  132. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
  133. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
  134. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +156 -0
  135. package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
  136. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
  137. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
  138. package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
  139. package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
  140. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
  141. package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  142. package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  143. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
  144. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
  145. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
  146. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +156 -0
  147. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
  148. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
  149. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
  150. package/src/templates/report-assets/README.md +32 -0
  151. package/src/templates/report-assets/mermaid.LICENSE +21 -0
  152. package/src/templates/report-assets/mermaid.min.js +4376 -0
  153. package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
  154. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
  155. package/src/tools/setup/capabilities/graphify.ts +16 -7
  156. package/src/tools/setup/capabilities/openspec.ts +63 -56
  157. package/src/tools/setup/capabilities/tokensave.ts +116 -2
  158. package/src/tools/setup/engine.ts +34 -6
  159. package/src/tools/setup/mate.ts +42 -13
  160. package/src/tools/setup/plugin.ts +9 -0
  161. package/src/tools/setup/plugins/guidance.ts +11 -1
  162. package/src/tools/setup/providers/claude-format.ts +49 -4
  163. package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
  164. package/src/tools/setup/providers/claude.ts +55 -220
  165. package/src/tools/setup/providers/opencode.ts +41 -14
  166. package/src/tools/setup/runtime-documents.ts +174 -0
  167. package/src/tools/setup/surface-target.ts +50 -0
  168. package/src/tools/setup/working-repo-cleanup.ts +33 -26
  169. package/src/tools/setup/working-repo-local-state.ts +21 -1
  170. package/src/tools/setup.ts +25 -3
  171. package/wrappers/bin/graphify +57 -8
  172. package/wrappers/bin/openspec +50 -3
  173. package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
  174. package/src/cli/commands/cap/headroom.ts +0 -52
  175. package/src/cli/commands/workspace/list.ts +0 -25
  176. package/src/cli/commands/workspace/materialize.ts +0 -46
  177. package/src/cli/commands/workspace/workspace.ts +0 -22
  178. package/src/hooks/artifact-finish-nudge.ts +0 -244
  179. package/src/lib/orchestrator/headroom/proxy.ts +0 -116
  180. package/src/lib/orchestrator/workspace-materialize.ts +0 -80
  181. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
  182. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
  183. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
  184. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
  185. package/src/tools/setup/capabilities/headroom.ts +0 -57
  186. /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
@@ -12,6 +12,10 @@ import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
14
 
15
+ import { companionForkRefusal } from "../runtime/companion-sync";
16
+ import { readCompanionRuntimeContext } from "../runtime/env";
17
+ import { companionLinkPath } from "../runtime/repo-local";
18
+
15
19
  export type HookEnv = Record<string, string | undefined>;
16
20
 
17
21
  export interface HookOutcome {
@@ -56,13 +60,53 @@ export function artifactLikePath(filePath: string): boolean {
56
60
  return false;
57
61
  }
58
62
 
59
- function repoRoot(env: HookEnv): string {
60
- return env.MATE_REPO_PATH || process.cwd();
63
+ /**
64
+ * Resolved once per evaluation: the companion may come from the launch
65
+ * environment or from the Working Repository's Projection Root, and every
66
+ * path check below is relative to whichever resolved.
67
+ */
68
+ interface GuardContext {
69
+ env: HookEnv;
70
+ /** As resolved, for the operator: the message names the path they configured. */
71
+ companion: string;
72
+ /** Followed through every link, so filesystem identity decides the verdict. */
73
+ companionReal: string;
74
+ companionLink: string;
75
+ repoRoot: string;
76
+ }
77
+
78
+ /**
79
+ * The target's real path, resolved as far as it exists — a path being written
80
+ * does not yet, so the deepest existing ancestor is resolved and the rest
81
+ * appended.
82
+ */
83
+ function realPath(target: string): string {
84
+ const absolute = path.resolve(target);
85
+ const trailing: string[] = [];
86
+ let current = absolute;
87
+ for (;;) {
88
+ try {
89
+ return path.join(fs.realpathSync(current), ...trailing);
90
+ } catch {
91
+ const parent = path.dirname(current);
92
+ if (parent === current) return absolute;
93
+ trailing.unshift(path.basename(current));
94
+ current = parent;
95
+ }
96
+ }
61
97
  }
62
98
 
63
- function companionRoot(env: HookEnv): string | null {
64
- const companion = env.MATE_ARTIFACT_PATH;
65
- return companion ? path.normalize(companion) : null;
99
+ function guardContext(env: HookEnv, cwd: string): GuardContext | null {
100
+ const resolved = readCompanionRuntimeContext(env, cwd);
101
+ if (!resolved.companionPath) return null;
102
+ const repoRoot = resolved.repositoryPath || cwd;
103
+ return {
104
+ env,
105
+ companion: path.normalize(resolved.companionPath),
106
+ companionReal: realPath(resolved.companionPath),
107
+ companionLink: companionLinkPath(repoRoot),
108
+ repoRoot,
109
+ };
66
110
  }
67
111
 
68
112
  // Claude Code's own config/state directory (plan files, settings, todos).
@@ -73,9 +117,9 @@ function claudeConfigRoot(env: HookEnv): string {
73
117
  return path.normalize(path.join(env.HOME || os.homedir(), ".claude"));
74
118
  }
75
119
 
76
- function normalizePath(filePath: string, env: HookEnv): string {
120
+ function normalizePath(filePath: string, ctx: GuardContext): string {
77
121
  if (path.isAbsolute(filePath)) return path.normalize(filePath);
78
- return path.normalize(path.join(repoRoot(env), filePath));
122
+ return path.normalize(path.join(ctx.repoRoot, filePath));
79
123
  }
80
124
 
81
125
  function isUnder(target: string, root: string): boolean {
@@ -83,8 +127,17 @@ function isUnder(target: string, root: string): boolean {
83
127
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
84
128
  }
85
129
 
86
- function isGitignored(target: string, env: HookEnv): boolean {
87
- const root = repoRoot(env);
130
+ /**
131
+ * Under the Projection Root's companion link, or under the companion it lands
132
+ * in — the second answers for a link the operator made, and for the first once
133
+ * its target exists.
134
+ */
135
+ function isCompanionPath(target: string, ctx: GuardContext): boolean {
136
+ return isUnder(target, ctx.companionLink) || isUnder(realPath(target), ctx.companionReal);
137
+ }
138
+
139
+ function isGitignored(target: string, ctx: GuardContext): boolean {
140
+ const root = ctx.repoRoot;
88
141
  if (!isUnder(target, root)) return false;
89
142
 
90
143
  const relPath = path.relative(root, target);
@@ -96,8 +149,8 @@ function isGitignored(target: string, env: HookEnv): boolean {
96
149
 
97
150
  // A file already tracked in the working repo is product code being edited,
98
151
  // not a new agent artifact (e.g. packaged SKILL.md/spec.md template sources).
99
- function isGitTracked(target: string, env: HookEnv): boolean {
100
- const root = repoRoot(env);
152
+ function isGitTracked(target: string, ctx: GuardContext): boolean {
153
+ const root = ctx.repoRoot;
101
154
  if (!isUnder(target, root)) return false;
102
155
 
103
156
  const relPath = path.relative(root, target);
@@ -107,8 +160,8 @@ function isGitTracked(target: string, env: HookEnv): boolean {
107
160
  return result.status === 0;
108
161
  }
109
162
 
110
- function isProductDocumentationPath(target: string, env: HookEnv): boolean {
111
- const root = repoRoot(env);
163
+ function isProductDocumentationPath(target: string, ctx: GuardContext): boolean {
164
+ const root = ctx.repoRoot;
112
165
  if (!isUnder(target, root)) return false;
113
166
 
114
167
  const relPath = path.relative(root, target);
@@ -140,31 +193,45 @@ function blockWrite(target: string, companion: string): HookOutcome {
140
193
 
141
194
  function checkFilePath(
142
195
  filePath: string,
143
- companion: string | null,
144
- env: HookEnv,
196
+ ctx: GuardContext,
145
197
  allowExisting = false,
146
198
  ): HookOutcome | null {
147
- if (!filePath || !companion) return null;
199
+ if (!filePath) return null;
148
200
 
149
- const normalized = normalizePath(filePath, env);
150
- if (normalized.startsWith(companion)) return null;
201
+ const normalized = normalizePath(filePath, ctx);
202
+ if (isCompanionPath(normalized, ctx)) return null;
151
203
 
152
- if (isUnder(normalized, claudeConfigRoot(env))) return null;
204
+ if (isUnder(normalized, claudeConfigRoot(ctx.env))) return null;
153
205
 
154
206
  if (allowExisting && fs.existsSync(normalized) && fs.statSync(normalized).isFile()) return null;
155
207
 
156
- if (isProductDocumentationPath(normalized, env)) return null;
208
+ if (isProductDocumentationPath(normalized, ctx)) return null;
157
209
 
158
210
  if (!artifactLikePath(filePath)) return null;
159
211
 
160
212
  if (
161
- isUnder(normalized, repoRoot(env)) &&
162
- (isGitignored(normalized, env) || isGitTracked(normalized, env))
213
+ isUnder(normalized, ctx.repoRoot) &&
214
+ (isGitignored(normalized, ctx) || isGitTracked(normalized, ctx))
163
215
  ) {
164
216
  return null;
165
217
  }
166
218
 
167
- return blockWrite(filePath, companion);
219
+ return blockWrite(filePath, ctx.companion);
220
+ }
221
+
222
+ /**
223
+ * The fork refusal, unlike the path refusal, is not a function of the tool
224
+ * input alone: it depends on companion Git state, which a Managed Launch may
225
+ * have settled on purpose (`--no-git`, a non-automatic Git policy). So it is
226
+ * gated on the absence of a launch environment, and the path refusal keeps its
227
+ * input-only verdict and its double-load tolerance untouched.
228
+ */
229
+ function checkForkedCompanion(filePath: string, ctx: GuardContext): HookOutcome | null {
230
+ if (!filePath || !artifactLikePath(filePath)) return null;
231
+ if (!isCompanionPath(normalizePath(filePath, ctx), ctx)) return null;
232
+
233
+ const refusal = companionForkRefusal(ctx.env, ctx.companion);
234
+ return refusal ? { exitCode: 2, stderr: `${refusal}\n` } : null;
168
235
  }
169
236
 
170
237
  export function commandMatches(command: string): string[] {
@@ -179,11 +246,15 @@ export function commandMatches(command: string): string[] {
179
246
  return targets;
180
247
  }
181
248
 
182
- export function evaluate(payload: unknown, env: HookEnv): HookOutcome {
249
+ /**
250
+ * Fails open only when no Mate context resolves from either source; in a
251
+ * wrapped Working Repository the guard runs without a launch.
252
+ */
253
+ export function evaluate(payload: unknown, env: HookEnv, cwd: string = process.cwd()): HookOutcome {
183
254
  const allow: HookOutcome = { exitCode: 0, stderr: "" };
184
255
 
185
- const companion = companionRoot(env);
186
- if (!companion) return allow;
256
+ const ctx = guardContext(env, cwd);
257
+ if (!ctx) return allow;
187
258
 
188
259
  const input = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
189
260
  const toolName = typeof input.tool_name === "string" ? input.tool_name : "";
@@ -193,19 +264,21 @@ export function evaluate(payload: unknown, env: HookEnv): HookOutcome {
193
264
  : {};
194
265
 
195
266
  if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") {
196
- const outcome = checkFilePath(
197
- String(toolInput.file_path ?? ""),
198
- companion,
199
- env,
200
- toolName === "Edit" || toolName === "MultiEdit",
201
- );
202
- return outcome ?? allow;
267
+ const filePath = String(toolInput.file_path ?? "");
268
+ /** The path refusal wins when both apply: its message is not replaced. */
269
+ const outcome = checkFilePath(filePath, ctx, toolName === "Edit" || toolName === "MultiEdit");
270
+ return outcome ?? checkForkedCompanion(filePath, ctx) ?? allow;
203
271
  }
204
272
 
205
273
  if (toolName === "Bash") {
206
274
  const command = String(toolInput.command ?? "");
207
- for (const target of commandMatches(command)) {
208
- const outcome = checkFilePath(target, companion, env);
275
+ const targets = commandMatches(command);
276
+ for (const target of targets) {
277
+ const outcome = checkFilePath(target, ctx);
278
+ if (outcome) return outcome;
279
+ }
280
+ for (const target of targets) {
281
+ const outcome = checkForkedCompanion(target, ctx);
209
282
  if (outcome) return outcome;
210
283
  }
211
284
  return allow;
@@ -24,3 +24,12 @@ export async function fileExists(filePath: string): Promise<boolean> {
24
24
  return false;
25
25
  }
26
26
  }
27
+
28
+ export async function pathIsDirectory(candidatePath: string): Promise<boolean> {
29
+ try {
30
+ const stats = await fs.stat(candidatePath);
31
+ return stats.isDirectory();
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
@@ -278,6 +278,15 @@ export interface InstallPreflightResult {
278
278
  plan?: InstallPlan;
279
279
  }
280
280
 
281
+ export function isRepairableInstallPreflight(result: InstallPreflightResult): boolean {
282
+ return Boolean(
283
+ !result.ok &&
284
+ result.plan &&
285
+ result.plan.context.kind !== "ambiguous" &&
286
+ result.plan.requirements.every((requirement) => requirement.satisfied),
287
+ );
288
+ }
289
+
281
290
  export async function inspectInstallPreflight(
282
291
  cwd = process.cwd(),
283
292
  deps: { stateStore?: InstallStateStore; globalConfigStore?: GlobalConfigStore } = {},
@@ -328,3 +337,27 @@ export async function reconcileInstalledCompanion(plan: InstallPlan): Promise<vo
328
337
  const { syncCompanionFiles } = await import("../tools/setup");
329
338
  await syncCompanionFiles(plan.context.companionPath, plan.context.config);
330
339
  }
340
+
341
+ export async function repairInstallState(plan: InstallPlan): Promise<void> {
342
+ const results = plan.requirements.map((requirement) => ({
343
+ id: requirement.id,
344
+ status: "skipped" as const,
345
+ verified: true,
346
+ }));
347
+ await reconcileInstalledCompanion(plan);
348
+
349
+ const store = installStateStoreForContext(plan.context);
350
+ const state = await readInstallState(store);
351
+ const version = getActiveDistribution().config.version;
352
+ if (
353
+ state?.contractRevision === INSTALL_CONTRACT_REVISION &&
354
+ state.mateVersion === version &&
355
+ state.contextFingerprint === plan.context.fingerprint &&
356
+ state.requirementFingerprint === plan.fingerprint &&
357
+ JSON.stringify(state.requirements) === JSON.stringify(results)
358
+ ) {
359
+ return;
360
+ }
361
+
362
+ await saveCompleteInstallState(plan, results, store);
363
+ }
@@ -1,22 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import path from "node:path";
3
3
 
4
- import { version } from "../../../../package.json";
5
- import { isCommandOnPath } from "../../fs-utils";
6
4
  import { FRAMEWORK_NAME } from "../../../framework";
7
- import { getReactDoctorBinPath, getWrapperBinPath } from "../../package-paths";
8
- import {
9
- GRAPHIFY_OUTPUT_SUBDIR,
10
- GRAPHIFY_STORE_SEGMENT,
11
- } from "../../../tools/setup/capabilities/graphify";
12
- import {
13
- type SpawnProxyOpts,
14
- getProxyStderr,
15
- isProxyReachable as defaultIsProxyReachable,
16
- resolveHeadroomPort,
17
- spawnProxyBackground as defaultSpawnProxyBackground,
18
- waitForProxyReachable as defaultWaitForProxyReachable,
19
- } from "../headroom/proxy";
5
+ import { MATE_ENV } from "../../../runtime/env-names";
6
+ import { buildProjection, projectionEnvironment } from "../projection-record";
20
7
  import type { CapabilityConfig, GitModeProfile, LinkedRepository } from "../types";
21
8
 
22
9
  export interface AdapterContext {
@@ -37,13 +24,6 @@ export interface PreparedLaunch {
37
24
  command: string;
38
25
  args: string[];
39
26
  env: NodeJS.ProcessEnv;
40
- warning?: string;
41
- }
42
-
43
- export interface ProxyDeps {
44
- isProxyReachable?: (port: number) => Promise<boolean>;
45
- spawnProxyBackground?: (opts: SpawnProxyOpts) => void;
46
- waitForProxyReachable?: (port: number) => Promise<boolean>;
47
27
  }
48
28
 
49
29
  function prependPathEntry(pathValue: string | undefined, entry: string): string {
@@ -63,63 +43,33 @@ export abstract class LaunchAdapter {
63
43
  return {};
64
44
  }
65
45
 
66
- protected headroomEnv(
67
- _context: AdapterContext,
68
- _port: number,
69
- _companionPath: string,
70
- _env: NodeJS.ProcessEnv,
71
- ): NodeJS.ProcessEnv {
72
- return {};
73
- }
74
-
46
+ /**
47
+ * The projected paths, materialized, plus the predicates the projection
48
+ * refuses to hold. Nothing here derives a path: a managed session and an
49
+ * Unmanaged Session read the same record, so they cannot disagree.
50
+ */
75
51
  environment(context: AdapterContext): NodeJS.ProcessEnv {
76
52
  const reactDoctorEnabled = context.capabilities.some((c) => c.name === "react-doctor");
77
- const wrapperBinPath = getWrapperBinPath();
53
+ const projection = buildProjection(context.companionPath, context.repository);
78
54
  const env: NodeJS.ProcessEnv = {
79
55
  ...process.env,
56
+ ...projectionEnvironment(projection),
80
57
  MATE_NAME: FRAMEWORK_NAME,
81
- MATE_VERSION: version,
82
- MATE_ARTIFACT_PATH: context.companionPath,
83
- MATE_WRAPPER_BIN_PATH: wrapperBinPath,
84
- PATH: prependPathEntry(process.env.PATH, wrapperBinPath),
58
+ PATH: prependPathEntry(process.env.PATH, projection.wrapperBinPath),
85
59
  MATE_GRAPHIFY_ENABLED: context.capabilities.some((c) => c.name === "graphify") ? "1" : "0",
86
60
  MATE_OPENSPEC_ENABLED: context.capabilities.some((c) => c.name === "openspec") ? "1" : "0",
87
61
  MATE_REACT_DOCTOR_ENABLED: reactDoctorEnabled ? "1" : "0",
88
62
  MATE_GIT_AUTO_MODE: context.git === "auto" ? "1" : "0",
89
- MATE_REPO_ID: context.repository.id,
90
- MATE_REPO_PATH: context.repository.path,
91
63
  MATE_POLICY_JSON: JSON.stringify({ allowedAgents: context.allowedAgents }),
92
64
  };
93
65
 
94
- if (reactDoctorEnabled) {
95
- env.MATE_REACT_DOCTOR_BIN_PATH = getReactDoctorBinPath();
96
- } else {
97
- delete env.MATE_REACT_DOCTOR_BIN_PATH;
98
- }
99
-
100
- // Route launch-session graphify output to the companion store. graphifyy resolves its
101
- // output dir from GRAPHIFY_OUT at import time (graphify/paths.py) and every
102
- // reader honours it, so injecting it into the launch session env keeps the
103
- // skill's per-shell `$GRAPHIFY_OUT` references and graphifyy's own defaults
104
- // pointed at the companion store instead of leaking graphify-out/ into the
105
- // working repo. Absolute value, matching the wrapper and deriveGraphifyPaths contract.
106
- if (context.capabilities.some((c) => c.name === "graphify")) {
107
- env.GRAPHIFY_OUT = path.join(
108
- context.companionPath,
109
- GRAPHIFY_STORE_SEGMENT,
110
- context.repository.id,
111
- GRAPHIFY_OUTPUT_SUBDIR,
112
- );
113
- }
66
+ /** The one field where a launch still narrows the projection rather than materializing it. */
67
+ if (!reactDoctorEnabled) delete env[MATE_ENV.reactDoctorBinPath];
114
68
 
115
69
  return env;
116
70
  }
117
71
 
118
- async prepareLaunch(
119
- context: AdapterContext,
120
- args: string[],
121
- proxyDeps: ProxyDeps = {},
122
- ): Promise<PreparedLaunch> {
72
+ async prepareLaunch(context: AdapterContext, args: string[]): Promise<PreparedLaunch> {
123
73
  const builtArgs = this.buildArgs(context, args);
124
74
  const baseEnv = this.environment(context);
125
75
  const extendedEnv = this.extendEnvironment(context);
@@ -134,72 +84,11 @@ export abstract class LaunchAdapter {
134
84
  }
135
85
  }
136
86
 
137
- // Headroom behaves like any other capability: when it is enabled the launch
138
- // is routed through the proxy, with no per-launch opt-in. It only falls back
139
- // to a direct launch if the `headroom` binary is missing or the proxy fails
140
- // to become ready (handled below).
141
- const headroomCap = context.capabilities.find((c) => c.name === "headroom");
142
- if (!headroomCap) {
143
- return { command: this.toolName, args: builtArgs, env };
144
- }
145
-
146
- if (!isCommandOnPath("headroom", env.PATH ?? process.env.PATH ?? "")) {
147
- return {
148
- command: this.toolName,
149
- args: builtArgs,
150
- env,
151
- warning: `${FRAMEWORK_NAME}: headroom capability enabled but \`headroom\` was not found on PATH; install with \`uv tool install "headroom-ai[all]"\`; launching ${this.toolName} directly\n`,
152
- };
153
- }
154
-
155
- const probeProxy = proxyDeps.isProxyReachable ?? defaultIsProxyReachable;
156
- const spawnProxy = proxyDeps.spawnProxyBackground ?? defaultSpawnProxyBackground;
157
- const waitForProxy = proxyDeps.waitForProxyReachable ?? defaultWaitForProxyReachable;
158
-
159
- const port = resolveHeadroomPort();
160
- const reachable = await probeProxy(port);
161
- if (!reachable) {
162
- const maxProxyStartAttempts = 3;
163
- let ready = false;
164
-
165
- for (let attempt = 1; attempt <= maxProxyStartAttempts; attempt += 1) {
166
- spawnProxy({ port });
167
- if (await waitForProxy(port)) {
168
- ready = true;
169
- break;
170
- }
171
- }
172
-
173
- if (!ready) {
174
- const proxyErr = getProxyStderr(port).trim();
175
- const errorDetail = proxyErr
176
- ? `\nProxy error:\n${proxyErr
177
- .split("\n")
178
- .map((line) => ` ${line}`)
179
- .join("\n")}`
180
- : "";
181
- return {
182
- command: this.toolName,
183
- args: builtArgs,
184
- env,
185
- warning: `${FRAMEWORK_NAME}: headroom proxy failed to become ready on port ${port} after ${maxProxyStartAttempts} attempts; launching ${this.toolName} directly${errorDetail}\n`,
186
- };
187
- }
188
- }
189
-
190
- return {
191
- command: this.toolName,
192
- args: builtArgs,
193
- env: {
194
- ...env,
195
- ...this.headroomEnv(context, port, context.companionPath, env),
196
- },
197
- };
87
+ return { command: this.toolName, args: builtArgs, env };
198
88
  }
199
89
 
200
90
  async run(context: AdapterContext, args: string[]): Promise<AdapterResult> {
201
91
  const launch = await this.prepareLaunch(context, args);
202
- if (launch.warning) process.stderr.write(launch.warning);
203
92
 
204
93
  return new Promise((resolve, reject) => {
205
94
  const child = spawn(launch.command, launch.args, {
@@ -82,15 +82,4 @@ export class ClaudeAdapter extends LaunchAdapter {
82
82
  CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1",
83
83
  };
84
84
  }
85
-
86
- protected headroomEnv(
87
- context: AdapterContext,
88
- port: number,
89
- _companionPath: string,
90
- ): NodeJS.ProcessEnv {
91
- const project = encodeURIComponent(context.repository.id);
92
- return {
93
- ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/p/${project}`,
94
- };
95
- }
96
85
  }
@@ -14,6 +14,7 @@ import {
14
14
  mergeOpenCodeConfigContent,
15
15
  readOpenCodeConfig,
16
16
  } from "../../../tools/setup/providers/opencode-format";
17
+ import { renderCompanionExternalDirectoryPermissions } from "../../../tools/setup/providers/opencode";
17
18
  import { buildOpenCodeGuidance } from "../opencode-guidance";
18
19
  import { LaunchPreflightError } from "../types";
19
20
  import { type AdapterContext, LaunchAdapter } from "./base";
@@ -38,10 +39,7 @@ export class OpenCodeAdapter extends LaunchAdapter {
38
39
  OPENCODE_CONFIG_CONTENT: mergeOpenCodeConfigContent(
39
40
  {
40
41
  permission: {
41
- external_directory: {
42
- [context.companionPath]: "allow",
43
- [`${context.companionPath}/**`]: "allow",
44
- },
42
+ external_directory: renderCompanionExternalDirectoryPermissions(context.companionPath),
45
43
  },
46
44
  references: {
47
45
  mate: context.companionPath,
@@ -60,34 +58,6 @@ export class OpenCodeAdapter extends LaunchAdapter {
60
58
  return buildOpenCodeGuidance(context.capabilities);
61
59
  }
62
60
 
63
- protected headroomEnv(
64
- context: AdapterContext,
65
- port: number,
66
- _companionPath: string,
67
- env: NodeJS.ProcessEnv,
68
- ): NodeJS.ProcessEnv {
69
- const project = encodeURIComponent(context.repository.id);
70
- const baseUrl = `http://127.0.0.1:${port}/p/${project}`;
71
- const configContent = mergeOpenCodeConfigContent(
72
- {
73
- provider: {
74
- anthropic: {
75
- options: { baseURL: baseUrl },
76
- },
77
- openai: {
78
- options: { baseURL: `${baseUrl}/v1` },
79
- },
80
- },
81
- },
82
- env,
83
- );
84
- return {
85
- ANTHROPIC_BASE_URL: baseUrl,
86
- OPENAI_BASE_URL: `${baseUrl}/v1`,
87
- OPENCODE_CONFIG_CONTENT: configContent,
88
- };
89
- }
90
-
91
61
  async validateLaunch(context: AdapterContext): Promise<void> {
92
62
  const missingAssets = await Promise.all(
93
63
  this.requiredRuntimeAssets.map(async (asset) => {