@uniqbit/mate-core 0.18.3-canary.1 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) 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/companion.ts +47 -12
  5. package/opencode-plugin/src/server.ts +39 -4
  6. package/package.json +3 -3
  7. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  8. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  9. package/src/cli/commands/companion/link.ts +16 -11
  10. package/src/cli/commands/companion/list.ts +4 -5
  11. package/src/cli/commands/companion/open.ts +4 -5
  12. package/src/cli/commands/companion/tui.ts +1 -1
  13. package/src/cli/commands/doctor.ts +1 -1
  14. package/src/cli/commands/report/renderer.ts +9 -19
  15. package/src/cli/commands/session/prepare.ts +119 -0
  16. package/src/cli/commands/shared/companion-selection.ts +2 -2
  17. package/src/cli/commands/studio/reports.ts +0 -1
  18. package/src/cli/commands/studio/server.ts +6 -2
  19. package/src/cli/commands/unwrap.ts +28 -3
  20. package/src/cli/commands/wrap.ts +53 -59
  21. package/src/cli/companion-selector.tsx +1 -1
  22. package/src/cli/main.ts +17 -0
  23. package/src/cli/usage.ts +3 -1
  24. package/src/hooks/session-banner.ts +19 -10
  25. package/src/hooks/session-prepare.ts +101 -0
  26. package/src/lib/fs-utils.ts +13 -0
  27. package/src/lib/orchestrator/adapters/base.ts +43 -8
  28. package/src/lib/orchestrator/adapters/claude.ts +130 -29
  29. package/src/lib/orchestrator/adapters/opencode.ts +17 -19
  30. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  31. package/src/lib/orchestrator/companion-hub.ts +2 -1
  32. package/src/lib/orchestrator/config-store.ts +9 -0
  33. package/src/lib/orchestrator/framework-context.ts +5 -29
  34. package/src/lib/orchestrator/launcher.ts +36 -171
  35. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  36. package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
  37. package/src/lib/orchestrator/projection-entries.ts +78 -11
  38. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  39. package/src/lib/orchestrator/projection-record.ts +7 -1
  40. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  41. package/src/lib/orchestrator/projection-types.ts +38 -33
  42. package/src/lib/orchestrator/repository-link.ts +473 -0
  43. package/src/lib/orchestrator/root-context.ts +6 -2
  44. package/src/lib/orchestrator/session-prepare.ts +425 -0
  45. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  46. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  47. package/src/lib/orchestrator/working-repo-projection.ts +71 -159
  48. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  49. package/src/lib/orchestrator/wrap-pass.ts +131 -0
  50. package/src/lib/runtime-directory-refresh.ts +186 -0
  51. package/src/lib/update-checker.ts +6 -0
  52. package/src/opencode/companion-hooks.ts +24 -132
  53. package/src/opencode/companion-policy.ts +26 -10
  54. package/src/opencode/index.ts +1 -7
  55. package/src/opencode/session-notes.ts +76 -0
  56. package/src/opencode/session-prepare.ts +139 -0
  57. package/src/opencode/tui.tsx +56 -9
  58. package/src/runtime/claude-delivery-plan.ts +73 -0
  59. package/src/runtime/claude-hook-group.ts +21 -0
  60. package/src/runtime/companion-lock.ts +121 -0
  61. package/src/runtime/companion-sync.ts +66 -9
  62. package/src/runtime/runtime-directory.ts +43 -0
  63. package/src/runtime/runtime-document-manifest.ts +335 -0
  64. package/src/runtime/session-prepare-runner.ts +148 -0
  65. package/src/runtime/wrap-state.ts +90 -0
  66. package/src/runtime/write-atomic.ts +27 -0
  67. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  68. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  69. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  70. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  71. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  72. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  73. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  74. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  75. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  76. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  77. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  78. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
  79. package/src/tools/setup/engine.ts +15 -4
  80. package/src/tools/setup/mate.ts +1 -1
  81. package/src/tools/setup/providers/claude-format.ts +11 -18
  82. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  83. package/src/tools/setup/providers/claude.ts +23 -6
  84. package/src/tools/setup/runtime-documents.ts +152 -32
  85. package/src/tools/setup/working-repo-local-state.ts +82 -13
  86. package/src/tools/setup.ts +2 -2
  87. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  88. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  89. package/src/lib/orchestrator/companion-store.ts +0 -94
  90. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  91. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  92. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  93. package/src/templates/report-assets/README.md +0 -32
  94. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  95. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -1,22 +1,19 @@
1
- import { execFile } from "node:child_process";
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
- import { promisify } from "node:util";
5
3
 
6
4
  import { FRAMEWORK_NAME } from "../../framework";
7
- import { gitEnvironment } from "../../runtime/companion-git";
8
5
  import { readProjectionFile } from "../../runtime/projection";
6
+ import { GlobalConfigStore } from "./global-config-store";
9
7
  import { repoLocalDirPath } from "../../runtime/repo-local";
10
- import type { GlobalConfigStore } from "./global-config-store";
11
8
  import { projectionEntries } from "./projection-entries";
12
9
  import {
13
- isExternalDocument,
14
- recordedRuntimeDocuments,
15
- removeRuntimeDocuments,
16
- } from "./projection-runtime-documents";
10
+ copies as recordedCopies,
11
+ documents as recordedDocuments,
12
+ readManifest,
13
+ } from "../../runtime/runtime-document-manifest";
14
+ import { removeRuntimeDocuments } from "./projection-runtime-documents";
17
15
  import {
18
16
  firstFailure,
19
- type RenderedRuntimeDocument,
20
17
  type ProjectionDescription,
21
18
  type ProjectionEntry,
22
19
  type ProjectionEntryOutcome,
@@ -26,10 +23,10 @@ import {
26
23
  type ProjectionRemovalResult,
27
24
  type ProjectionResult,
28
25
  type ProjectionScope,
26
+ type ProjectionWriteReport,
27
+ type ProjectionWriteState,
29
28
  } from "./projection-types";
30
- import type { FrameworkConfig, LinkedRepository } from "./types";
31
-
32
- const execFileAsync = promisify(execFile);
29
+ import type { LinkedRepository } from "./types";
33
30
 
34
31
  /**
35
32
  * The owner of the Managed Projection: every path Mate places inside a Working
@@ -60,6 +57,22 @@ function outcome(
60
57
  state,
61
58
  ...(error ? { error } : {}),
62
59
  ...(entry.degradable ? { degradable: true } : {}),
60
+ ...(entry.runtime ? { runtime: entry.runtime } : {}),
61
+ };
62
+ }
63
+
64
+ /** One outcome shape for both forms a write may return; empty reports are omitted. */
65
+ function writeOutcome(
66
+ entry: ProjectionEntry,
67
+ written: ProjectionWriteState | ProjectionWriteReport,
68
+ ): ProjectionEntryOutcome {
69
+ if (typeof written === "string") return outcome(entry, written);
70
+ const { state, warnings = [], changed } = written;
71
+ const anyChanged = changed && (changed.written.length > 0 || changed.removed.length > 0);
72
+ return {
73
+ ...outcome(entry, state),
74
+ ...(warnings.length > 0 ? { warnings } : {}),
75
+ ...(anyChanged ? { changed } : {}),
63
76
  };
64
77
  }
65
78
 
@@ -74,11 +87,13 @@ export async function project(
74
87
  input: ProjectionInput,
75
88
  ): Promise<ProjectionResult> {
76
89
  const outcomes: ProjectionEntryOutcome[] = [];
90
+ const runtimes = input.runtimes ? new Set(input.runtimes) : null;
77
91
  for (const entry of projectionEntries()) {
78
92
  if (!entry.scopes.includes(scope)) continue;
93
+ if (entry.runtime && runtimes && !runtimes.has(entry.runtime)) continue;
79
94
  try {
80
95
  // oxlint-disable-next-line no-await-in-loop -- catalogue order is the contract
81
- outcomes.push(outcome(entry, await entry.write(input)));
96
+ outcomes.push(writeOutcome(entry, await entry.write(input))); // react-doctor-disable-line react-doctor/async-await-in-loop
82
97
  } catch (error) {
83
98
  outcomes.push(outcome(entry, "failed", toError(error)));
84
99
  }
@@ -93,7 +108,7 @@ export async function project(
93
108
  */
94
109
  export async function unproject(input: ProjectionRemovalInput): Promise<ProjectionRemovalResult> {
95
110
  const outcomes: ProjectionEntryOutcome[] = [];
96
- for (const entry of [...projectionEntries()].reverse()) {
111
+ for (const entry of projectionEntries().toReversed()) {
97
112
  const { removal } = entry;
98
113
  if (removal.by === "entry") {
99
114
  outcomes.push(outcome(entry, "covered"));
@@ -113,39 +128,42 @@ export async function unproject(input: ProjectionRemovalInput): Promise<Projecti
113
128
  return { root: repoLocalDirPath(input.repoPath), outcomes };
114
129
  }
115
130
 
116
- /** Removes entries that wrap owns but an unwrap must withdraw independently. */
131
+ /**
132
+ * Removes entries that wrap owns but an unwrap must withdraw independently.
133
+ * Sequential: several of them rewrite the manifest, and concurrent rewrites
134
+ * would restore each other's withdrawn records.
135
+ */
117
136
  async function removeWorkingRepositoryUnwrapEntries(
118
137
  repoPath: string,
138
+ registeredCompanionPaths: string[],
119
139
  ): Promise<ProjectionEntryOutcome[]> {
120
- const entries = projectionEntries().filter(
121
- (entry) => entry.removeOnUnwrap && entry.removal.by === "self",
122
- );
123
- return Promise.all(
124
- entries.map(async (entry) => {
125
- const removal = entry.removal;
126
- if (removal.by !== "self") return outcome(entry, "failed", new Error("invalid removal"));
127
- try {
128
- return outcome(entry, await removal.remove({ repoPath }));
129
- } catch (error) {
130
- return outcome(entry, "failed", toError(error));
131
- }
132
- }),
133
- );
140
+ const outcomes: ProjectionEntryOutcome[] = [];
141
+ for (const entry of projectionEntries()) {
142
+ const removal = entry.removal;
143
+ if (!entry.removeOnUnwrap || removal.by !== "self") continue;
144
+ try {
145
+ // oxlint-disable-next-line no-await-in-loop -- each removal rewrites the manifest
146
+ outcomes.push(outcome(entry, await removal.remove({ repoPath, registeredCompanionPaths }))); // react-doctor-disable-line react-doctor/async-await-in-loop
147
+ } catch (error) {
148
+ outcomes.push(outcome(entry, "failed", toError(error)));
149
+ }
150
+ }
151
+ return outcomes;
134
152
  }
135
153
 
136
154
  /** Reads and reports; writes nothing. Freshness is a verdict above this module. */
137
155
  export async function describe(repoPath: string): Promise<ProjectionDescription> {
138
- const entries: ProjectionEntryPresence[] = [];
139
- for (const entry of projectionEntries()) {
140
- const present = entry.present
141
- ? // oxlint-disable-next-line no-await-in-loop -- reported in catalogue order
142
- await entry.present(repoPath)
143
- : await fs
144
- .access(path.join(path.resolve(repoPath), entry.path))
145
- .then(() => true)
146
- .catch(() => false);
147
- entries.push({ id: entry.id, path: entry.path, kind: entry.kind, present });
148
- }
156
+ const entries: ProjectionEntryPresence[] = await Promise.all(
157
+ projectionEntries().map(async (entry) => {
158
+ const present = await (entry.present
159
+ ? entry.present(repoPath)
160
+ : fs
161
+ .access(path.join(path.resolve(repoPath), entry.path))
162
+ .then(() => true)
163
+ .catch(() => false));
164
+ return { id: entry.id, path: entry.path, kind: entry.kind, present };
165
+ }),
166
+ );
149
167
  return {
150
168
  root: repoLocalDirPath(repoPath),
151
169
  entries,
@@ -183,123 +201,12 @@ export async function projectWorkingRepository(
183
201
  };
184
202
  }
185
203
 
186
- /**
187
- * The rendered runtime documents, placed. Reported per document because the
188
- * operator asked for these by name: a failure has to say which one failed while
189
- * the projection already written stays where it is.
190
- */
191
- export type RuntimeDocumentsResult =
192
- | { kind: "written" | "current"; documents: string[] }
193
- | { kind: "failed"; document: string; error: Error };
194
-
195
- /**
196
- * Which of the given repo-relative paths Git already has in its index.
197
- * Best-effort by construction: a directory that is no repository, a Git that is
198
- * not installed, and a `ls-files` that fails for any other reason all answer
199
- * "nothing tracked" — this feeds a warning, and a warning may not fail a wrap.
200
- */
201
- async function trackedDocuments(repoPath: string, documentPaths: string[]): Promise<string[]> {
202
- if (documentPaths.length === 0) return [];
203
- try {
204
- const { stdout } = await execFileAsync("git", ["ls-files", "-z", "--", ...documentPaths], {
205
- cwd: path.resolve(repoPath),
206
- encoding: "utf8",
207
- env: gitEnvironment(),
208
- maxBuffer: 64 * 1024 * 1024,
209
- });
210
- return stdout.split("\0").filter(Boolean);
211
- } catch {
212
- return [];
213
- }
214
- }
215
-
216
- /**
217
- * The managed exclude block only hides a file Git does not already track, so a
218
- * repository that commits one of these documents keeps it in the index and the
219
- * regions land in a tracked file. Those regions carry machine-absolute
220
- * companion paths — keys naming directories under this user's home — so a
221
- * routine `git add -A` pushes one machine's layout to everyone else. Warn and
222
- * write anyway: refusing would silently drop the projection the repository was
223
- * wrapped for.
224
- *
225
- * Asked here rather than while rendering because the render also runs on every
226
- * launch; the wrap pass is the one that is a deliberate act, so this is the
227
- * scope where saying it once is information rather than noise.
228
- */
229
- async function warnAboutTrackedDocuments(
230
- repository: LinkedRepository,
231
- runtimeDocuments: readonly RenderedRuntimeDocument[],
232
- ): Promise<void> {
233
- const tracked = await trackedDocuments(
234
- repository.path,
235
- runtimeDocuments
236
- .map((document) => document.path)
237
- .filter((documentPath) => !isExternalDocument(documentPath)),
238
- );
239
- for (const documentPath of tracked) {
240
- console.error(
241
- `${FRAMEWORK_NAME}: warning: ${documentPath} is tracked by Git in ${repository.id}; ${FRAMEWORK_NAME} writes machine-absolute companion paths into it, so committing it would push this machine's paths to everyone else on the repository.`,
242
- );
243
- console.error(
244
- `Untrack it with \`git rm --cached ${documentPath}\` if those paths should stay local.`,
245
- );
246
- }
247
- }
248
-
249
- export async function projectWorkingRuntimeDocuments(
250
- companionPath: string,
251
- repository: LinkedRepository,
252
- config: FrameworkConfig,
253
- runtimeDocuments: readonly RenderedRuntimeDocument[],
254
- globalConfigStore?: GlobalConfigStore,
255
- ): Promise<RuntimeDocumentsResult> {
256
- await warnAboutTrackedDocuments(repository, runtimeDocuments);
257
- const result = await project("wrap", {
258
- repoPath: repository.path,
259
- companionPath: path.resolve(companionPath),
260
- repository,
261
- config,
262
- runtimeDocuments,
263
- globalConfigStore,
264
- });
265
-
266
- const failure = firstFailure(result.outcomes);
267
- if (failure) {
268
- return {
269
- kind: "failed",
270
- document: failure.path,
271
- error: failure.error ?? new Error("runtime document write failed"),
272
- };
273
- }
274
-
275
- /** The destinations the caller handed over; the rest of the pass is scaffolding. */
276
- const destinations = new Set(
277
- runtimeDocuments.map((document) => document.path.split("/").join(path.sep)),
278
- );
279
- const outcomes = result.outcomes.filter((outcome) => destinations.has(outcome.path));
280
- return {
281
- kind: outcomes.some((outcome) => outcome.state === "written") ? "written" : "current",
282
- documents: [...new Set(outcomes.map((outcome) => outcome.path))],
283
- };
284
- }
285
-
286
- /**
287
- * Whether `mate wrap` configured this Working Repository. Asked of the runtime
288
- * document manifest rather than of the Projection Root, because the root is not
289
- * evidence of a wrap: a launch and every Capability command write the
290
- * projection pair too. The manifest is written only by a pass that placed a
291
- * runtime document, and wrapping is the only pass that places one.
292
- */
293
- export async function isWorkingRepositoryWrapped(repoPath: string): Promise<boolean> {
294
- return (await recordedRuntimeDocuments(repoPath)).length > 0;
295
- }
296
-
297
204
  export type UnwrapResult =
298
- | { kind: "unwrapped" | "absent"; documents: string[] }
205
+ | { kind: "unwrapped" | "absent"; documents: string[]; copies: string[] }
299
206
  | { kind: "failed"; document: string; error: Error };
300
207
 
301
208
  /**
302
- * The inverse of {@link projectWorkingRuntimeDocuments}: runtime documents and
209
+ * The inverse of a wrap's runtime documents: runtime documents and
303
210
  * projected Claude skill links are withdrawn while the Projection Root, the
304
211
  * Repository Link and the companion link are left exactly as they were. That
305
212
  * asymmetry with `mate working cleanup` is the point — cleanup removes Mate's
@@ -311,10 +218,15 @@ export type UnwrapResult =
311
218
  * Driven by the manifest, so it withdraws whatever the wrap that ran recorded,
312
219
  * including a destination this release no longer renders.
313
220
  */
314
- export async function unwrapWorkingRuntimeDocuments(repoPath: string): Promise<UnwrapResult> {
315
- const documents = await recordedRuntimeDocuments(repoPath);
221
+ export async function unwrapWorkingRuntimeDocuments(
222
+ repoPath: string,
223
+ globalConfigStore: GlobalConfigStore = new GlobalConfigStore(),
224
+ ): Promise<UnwrapResult> {
225
+ const manifest = await readManifest(repoPath);
226
+ const documents = recordedDocuments(manifest);
227
+ const copies = Object.keys(recordedCopies(manifest));
316
228
  const removeWrapEntries = async (): Promise<ProjectionEntryOutcome[]> =>
317
- removeWorkingRepositoryUnwrapEntries(repoPath);
229
+ removeWorkingRepositoryUnwrapEntries(repoPath, await globalConfigStore.list());
318
230
  if (documents.length === 0) {
319
231
  const outcomes = await removeWrapEntries();
320
232
  const failure = firstFailure(outcomes);
@@ -326,8 +238,8 @@ export async function unwrapWorkingRuntimeDocuments(repoPath: string): Promise<U
326
238
  };
327
239
  }
328
240
  return outcomes.some((entry) => entry.state === "removed")
329
- ? { kind: "unwrapped", documents: [] }
330
- : { kind: "absent", documents: [] };
241
+ ? { kind: "unwrapped", documents: [], copies }
242
+ : { kind: "absent", documents: [], copies: [] };
331
243
  }
332
244
 
333
245
  /**
@@ -349,7 +261,7 @@ export async function unwrapWorkingRuntimeDocuments(repoPath: string): Promise<U
349
261
  error: failure.error ?? new Error("wrap entry removal failed"),
350
262
  };
351
263
  }
352
- return { kind: "unwrapped", documents: removed };
264
+ return { kind: "unwrapped", documents: removed, copies };
353
265
  }
354
266
 
355
267
  /**
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
 
3
- import { pathIsDirectory } from "./repo-local-registry";
4
- import { readCompanionRegistry } from "./companion-registry-reader";
3
+ import { pathIsDirectory } from "../fs-utils";
4
+ import { readCompanionRegistry } from "./repository-link";
5
5
  import { GlobalConfigStore } from "./global-config-store";
6
6
  import type { LinkedRepository } from "./types";
7
7
 
@@ -0,0 +1,131 @@
1
+ import path from "node:path";
2
+
3
+ import { renderWorkingRuntimeDocuments } from "../../tools/setup";
4
+ import { readCompanionConfig } from "./config-store";
5
+ import { GlobalConfigStore } from "./global-config-store";
6
+ import { entryFailureMessage, firstFailure, type ProjectionChangedPaths } from "./projection-types";
7
+ import type { WrapRuntime } from "../../runtime/runtime-document-manifest";
8
+ import { readWrapState, type WrapState } from "../../runtime/wrap-state";
9
+ import type { FrameworkConfig, LinkedRepository } from "./types";
10
+ import { project } from "./working-repo-projection";
11
+
12
+ export interface WrapPassDependencies {
13
+ renderWorkingRuntimeDocuments: typeof renderWorkingRuntimeDocuments;
14
+ readCompanionConfig: typeof readCompanionConfig;
15
+ project: typeof project;
16
+ /** The registered companions, whose stale skill links a re-pin withdraws; the user's registry when absent. */
17
+ globalConfigStore?: GlobalConfigStore;
18
+ }
19
+
20
+ export const wrapPassDeps: WrapPassDependencies = {
21
+ renderWorkingRuntimeDocuments,
22
+ readCompanionConfig,
23
+ project,
24
+ };
25
+
26
+ export interface WrapPassOptions {
27
+ /** Reconcile only these runtimes; every active runtime when absent. */
28
+ runtimes?: readonly WrapRuntime[];
29
+ /** The companion config the caller already read; read from files otherwise. */
30
+ config?: FrameworkConfig;
31
+ }
32
+
33
+ export type WrapPassResult =
34
+ | {
35
+ kind: "written" | "current";
36
+ projectionRoot: string;
37
+ companionPath: string;
38
+ /** The runtime documents this pass answered for, written or current. */
39
+ documents: string[];
40
+ /** The subset of `documents` this pass wrote. */
41
+ writtenDocuments: string[];
42
+ /** Every path an entry reported writing or withdrawing one by one. */
43
+ changed: ProjectionChangedPaths;
44
+ /** The render's warnings, then every entry's, for the caller to present. */
45
+ warnings: string[];
46
+ /** Active runtimes whose tagged entries this pass ran; never inferred from paths. */
47
+ runtimes: ReadonlySet<WrapRuntime>;
48
+ /** The wrap state as this pass left it. */
49
+ wrapState: WrapState;
50
+ }
51
+ | { kind: "failed"; message: string };
52
+
53
+ /**
54
+ * One pass of the `wrap` scope, restricted to the requested runtimes by each
55
+ * entry's runtime tag. Best-effort per entry like every projection: a failed
56
+ * entry fails the pass while whatever the other entries wrote stays in place.
57
+ */
58
+ export async function runWrapPass(
59
+ companionPath: string,
60
+ repository: LinkedRepository,
61
+ options: WrapPassOptions = {},
62
+ deps: WrapPassDependencies = wrapPassDeps,
63
+ ): Promise<WrapPassResult> {
64
+ const resolvedCompanionPath = path.resolve(companionPath);
65
+ const config = options.config ?? (await deps.readCompanionConfig(resolvedCompanionPath));
66
+ const rendered = await deps.renderWorkingRuntimeDocuments(
67
+ resolvedCompanionPath,
68
+ config,
69
+ repository.path,
70
+ );
71
+ const result = await deps.project("wrap", {
72
+ repoPath: repository.path,
73
+ companionPath: resolvedCompanionPath,
74
+ repository,
75
+ config,
76
+ runtimeDocuments: rendered.documents,
77
+ globalConfigStore: deps.globalConfigStore ?? new GlobalConfigStore(),
78
+ ...(options.runtimes ? { runtimes: options.runtimes } : {}),
79
+ });
80
+
81
+ const failure = firstFailure(result.outcomes);
82
+ if (failure) return { kind: "failed", message: entryFailureMessage(failure, repository.id) };
83
+
84
+ /** Read once under the caller's lock, so it is exactly what the pass recorded. */
85
+ let wrapState: WrapState;
86
+ try {
87
+ wrapState = await readWrapState(repository.path);
88
+ } catch (error) {
89
+ return {
90
+ kind: "failed",
91
+ message: `could not read the runtime document manifest for ${repository.id}: ${(error as Error).message}`,
92
+ };
93
+ }
94
+ /** Inactive runtimes' entries run too, to withdraw; that is not covering them. */
95
+ const active = new Set(config.allowedAgents ?? []);
96
+ const runtimes = new Set(
97
+ result.outcomes.flatMap((outcome) =>
98
+ outcome.runtime && outcome.state !== "skipped" && active.has(outcome.runtime)
99
+ ? [outcome.runtime]
100
+ : [],
101
+ ),
102
+ );
103
+
104
+ /** The destinations the surfaces rendered; the rest of the pass is scaffolding. */
105
+ const destinations = new Set(
106
+ rendered.documents.map((document) => document.path.split("/").join(path.sep)),
107
+ );
108
+ const placed = result.outcomes.filter((outcome) => destinations.has(outcome.path));
109
+ const documents = placed.map((outcome) => outcome.path);
110
+ const writtenDocuments = placed
111
+ .filter((outcome) => outcome.state === "written")
112
+ .map((outcome) => outcome.path);
113
+
114
+ return {
115
+ kind: result.outcomes.some((outcome) => outcome.state === "written") ? "written" : "current",
116
+ projectionRoot: result.root,
117
+ companionPath: resolvedCompanionPath,
118
+ documents: [...new Set(documents)],
119
+ writtenDocuments: [...new Set(writtenDocuments)],
120
+ changed: {
121
+ written: result.outcomes.flatMap((outcome) => outcome.changed?.written ?? []),
122
+ removed: result.outcomes.flatMap((outcome) => outcome.changed?.removed ?? []),
123
+ },
124
+ warnings: [
125
+ ...rendered.warnings,
126
+ ...result.outcomes.flatMap((outcome) => outcome.warnings ?? []),
127
+ ],
128
+ runtimes,
129
+ wrapState,
130
+ };
131
+ }
@@ -0,0 +1,186 @@
1
+ import crypto from "node:crypto";
2
+ import fsSync from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { FRAMEWORK_NAME } from "../framework";
7
+ import {
8
+ runtimeDirectoryPath,
9
+ runtimeClaudePluginRoot,
10
+ runtimeMateBin,
11
+ runtimeOpenCodePluginRoot,
12
+ runtimeWrapperBinPath,
13
+ } from "../runtime/runtime-directory";
14
+ import { writeAtomic } from "../runtime/write-atomic";
15
+ import { realLinkTarget, realOrResolved } from "./fs-utils";
16
+ import { getClaudePluginRoot, getOpenCodePluginRoot, getWrapperBinPath } from "./package-paths";
17
+
18
+ export interface RuntimeDirectoryTargets {
19
+ pluginRoot: string;
20
+ claudePluginRoot: string;
21
+ /** The resolved `wrappers/bin`; its parent is what the `wrappers` link names. */
22
+ wrapperBinPath: string;
23
+ /** The interpreter running this CLI, written into the launcher. */
24
+ execPath: string;
25
+ cliEntry: string;
26
+ }
27
+
28
+ export interface RuntimeDirectoryRefresh {
29
+ /** Entries rewritten by this run, relative to the runtime directory. */
30
+ changed: string[];
31
+ }
32
+
33
+ /**
34
+ * `process.execPath` is the resolved, often versioned, binary (Homebrew's
35
+ * `Cellar/bun/<version>/bin/bun`), which an upgrade deletes. A `PATH` entry of
36
+ * the same name that is a link to it survives the upgrade; without one the
37
+ * resolved path is all there is.
38
+ */
39
+ export function stableInterpreterPath(
40
+ execPath: string,
41
+ pathValue: string | undefined = process.env.PATH,
42
+ ): string {
43
+ let real: string;
44
+ try {
45
+ real = fsSync.realpathSync(execPath);
46
+ } catch {
47
+ return execPath;
48
+ }
49
+ const name = path.basename(execPath);
50
+ for (const dir of (pathValue ?? "").split(path.delimiter).filter(Boolean)) {
51
+ const candidate = path.join(dir, name);
52
+ if (candidate === real) continue;
53
+ try {
54
+ if (fsSync.realpathSync(candidate) === real) return candidate;
55
+ } catch {
56
+ /* not on this PATH entry */
57
+ }
58
+ }
59
+ return execPath;
60
+ }
61
+
62
+ function currentRuntimeDirectoryTargets(): RuntimeDirectoryTargets {
63
+ return {
64
+ pluginRoot: getOpenCodePluginRoot(),
65
+ claudePluginRoot: getClaudePluginRoot(),
66
+ wrapperBinPath: getWrapperBinPath(),
67
+ execPath: stableInterpreterPath(process.execPath),
68
+ cliEntry: path.resolve(process.argv[1] ?? ""),
69
+ };
70
+ }
71
+
72
+ /** The link's {@link realLinkTarget}; `""` when not a link, `null` when absent. */
73
+ async function readLinkTarget(linkPath: string): Promise<string | null> {
74
+ try {
75
+ const stat = await fs.lstat(linkPath);
76
+ if (!stat.isSymbolicLink()) return "";
77
+ return await realLinkTarget(linkPath, await fs.readlink(linkPath));
78
+ } catch (error) {
79
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Junctions on Windows need no admin rights or Developer Mode. On POSIX a
86
+ * sibling link renamed over the old one keeps concurrent runs from ever seeing
87
+ * the entry missing.
88
+ */
89
+ async function pointLink(linkPath: string, target: string): Promise<boolean> {
90
+ const [current, wanted] = await Promise.all([readLinkTarget(linkPath), realOrResolved(target)]);
91
+ if (current === wanted) return false;
92
+ await fs.mkdir(path.dirname(linkPath), { recursive: true });
93
+ if (process.platform === "win32") {
94
+ await fs.rm(linkPath, { recursive: true, force: true });
95
+ await fs.symlink(target, linkPath, "junction");
96
+ return true;
97
+ }
98
+ /** A rename cannot replace a real directory or file. */
99
+ if (current === "") await fs.rm(linkPath, { recursive: true, force: true });
100
+ const tempPath = `${linkPath}.${crypto.randomUUID()}.tmp`;
101
+ try {
102
+ await fs.symlink(target, tempPath, "dir");
103
+ await fs.rename(tempPath, linkPath);
104
+ } catch (error) {
105
+ await fs.rm(tempPath, { force: true }).catch(() => {});
106
+ throw error;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ function shellQuote(value: string): string {
112
+ return `'${value.replaceAll("'", `'\\''`)}'`;
113
+ }
114
+
115
+ function launcherContent(targets: RuntimeDirectoryTargets, platform: NodeJS.Platform): string {
116
+ if (platform === "win32") {
117
+ return `@echo off\r\n"${targets.execPath}" "${targets.cliEntry}" %*\r\n`;
118
+ }
119
+ return `#!/bin/sh\nexec ${shellQuote(targets.execPath)} ${shellQuote(targets.cliEntry)} "$@"\n`;
120
+ }
121
+
122
+ async function writeLauncher(
123
+ launcherPath: string,
124
+ targets: RuntimeDirectoryTargets,
125
+ ): Promise<boolean> {
126
+ const next = launcherContent(targets, process.platform);
127
+ const existing = await fs.readFile(launcherPath, "utf8").catch(() => null);
128
+ if (existing === next) return false;
129
+ await fs.mkdir(path.dirname(launcherPath), { recursive: true });
130
+ await writeAtomic(launcherPath, next, 0o755);
131
+ return true;
132
+ }
133
+
134
+ /** Re-points or rewrites only the entries whose target differs from `targets`. */
135
+ export async function refreshRuntimeDirectory(
136
+ targets: RuntimeDirectoryTargets = currentRuntimeDirectoryTargets(),
137
+ homeDir?: string,
138
+ ): Promise<RuntimeDirectoryRefresh> {
139
+ const root = runtimeDirectoryPath(homeDir);
140
+ const launcherPath = runtimeMateBin(homeDir);
141
+ const changed: string[] = [];
142
+ if (await pointLink(runtimeOpenCodePluginRoot(homeDir), targets.pluginRoot)) {
143
+ changed.push("opencode-plugin");
144
+ }
145
+ if (await pointLink(runtimeClaudePluginRoot(homeDir), targets.claudePluginRoot)) {
146
+ changed.push("claude-plugin");
147
+ }
148
+ const wrappersLink = path.dirname(runtimeWrapperBinPath(homeDir));
149
+ if (await pointLink(wrappersLink, path.dirname(targets.wrapperBinPath))) {
150
+ changed.push("wrappers");
151
+ }
152
+ if (await writeLauncher(launcherPath, targets)) {
153
+ changed.push(path.relative(root, launcherPath).split(path.sep).join("/"));
154
+ }
155
+ return { changed };
156
+ }
157
+
158
+ /** Commands whose output names runtime-directory paths cannot run without it. */
159
+ const RUNTIME_DIRECTORY_COMMANDS = new Set(["wrap", "opencode", "session"]);
160
+
161
+ function runtimeDirectoryRequired(command: string | undefined): boolean {
162
+ return command !== undefined && RUNTIME_DIRECTORY_COMMANDS.has(command);
163
+ }
164
+
165
+ /**
166
+ * Startup refresh: a failure warns, except for the commands that write or
167
+ * consume runtime-directory paths, where it is returned as fatal.
168
+ */
169
+ export async function ensureRuntimeDirectory(
170
+ command: string | undefined,
171
+ refresh: () => Promise<RuntimeDirectoryRefresh> = () => refreshRuntimeDirectory(),
172
+ ): Promise<boolean> {
173
+ try {
174
+ await refresh();
175
+ return true;
176
+ } catch (error) {
177
+ const detail = error instanceof Error ? error.message : String(error);
178
+ const message = `failed to update ${runtimeDirectoryPath()}: ${detail}`;
179
+ if (runtimeDirectoryRequired(command)) {
180
+ console.error(`${FRAMEWORK_NAME}: ${message}`);
181
+ return false;
182
+ }
183
+ console.error(`${FRAMEWORK_NAME}: warning: ${message}`);
184
+ return true;
185
+ }
186
+ }
@@ -99,6 +99,11 @@ export function isCanaryVersion(version: string = getCurrentVersion()): boolean
99
99
  return version.includes("-canary");
100
100
  }
101
101
 
102
+ /** Versions packed by `pack-local.sh` (`x.y.z-local.n`) are local test builds. */
103
+ export function isLocalVersion(version: string = getCurrentVersion()): boolean {
104
+ return version.includes("-local");
105
+ }
106
+
102
107
  export type UpdateChannel = "latest" | "canary";
103
108
 
104
109
  export function getUpdateChannel(version: string = getCurrentVersion()): UpdateChannel {
@@ -134,6 +139,7 @@ export async function showUpdateBannerIfAvailable(store: UpdateStateStore): Prom
134
139
  export async function enforceUpdateIfRequired(store: UpdateStateStore): Promise<boolean> {
135
140
  if (isPinnedDeployment()) return false;
136
141
  if (!getUpdateConfig().enforce) return false;
142
+ if (isLocalVersion()) return false;
137
143
  try {
138
144
  const state = await store.load();
139
145
  if (!state.latestVersion) return false;