@uniqbit/mate-core 0.16.0 → 0.17.0-canary.10

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 (49) hide show
  1. package/package.json +2 -2
  2. package/src/cli/commands/companion/companion.ts +8 -0
  3. package/src/cli/commands/companion/prepare.ts +76 -0
  4. package/src/cli/commands/companion/register.ts +47 -0
  5. package/src/cli/commands/launch/claude.ts +4 -3
  6. package/src/cli/commands/launch/opencode.ts +4 -3
  7. package/src/cli/commands/launch/shared.ts +41 -28
  8. package/src/cli/commands/shared/companion-selection.ts +3 -1
  9. package/src/cli/commands/studio/index.ts +85 -6
  10. package/src/cli/commands/studio/routes.ts +1 -1
  11. package/src/cli/commands/studio/selection.ts +11 -2
  12. package/src/cli/commands/studio/server.ts +212 -17
  13. package/src/cli/commands/studio/vault.ts +607 -0
  14. package/src/cli/commands/studio/views/client.ts +141 -1
  15. package/src/cli/commands/studio/views/companion-selector.tsx +1 -1
  16. package/src/cli/commands/studio/views/document.tsx +137 -3
  17. package/src/cli/commands/studio/views/model.ts +13 -0
  18. package/src/cli/commands/studio/views/styles.ts +17 -0
  19. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  20. package/src/cli/commands/update.ts +6 -6
  21. package/src/cli/main.ts +56 -4
  22. package/src/cli/usage.ts +6 -3
  23. package/src/index.ts +1 -0
  24. package/src/lib/context-mode-package.ts +7 -4
  25. package/src/lib/node-engine-version.ts +15 -0
  26. package/src/lib/opencode-plugin-package.ts +10 -4
  27. package/src/lib/orchestrator/adapters/base.ts +65 -10
  28. package/src/lib/orchestrator/adapters/opencode.ts +27 -2
  29. package/src/lib/orchestrator/companion-registration.ts +73 -0
  30. package/src/lib/orchestrator/framework-context.ts +29 -3
  31. package/src/lib/orchestrator/launcher.ts +46 -22
  32. package/src/lib/orchestrator/projection-record.ts +24 -0
  33. package/src/lib/orchestrator/types.ts +11 -0
  34. package/src/lib/package-root.ts +43 -0
  35. package/src/lib/prebuilt-workspace-prepare.ts +152 -0
  36. package/src/lib/prebuilt-workspace.ts +246 -0
  37. package/src/lib/preinstalled-plugins.ts +106 -0
  38. package/src/lib/update-checker.ts +39 -0
  39. package/src/opencode/companion-hooks.ts +45 -20
  40. package/src/playbooks/companion-guidance.ts +1 -0
  41. package/src/runtime/companion-guidance.ts +61 -15
  42. package/src/runtime/env.ts +3 -3
  43. package/src/runtime/index.ts +1 -0
  44. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  45. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +127 -77
  46. package/src/tools/setup/capabilities/context-mode.ts +34 -2
  47. package/src/tools/setup/capabilities/openspec.ts +12 -2
  48. package/src/tools/setup/plugin.ts +7 -1
  49. package/src/tools/setup/providers/opencode.ts +24 -5
@@ -91,7 +91,11 @@ export const STUDIO_CLIENT_SCRIPT = `(function () {
91
91
  }
92
92
 
93
93
  function copy(text, label) {
94
- return navigator.clipboard.writeText(text).then(
94
+ if (typeof navigator === "undefined" || !navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
95
+ announce("copying is blocked in this browser");
96
+ return Promise.resolve();
97
+ }
98
+ return Promise.resolve().then(function () { return navigator.clipboard.writeText(text); }).then(
95
99
  function () {
96
100
  announce("copied " + label);
97
101
  },
@@ -194,4 +198,140 @@ export const STUDIO_CLIENT_SCRIPT = `(function () {
194
198
  if (text) copy(text, node.getAttribute("data-copy-label") || "prompt");
195
199
  });
196
200
  });
201
+
202
+ var savePromise = null;
203
+ function editor() { return document.getElementById("vault-editor"); }
204
+ function status(message) {
205
+ var node = document.getElementById("vault-status");
206
+ if (node) node.textContent = message;
207
+ }
208
+ function dirty() {
209
+ var node = editor();
210
+ return !!node && node.value !== (node.getAttribute("data-vault-original") || "");
211
+ }
212
+ function setIncoming(event) {
213
+ var box = document.getElementById("vault-incoming");
214
+ var content = document.getElementById("vault-incoming-content");
215
+ var recover = document.getElementById("vault-recover");
216
+ if (!box || !content || !recover) return;
217
+ box.hidden = false;
218
+ content.textContent = event.content || "";
219
+ recover.setAttribute("data-vault-content", event.content || "");
220
+ recover.setAttribute("data-vault-token", event.token || "");
221
+ }
222
+ function acceptIncoming(event) {
223
+ var node = editor();
224
+ if (!node) return;
225
+ node.value = event.content || "";
226
+ node.setAttribute("data-vault-original", node.value);
227
+ if (event.token) node.setAttribute("data-vault-token", event.token);
228
+ var box = document.getElementById("vault-incoming");
229
+ if (box) box.hidden = true;
230
+ status("incoming version loaded into the editor");
231
+ }
232
+ function askBeforeNavigation() {
233
+ return !dirty() || window.confirm("Discard unsaved changes?");
234
+ }
235
+ function waitForSave() {
236
+ return savePromise ? savePromise.then(function (result) { return result; }) : Promise.resolve(true);
237
+ }
238
+ function navigate(url) {
239
+ waitForSave().then(function (allowed) {
240
+ if (allowed && askBeforeNavigation()) location.assign(url);
241
+ });
242
+ }
243
+ function navigationUrl(form, submitter) {
244
+ var target = new URL(form.action || location.href, location.href);
245
+ new FormData(form).forEach(function (value, key) {
246
+ if (typeof value === "string") target.searchParams.append(key, value);
247
+ });
248
+ if (submitter && submitter.name && submitter.value) target.searchParams.set(submitter.name, submitter.value);
249
+ return target.toString();
250
+ }
251
+ function wireNavigation() {
252
+ document.querySelectorAll('form[method="get"]').forEach(function (form) {
253
+ form.addEventListener("submit", function (event) {
254
+ event.preventDefault();
255
+ navigate(navigationUrl(form, event.submitter));
256
+ });
257
+ form.querySelectorAll("select").forEach(function (select) {
258
+ select.addEventListener("change", function () { navigate(navigationUrl(form)); });
259
+ });
260
+ });
261
+ }
262
+ function wireVault() {
263
+ var node = editor();
264
+ var root = document.querySelector("[data-vault-root]");
265
+ if (!node || !root) return;
266
+ node.setAttribute("data-vault-original", node.value);
267
+ var save = document.getElementById("vault-save");
268
+ if (save) save.addEventListener("click", function () {
269
+ if (savePromise) return;
270
+ var companion = document.querySelector("[data-companion]");
271
+ var body = {
272
+ companion: companion ? companion.getAttribute("data-companion") : "",
273
+ path: node.getAttribute("data-vault-path"),
274
+ content: node.value,
275
+ token: node.getAttribute("data-vault-token"),
276
+ };
277
+ savePromise = fetch("/api/vault/save", {
278
+ method: "POST",
279
+ headers: { "content-type": "application/json" },
280
+ body: JSON.stringify(body),
281
+ }).then(function (response) {
282
+ return response.json().then(function (result) {
283
+ if (!response.ok) {
284
+ if (response.status === 409) setIncoming(result);
285
+ status(result.reason || "save refused");
286
+ return false;
287
+ }
288
+ node.setAttribute("data-vault-token", result.token || "");
289
+ node.setAttribute("data-vault-original", node.value);
290
+ status("saved");
291
+ return true;
292
+ });
293
+ }).catch(function () {
294
+ status("save failed");
295
+ return false;
296
+ }).finally(function () { savePromise = null; });
297
+ });
298
+ var recover = document.getElementById("vault-recover");
299
+ if (recover) recover.addEventListener("click", function () {
300
+ acceptIncoming({ content: recover.getAttribute("data-vault-content") || "", token: recover.getAttribute("data-vault-token") || "" });
301
+ });
302
+ var companion = document.querySelector("[data-companion]");
303
+ var path = node.getAttribute("data-vault-path");
304
+ if (companion && path && typeof EventSource !== "undefined") {
305
+ var events = new EventSource("/api/vault/events?companion=" + encodeURIComponent(companion.getAttribute("data-companion") || "") + "&path=" + encodeURIComponent(path));
306
+ events.onmessage = function (message) {
307
+ var event;
308
+ try { event = JSON.parse(message.data); } catch (error) { return; }
309
+ if (event.kind === "overwritten") {
310
+ setIncoming(event);
311
+ status("a version was overwritten; its content is available below");
312
+ return;
313
+ }
314
+ if (event.kind === "removed") {
315
+ status("this file no longer exists on disk");
316
+ return;
317
+ }
318
+ if (dirty()) {
319
+ setIncoming(event);
320
+ status("the file changed on disk; unsaved content is still in the editor");
321
+ return;
322
+ }
323
+ node.value = event.content || "";
324
+ node.setAttribute("data-vault-original", node.value);
325
+ if (event.token) node.setAttribute("data-vault-token", event.token);
326
+ status("updated from disk");
327
+ };
328
+ }
329
+ }
330
+ if (typeof window !== "undefined") window.addEventListener("beforeunload", function (event) {
331
+ if (!dirty()) return;
332
+ event.preventDefault();
333
+ event.returnValue = "";
334
+ });
335
+ wireNavigation();
336
+ wireVault();
197
337
  })();`;
@@ -25,7 +25,7 @@ export function CompanionSelector({ inventory, selection }: CompanionSelectorPro
25
25
  <input type="hidden" name="view" value={selection.view} />
26
26
  )}
27
27
  {/* eslint-disable-next-line react/no-unknown-property -- raw HTML attribute: hono/jsx server-renders this inline handler */}
28
- <select name="companion" aria-label="Companion Repository" onchange="this.form.submit()">
28
+ <select name="companion" aria-label="Companion Repository">
29
29
  <PlaceholderOption companionCount={companions.length} selected={selected} />
30
30
  {companions.map((companion) => {
31
31
  const digest = companionDigest(companion.path);
@@ -1,12 +1,13 @@
1
1
  /** @jsxImportSource hono/jsx */
2
2
 
3
- import { companionDigest, REFRESH_PARAM, type StudioSelection } from "../selection";
3
+ import { companionDigest, FILE_PARAM, REFRESH_PARAM, type StudioSelection } from "../selection";
4
4
  import { STUDIO_CLIENT_SCRIPT, STUDIO_PREPAINT_SCRIPT } from "./client";
5
5
  import { CompanionPicker } from "./companion-picker";
6
6
  import { CompanionSelector } from "./companion-selector";
7
7
  import { Dashboard } from "./dashboard/index";
8
8
  import { CompanionError } from "./error";
9
- import { formatCollectedAt, type StudioPage } from "./model";
9
+ import { formatCollectedAt, type StudioPage, type StudioVaultPage } from "./model";
10
+ import type { VaultTreeNode } from "../vault";
10
11
  import { Specs } from "./specs/index";
11
12
  import { STUDIO_STYLES } from "./styles";
12
13
  import { Skills } from "./skills/index";
@@ -31,6 +32,10 @@ const VIEW_DETAILS = {
31
32
  eyebrow: "Agent guidance",
32
33
  title: "Skills",
33
34
  },
35
+ vault: {
36
+ eyebrow: "Companion files",
37
+ title: "Vault",
38
+ },
34
39
  } as const;
35
40
 
36
41
  /**
@@ -86,6 +91,7 @@ function Content({ page }: { page: StudioPage }) {
86
91
  if (page.error) {
87
92
  return <CompanionError companionPath={page.error.companionPath} reason={page.error.reason} />;
88
93
  }
94
+ if (page.selection.view === "vault") return <Vault page={page} />;
89
95
  if (!page.payload) {
90
96
  return (
91
97
  <section className="panel">
@@ -153,7 +159,7 @@ function ViewNav({ selection }: { selection: StudioSelection }) {
153
159
  <nav className="sidebar-nav" aria-label="Studio views">
154
160
  <span className="sidebar-label">Views</span>
155
161
  <form method="get" action="/">
156
- <SelectionFields selection={selection} omit="view" />
162
+ <SelectionFields selection={selection} omit="view" dropOpenPath />
157
163
  <div className="sidebar-nav-list">
158
164
  <button
159
165
  type="submit"
@@ -163,6 +169,9 @@ function ViewNav({ selection }: { selection: StudioSelection }) {
163
169
  >
164
170
  <span>Overview</span>
165
171
  </button>
172
+ <button type="submit" name="view" value="vault" aria-pressed={selection.view === "vault"}>
173
+ <span>Vault</span>
174
+ </button>
166
175
  <button type="submit" name="view" value="specs" aria-pressed={selection.view === "specs"}>
167
176
  <span>Specs</span>
168
177
  </button>
@@ -239,9 +248,11 @@ function Footer({ page }: { page: StudioPage }) {
239
248
  function SelectionFields({
240
249
  selection,
241
250
  omit,
251
+ dropOpenPath = false,
242
252
  }: {
243
253
  selection: StudioSelection;
244
254
  omit?: keyof StudioSelection;
255
+ dropOpenPath?: boolean;
245
256
  }) {
246
257
  return (
247
258
  <>
@@ -251,6 +262,129 @@ function SelectionFields({
251
262
  {selection.view !== "dashboard" && omit !== "view" ? (
252
263
  <input type="hidden" name="view" value={selection.view} />
253
264
  ) : null}
265
+ {selection.view === "vault" && selection.openPath && !dropOpenPath ? (
266
+ <input type="hidden" name={FILE_PARAM} value={selection.openPath} />
267
+ ) : null}
254
268
  </>
255
269
  );
256
270
  }
271
+
272
+ function Vault({ page }: { page: StudioPage }) {
273
+ const vault = page.vault;
274
+ if (!vault)
275
+ return (
276
+ <section className="panel">
277
+ <p className="empty">No vault state collected.</p>
278
+ </section>
279
+ );
280
+ return (
281
+ <section className="vault-layout">
282
+ <div className="panel vault-tree-panel">
283
+ <div className="section-header">
284
+ <div>
285
+ <h3>Markdown files</h3>
286
+ <p className="section-note">
287
+ {vault.tree.length === 0
288
+ ? "This companion has no markdown files."
289
+ : "Every non-ignored markdown file in this companion."}
290
+ </p>
291
+ </div>
292
+ {!vault.watching ? (
293
+ <form method="get" action="/" data-studio-navigation>
294
+ <SelectionFields selection={page.selection} />
295
+ <input type="hidden" name={REFRESH_PARAM} value="1" />
296
+ <button type="submit">Refresh tree</button>
297
+ </form>
298
+ ) : null}
299
+ </div>
300
+ {vault.warning ? (
301
+ <p className="note vault-warning">{vault.warning}. The tree may be out of date.</p>
302
+ ) : null}
303
+ <nav aria-label="Markdown files">
304
+ {vault.tree.map((node) => (
305
+ <VaultNode key={node.path} node={node} selection={page.selection} />
306
+ ))}
307
+ </nav>
308
+ </div>
309
+ <VaultEditor page={page} vault={vault} />
310
+ </section>
311
+ );
312
+ }
313
+
314
+ function VaultNode({ node, selection }: { node: VaultTreeNode; selection: StudioSelection }) {
315
+ if (node.kind === "directory") {
316
+ return (
317
+ <details open className="vault-directory">
318
+ <summary>{node.name}</summary>
319
+ <div className="vault-children">
320
+ {node.children?.map((child) => (
321
+ <VaultNode key={child.path} node={child} selection={selection} />
322
+ ))}
323
+ </div>
324
+ </details>
325
+ );
326
+ }
327
+ return (
328
+ <form method="get" action="/" className="vault-file" data-studio-navigation>
329
+ <SelectionFields selection={selection} dropOpenPath />
330
+ <input type="hidden" name={FILE_PARAM} value={node.path} />
331
+ <button type="submit" aria-current={selection.openPath === node.path ? "page" : undefined}>
332
+ {node.name}
333
+ </button>
334
+ </form>
335
+ );
336
+ }
337
+
338
+ function VaultEditor({ page, vault }: { page: StudioPage; vault: StudioVaultPage }) {
339
+ if (!vault.open) {
340
+ return (
341
+ <div className="panel vault-editor-panel">
342
+ <h3>{vault.refusal ? "File refused" : "Open a markdown file"}</h3>
343
+ <p className={vault.refusal ? "note vault-warning" : "empty"}>
344
+ {vault.refusal ?? "Choose a file from the tree to begin."}
345
+ </p>
346
+ </div>
347
+ );
348
+ }
349
+ return (
350
+ <div
351
+ className="panel vault-editor-panel"
352
+ data-vault-root
353
+ data-vault-writable={page.writable ? "true" : "false"}
354
+ >
355
+ <div className="section-header">
356
+ <div>
357
+ <h3>{vault.open.path}</h3>
358
+ <p className="section-note">
359
+ {page.writable
360
+ ? "Changes stay in the editor until you save."
361
+ : "Read-only mode. Start Studio with --writable to save."}
362
+ </p>
363
+ </div>
364
+ {page.writable ? (
365
+ <button type="button" id="vault-save">
366
+ Save
367
+ </button>
368
+ ) : null}
369
+ </div>
370
+ <textarea
371
+ id="vault-editor"
372
+ data-vault-editor
373
+ data-vault-path={vault.open.path}
374
+ data-vault-token={vault.open.token}
375
+ {...(page.writable ? {} : { readOnly: true })}
376
+ spellCheck={false}
377
+ >
378
+ {vault.open.content}
379
+ </textarea>
380
+ <p id="vault-status" className="note" aria-live="polite" />
381
+ <div id="vault-incoming" className="vault-incoming" hidden>
382
+ <strong id="vault-incoming-title">Incoming version</strong>
383
+ <pre id="vault-incoming-content" />
384
+ <button type="button" id="vault-recover">
385
+ Put incoming content back
386
+ </button>
387
+ </div>
388
+ </div>
389
+ );
390
+ }
@@ -1,6 +1,7 @@
1
1
  import type { StudioInventory, StudioInventoryCompanion } from "../inventory";
2
2
  import type { StudioCompanionPayload } from "../payload";
3
3
  import type { StudioSelection } from "../selection";
4
+ import type { VaultFile, VaultTreeNode } from "../vault";
4
5
 
5
6
  /**
6
7
  * Everything one rendered document is derived from. `payload` and `error` are
@@ -15,6 +16,18 @@ export interface StudioPage {
15
16
  payload: StudioCompanionPayload | null;
16
17
  error: { companionPath: string; reason: string } | null;
17
18
  collectedAt: number | null;
19
+ writable: boolean;
20
+ vault: StudioVaultPage | null;
21
+ }
22
+
23
+ export interface StudioVaultPage {
24
+ tree: VaultTreeNode[];
25
+ open: VaultFile | null;
26
+ refusal: string | null;
27
+ incoming: VaultFile | null;
28
+ overwritten: VaultFile | null;
29
+ watching: boolean;
30
+ warning: string | null;
18
31
  }
19
32
 
20
33
  /**
@@ -360,6 +360,23 @@ ul.plain li:last-child { border-bottom: 0; }
360
360
  .workflow-option small { color: var(--muted); font-size: .65rem; line-height: 1.35; }
361
361
  .workflow-option .runway-step-copy { justify-self: start; }
362
362
 
363
+ .vault-layout { display: grid; grid-template-columns: minmax(190px, .34fr) minmax(0, 1fr); gap: 16px; align-items: start; }
364
+ .vault-tree-panel, .vault-editor-panel { min-width: 0; }
365
+ .vault-tree-panel nav { display: grid; gap: 2px; }
366
+ .vault-directory { color: var(--muted); font-family: var(--mono); font-size: .75rem; }
367
+ .vault-directory summary { cursor: pointer; padding: 5px 4px; }
368
+ .vault-children { display: grid; gap: 2px; padding-left: 12px; }
369
+ .vault-file { display: contents; }
370
+ .vault-file button { width: 100%; overflow: hidden; padding: 6px 8px; border-color: transparent; background: transparent; color: var(--text); font-family: var(--mono); font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
371
+ .vault-file button:hover, .vault-file button[aria-current="page"] { border-color: var(--border); background: var(--accent-soft); color: var(--accent); }
372
+ .vault-warning { color: var(--warn); }
373
+ #vault-editor { display: block; width: 100%; min-height: 62vh; resize: vertical; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--prompt-bg); color: var(--prompt-text); font-family: var(--mono); font-size: .86rem; line-height: 1.6; }
374
+ #vault-editor:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
375
+ #vault-editor[readonly] { cursor: default; opacity: .85; }
376
+ .vault-incoming { display: grid; gap: 8px; margin-top: 16px; padding: 12px; border: 1px solid var(--warn); border-radius: 8px; background: var(--warn-soft); }
377
+ .vault-incoming pre { max-height: 220px; overflow: auto; margin: 0; white-space: pre-wrap; font-family: var(--mono); font-size: .78rem; }
378
+ @media (max-width: 800px) { .vault-layout { grid-template-columns: 1fr; } }
379
+
363
380
  @media (max-width: 650px) {
364
381
  .workflow-console-header { grid-template-columns: 1fr auto; }
365
382
  .workflow-console-context, .workflow-console-tabs { margin-left: 0; }
@@ -240,13 +240,20 @@ export function workflowPlan(
240
240
  profile: "mate-minimal",
241
241
  label: "mate-minimal",
242
242
  description:
243
- "Short path that creates only specs and tasks for one-Area, low-risk changes; switch to mate-v1 when uncertain.",
243
+ "Short path that explores first, then creates only specs and tasks for one-Area, low-risk changes; switch to mate-v1 when uncertain.",
244
244
  steps: [
245
+ skillStep(
246
+ "explore",
247
+ "explore",
248
+ "Resolves repository evidence, unknowns, options, and direction without adding a planning artifact.",
249
+ "It gives the minimal proposal step an agreed direction while keeping the output to specs and tasks.",
250
+ "mate-minimal",
251
+ ),
245
252
  skillStep(
246
253
  "propose",
247
254
  "propose",
248
- "Creates the concise, parser-compatible behavior change.",
249
- "OpenSpec generates the minimal specs and tasks artifacts from the agreed direction.",
255
+ "Creates the concise, parser-compatible specs and tasks from the explored direction.",
256
+ "OpenSpec turns the agreed direction into only the minimal profile's specs and tasks; proposal and design artifacts are intentionally omitted.",
250
257
  "mate-minimal",
251
258
  ),
252
259
  reviewStep(
@@ -1,4 +1,3 @@
1
- import path from "node:path";
2
1
  import { spawnSync } from "node:child_process";
3
2
 
4
3
  import { FRAMEWORK_NAME } from "../../framework";
@@ -14,6 +13,7 @@ import {
14
13
  isNewer,
15
14
  } from "../../lib/update-checker";
16
15
  import { confirmPrompt } from "../../lib/components/confirm-prompt";
16
+ import { resolvePackageRoot } from "../../lib/package-root";
17
17
 
18
18
  type InstallResult = ReturnType<typeof installPublicPackageSync>;
19
19
 
@@ -38,11 +38,11 @@ export const updateCommandDeps = {
38
38
  getCurrentVersion,
39
39
  isNewer,
40
40
  confirmPrompt,
41
- isNpmManagedInstall: () =>
42
- isPackageInstalledAtNpmGlobalRoot(
43
- path.resolve(import.meta.dirname, "../../.."),
44
- getUpdateConfig().packageName,
45
- ),
41
+ isNpmManagedInstall: () => {
42
+ const packageName = getUpdateConfig().packageName;
43
+ const packageRoot = resolvePackageRoot(process.argv[1], packageName);
44
+ return packageRoot !== undefined && isPackageInstalledAtNpmGlobalRoot(packageRoot, packageName);
45
+ },
46
46
  installLatest: (latest: string): InstallResult => {
47
47
  const { packageName, registry } = getUpdateConfig();
48
48
  return installPublicPackageSync(`${packageName}@${latest}`, registry);
package/src/cli/main.ts CHANGED
@@ -22,6 +22,7 @@ import { runUnwrapCommand } from "./commands/unwrap";
22
22
  import { parseWrapArgs, runWrapCommand } from "./commands/wrap";
23
23
  import { runWorkingCommand } from "./commands/working/working";
24
24
  import { runInstallCommand } from "./commands/install";
25
+ import { parseDirectLaunchArgs } from "./commands/launch/shared";
25
26
  import {
26
27
  inspectInstallPreflight,
27
28
  isRepairableInstallPreflight,
@@ -191,6 +192,14 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
191
192
  case "link":
192
193
  if (!(await gate({ notHubRoot: true }))) return;
193
194
  break;
195
+ // Registration names its target explicitly, so it needs no companion
196
+ // context and no root classification of the current directory.
197
+ case "register":
198
+ // Preparation names its bundle and its target explicitly, and runs
199
+ // before a companion is usable at all.
200
+ case "prepare":
201
+ if (!(await gate({ updateGuard: true }))) return;
202
+ break;
194
203
  // Hub commands establish and operate on a local hub root directly;
195
204
  // they must not require a linked working repository or installation —
196
205
  // but companions are never hubs.
@@ -215,21 +224,64 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
215
224
  await runHubCommand(subcommand ? [subcommand, ...rest] : []);
216
225
  return;
217
226
  case "claude":
218
- if (!(await gate({ updateGuard: true, companion: true, install: true }))) return;
227
+ if (
228
+ !(await gate({
229
+ updateGuard: true,
230
+ companion:
231
+ parseDirectLaunchArgs(argv.slice(3)).scope === "companion"
232
+ ? { persistSelection: false }
233
+ : true,
234
+ install: true,
235
+ }))
236
+ )
237
+ return;
219
238
  await runLaunchClaudeCommand(argv.slice(3), { directPassthrough: true });
220
239
  return;
221
240
  case "opencode":
222
- if (!(await gate({ updateGuard: true, companion: true, install: true }))) return;
241
+ if (
242
+ !(await gate({
243
+ updateGuard: true,
244
+ companion:
245
+ parseDirectLaunchArgs(argv.slice(3)).scope === "companion"
246
+ ? { persistSelection: false }
247
+ : true,
248
+ install: true,
249
+ }))
250
+ )
251
+ return;
223
252
  await runLaunchOpenCodeCommand(argv.slice(3), { directPassthrough: true });
224
253
  return;
254
+ case "launch": {
255
+ const launchArgs = argv.slice(4);
256
+ const companion = parseDirectLaunchArgs(launchArgs).scope === "companion";
257
+ if (subcommand !== "claude" && subcommand !== "opencode") {
258
+ console.error(`${FRAMEWORK_NAME}: expected launch claude or launch opencode.`);
259
+ process.exitCode = 1;
260
+ return;
261
+ }
262
+
263
+ const launchCommand =
264
+ subcommand === "claude" ? runLaunchClaudeCommand : runLaunchOpenCodeCommand;
265
+ if (
266
+ !(await gate({
267
+ updateGuard: true,
268
+ companion: companion ? { persistSelection: false } : true,
269
+ install: true,
270
+ }))
271
+ )
272
+ return;
273
+
274
+ await launchCommand(launchArgs, { directPassthrough: true });
275
+ return;
276
+ }
225
277
  case "report":
226
278
  if (!(await gate({ updateGuard: true, install: true }))) return;
227
279
  await runReportCommand(argv.slice(3));
228
280
  return;
229
281
  case "studio":
230
282
  /**
231
- * Serves the machine-wide inventory read-only, so it resolves no
232
- * companion context and stays runnable from any directory.
283
+ * Serves the machine-wide inventory without companion context, so it
284
+ * stays runnable from any directory. Writes remain opt-in.
233
285
  */
234
286
  if (!(await gate({ updateGuard: true }))) return;
235
287
  await runStudioCommand(argv.slice(3));
package/src/cli/usage.ts CHANGED
@@ -13,6 +13,8 @@ export function usage(): string {
13
13
  ` ${n} install [--yes]`,
14
14
  ` ${n} companion link`,
15
15
  ` ${n} companion setup`,
16
+ ` ${n} companion register [path]`,
17
+ ` ${n} companion prepare --from <bundle> [path]`,
16
18
  ` ${n} companion list`,
17
19
  ` ${n} companion sync`,
18
20
  ` ${n} companion open`,
@@ -28,10 +30,11 @@ export function usage(): string {
28
30
  ` ${n} artifact pending [--json]`,
29
31
  ` ${n} doctor`,
30
32
  ` ${n} report [--days N] [--input FILE|-] [--json]`,
31
- ` ${n} studio`,
33
+ ` ${n} studio [--writable]`,
34
+ ` ${n} studio serve --port <port> [--host <host>] [--writable]`,
32
35
  ` ${n} config`,
33
- ` ${n} claude [args...] (use -- --no-git to bypass companion Git sync)`,
34
- ` ${n} opencode [args...] (use -- --no-git to bypass companion Git sync)`,
36
+ ` ${n} claude [args...] (after --: --no-git, --companion, --yes no-op)`,
37
+ ` ${n} opencode [args...] (after --: --no-git, --companion, --yes no-op)`,
35
38
  ` ${n} cap openspec <subcommand> [args...]`,
36
39
  ` ${n} cap graphify <subcommand> [args...]`,
37
40
  ` ${n} cap index [--graphify] [--tokensave]`,
package/src/index.ts CHANGED
@@ -54,4 +54,5 @@ export type {
54
54
  HubMemberSource,
55
55
  PluginDeclaration,
56
56
  PluginDeclarationPolicy,
57
+ LaunchScope,
57
58
  } from "./lib/orchestrator/types";
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
+ import { isPreinstalledPluginPath } from "./preinstalled-plugins";
5
6
  import { PUBLIC_NPM_REGISTRY } from "./public-npm";
6
7
 
7
8
  export const CONTEXT_MODE_PACKAGE_NAME = "context-mode";
@@ -12,11 +13,13 @@ export function getContextModePackageReference(): string {
12
13
  return `${CONTEXT_MODE_PACKAGE_NAME}@${CONTEXT_MODE_VERSION}`;
13
14
  }
14
15
 
16
+ /** Matches the published reference and a reference bound to an installed copy alike. */
15
17
  export function isContextModePackageReference(value: unknown): value is string {
16
- return (
17
- typeof value === "string" &&
18
- (value === CONTEXT_MODE_PACKAGE_NAME || value.startsWith(`${CONTEXT_MODE_PACKAGE_NAME}@`))
19
- );
18
+ if (typeof value !== "string") return false;
19
+ if (value === CONTEXT_MODE_PACKAGE_NAME || value.startsWith(`${CONTEXT_MODE_PACKAGE_NAME}@`)) {
20
+ return true;
21
+ }
22
+ return isPreinstalledPluginPath(value, CONTEXT_MODE_PACKAGE_NAME);
20
23
  }
21
24
 
22
25
  export function getContextModeInstallDir(companionPath: string): string {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The Node.js version an `engines.node` range may be enforced against, or null
3
+ * where there is none to enforce.
4
+ *
5
+ * Under Bun, `process.versions.node` is a compatibility claim rather than an
6
+ * installed Node release: it trails upstream on Bun's own schedule, and Bun
7
+ * ignores `engines` when loading a package anyway. Comparing a real range such
8
+ * as `^24.15.0` against that claim refuses graphs the runtime loads perfectly
9
+ * well, so there is nothing meaningful to compare and the check is skipped.
10
+ */
11
+ export function enforceableNodeVersion(
12
+ versions: NodeJS.ProcessVersions = process.versions,
13
+ ): string | null {
14
+ return versions.bun === undefined ? versions.node : null;
15
+ }
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
+ import { isPreinstalledPluginPath } from "./preinstalled-plugins";
6
7
  import { PUBLIC_NPM_REGISTRY } from "./public-npm";
7
8
  import { getCurrentVersion } from "./update-checker";
8
9
 
@@ -18,11 +19,16 @@ export function getOpenCodePluginPackageReference(version: string = getCurrentVe
18
19
  return `${OPENCODE_PLUGIN_PACKAGE_NAME}@${version}`;
19
20
  }
20
21
 
22
+ /** Matches the published reference and a reference bound to an installed copy alike. */
21
23
  export function isMateOpenCodePluginReference(value: unknown): boolean {
22
- return (
23
- typeof value === "string" &&
24
- (value === OPENCODE_PLUGIN_PACKAGE_NAME || value.startsWith(`${OPENCODE_PLUGIN_PACKAGE_NAME}@`))
25
- );
24
+ if (typeof value !== "string") return false;
25
+ if (
26
+ value === OPENCODE_PLUGIN_PACKAGE_NAME ||
27
+ value.startsWith(`${OPENCODE_PLUGIN_PACKAGE_NAME}@`)
28
+ ) {
29
+ return true;
30
+ }
31
+ return isPreinstalledPluginPath(value, OPENCODE_PLUGIN_PACKAGE_NAME);
26
32
  }
27
33
 
28
34
  export function getOpenCodeCacheDir(env: NodeJS.ProcessEnv = process.env): string {