@uniqbit/mate-core 0.17.0-canary.8 → 0.17.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 (103) hide show
  1. package/package.json +11 -5
  2. package/src/cli/commands/artifact/finish/index.ts +0 -9
  3. package/src/cli/commands/artifact/finish/openspec.ts +2 -2
  4. package/src/cli/commands/artifact/finish/registry.ts +1 -1
  5. package/src/cli/commands/artifact/pending/discovery.ts +1 -1
  6. package/src/cli/commands/artifact/pending/index.ts +0 -16
  7. package/src/cli/commands/cap/graphify.ts +1 -1
  8. package/src/cli/commands/cap/index-cmd.ts +1 -1
  9. package/src/cli/commands/cap/tokensave.ts +4 -3
  10. package/src/cli/commands/companion/companion.ts +4 -0
  11. package/src/cli/commands/companion/hub.ts +2 -1
  12. package/src/cli/commands/companion/list.ts +2 -1
  13. package/src/cli/commands/companion/prepare.ts +2 -2
  14. package/src/cli/commands/companion/update.ts +334 -0
  15. package/src/cli/commands/doctor.ts +2 -1
  16. package/src/cli/commands/install.ts +59 -5
  17. package/src/cli/commands/launch/shared.ts +2 -1
  18. package/src/cli/commands/plugin/plugin.ts +4 -0
  19. package/src/cli/commands/plugin/verify.ts +25 -0
  20. package/src/cli/commands/report/highlight.ts +1 -4
  21. package/src/cli/commands/report/renderer.ts +0 -2
  22. package/src/cli/commands/studio/access.ts +201 -0
  23. package/src/cli/commands/studio/index.ts +207 -26
  24. package/src/cli/commands/studio/openspec-cli.ts +1 -1
  25. package/src/cli/commands/studio/selection.ts +127 -11
  26. package/src/cli/commands/studio/server.ts +355 -64
  27. package/src/cli/commands/studio/terminal-assets.ts +50 -0
  28. package/src/cli/commands/studio/terminal-launch.ts +71 -0
  29. package/src/cli/commands/studio/terminal.ts +593 -0
  30. package/src/cli/commands/studio/vault.ts +320 -75
  31. package/src/cli/commands/studio/views/client.ts +90 -9
  32. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  33. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  34. package/src/cli/commands/studio/views/document.tsx +411 -107
  35. package/src/cli/commands/studio/views/model.ts +14 -1
  36. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  37. package/src/cli/commands/studio/views/styles.ts +106 -13
  38. package/src/cli/commands/studio/views/terminal.tsx +426 -0
  39. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  40. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  41. package/src/cli/commands/update.ts +11 -8
  42. package/src/cli/companion-link-wizard.tsx +1 -1
  43. package/src/cli/companion-selector.tsx +1 -1
  44. package/src/cli/launch-selector.tsx +1 -1
  45. package/src/cli/main.ts +2 -1
  46. package/src/cli/usage.ts +7 -3
  47. package/src/cli/write-json-stdout.ts +2 -2
  48. package/src/lib/components/spinner.tsx +2 -2
  49. package/src/lib/components/startup-progress.tsx +1 -1
  50. package/src/lib/context-mode-package.ts +1 -1
  51. package/src/lib/opencode-plugin-package.ts +3 -0
  52. package/src/lib/orchestrator/adapters/opencode.ts +53 -13
  53. package/src/lib/orchestrator/companion-hub.ts +3 -3
  54. package/src/lib/orchestrator/companion-registry-store.ts +1 -1
  55. package/src/lib/orchestrator/config-store.ts +2 -2
  56. package/src/lib/orchestrator/editor.ts +4 -12
  57. package/src/lib/orchestrator/engine-guard.ts +9 -1
  58. package/src/lib/orchestrator/projection-entries.ts +1 -1
  59. package/src/lib/orchestrator/projection-record.ts +1 -1
  60. package/src/lib/orchestrator/projection-types.ts +0 -4
  61. package/src/lib/orchestrator/repo-local-registry.ts +0 -1
  62. package/src/lib/orchestrator/root-context.ts +1 -1
  63. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  64. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  65. package/src/lib/orchestrator/types.ts +1 -1
  66. package/src/lib/orchestrator/working-repo-projection.ts +1 -1
  67. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  68. package/src/lib/package-paths.ts +1 -11
  69. package/src/lib/package-root.ts +43 -0
  70. package/src/lib/prebuilt-workspace.ts +1 -1
  71. package/src/lib/public-npm.ts +1 -1
  72. package/src/opencode/companion-hooks.ts +131 -153
  73. package/src/opencode/index.ts +12 -6
  74. package/src/opencode/tui.tsx +46 -49
  75. package/src/playbooks/companion-guidance.ts +1 -28
  76. package/src/runtime/companion-sync.ts +0 -3
  77. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  78. package/src/templates/providers/opencode/opencode.json +6 -4
  79. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
  80. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  81. package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
  82. package/src/tools/setup/capabilities/graphify.ts +2 -2
  83. package/src/tools/setup/capabilities/openspec.ts +2 -7
  84. package/src/tools/setup/capabilities/react-doctor.ts +1 -1
  85. package/src/tools/setup/capabilities/rtk.ts +2 -2
  86. package/src/tools/setup/capabilities/tokensave.ts +98 -12
  87. package/src/tools/setup/context-services.ts +3 -6
  88. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  89. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  90. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  91. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  92. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  93. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  94. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  95. package/src/tools/setup/mate.ts +1 -1
  96. package/src/tools/setup/providers/claude-format.ts +1 -1
  97. package/src/tools/setup/providers/claude.ts +1 -1
  98. package/src/tools/setup/providers/opencode-format.ts +220 -25
  99. package/src/tools/setup/providers/opencode.ts +63 -21
  100. package/src/tools/setup/runtime-documents.ts +9 -11
  101. package/src/tools/setup.ts +0 -4
  102. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  103. package/src/templates/providers/opencode/tui.json +0 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.17.0-canary.8",
3
+ "version": "0.17.1",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -16,6 +16,7 @@
16
16
  "./plugins": "./src/plugins.ts",
17
17
  "./runtime": "./src/runtime/index.ts",
18
18
  "./opencode": "./src/opencode/index.ts",
19
+ "./opencode/tui": "./src/opencode/tui.tsx",
19
20
  "./tui": "./src/tui.ts"
20
21
  },
21
22
  "publishConfig": {
@@ -31,11 +32,16 @@
31
32
  "format:check": "oxfmt src/ \"!**/*.generated.*\" \"!**/report-assets/**\" --check"
32
33
  },
33
34
  "dependencies": {
34
- "@opencode-ai/plugin": "^1.18.3",
35
- "@opentui/core": "^0.4.5",
36
- "@opentui/keymap": "^0.4.5",
37
- "@opentui/solid": "^0.4.5",
35
+ "@opencode/plugin": "^2.0.23",
36
+ "@opentui/core": "^0.5.14",
37
+ "@opentui/keymap": "^0.5.14",
38
+ "@opentui/solid": "^0.5.14",
38
39
  "@tanstack/highlight": "0.0.10",
40
+ "@xterm/addon-clipboard": "^0.2.0",
41
+ "@xterm/addon-fit": "^0.11.0",
42
+ "@xterm/addon-unicode11": "^0.9.0",
43
+ "@xterm/addon-web-links": "^0.12.0",
44
+ "@xterm/xterm": "^6.0.0",
39
45
  "hono": "^4.13.5",
40
46
  "ink": "^7.1.0",
41
47
  "react": "^19.2.7",
@@ -1,10 +1 @@
1
1
  export { runArtifactPublishCommand } from "./command";
2
- export type { PublishCommandDeps } from "./command";
3
- export type { FinishResult, FinishStep, FinishStatus } from "./engine";
4
- export type {
5
- ArtifactFinisher,
6
- FinishContext,
7
- FinisherFactory,
8
- Produced,
9
- ResolveResult,
10
- } from "./finisher";
@@ -269,7 +269,7 @@ async function resolved(
269
269
  }
270
270
 
271
271
  /** Tag namespace segregating spec publications from dated change anchors. */
272
- export const SPEC_TAG_NAMESPACE = "openspec/specs";
272
+ const SPEC_TAG_NAMESPACE = "openspec/specs";
273
273
  /** Spec ids named individually before the remainder collapses into a count. */
274
274
  const SPEC_LABEL_LIMIT = 3;
275
275
 
@@ -297,7 +297,7 @@ function specLabel(paths: string[]): string {
297
297
  }
298
298
 
299
299
  /** Commit subject for a spec publication; it belongs to no change, so it names specs, never an anchor. */
300
- export function specCommitSubject(paths: string[]): string {
300
+ function specCommitSubject(paths: string[]): string {
301
301
  const ids = specIds(paths);
302
302
  const named = ids.slice(0, SPEC_LABEL_LIMIT);
303
303
  const remaining = ids.length - named.length;
@@ -5,7 +5,7 @@ import { openspecFinisher } from "./openspec";
5
5
  * Artifact-kind → finisher factory. openspec is the only kind for now; a future ADR
6
6
  * finisher registers here and is selected via `--type`, with no engine changes.
7
7
  */
8
- export const FINISHERS: Record<string, FinisherFactory> = {
8
+ const FINISHERS: Record<string, FinisherFactory> = {
9
9
  openspec: openspecFinisher,
10
10
  };
11
11
 
@@ -52,7 +52,7 @@ export interface ArchiveEntry {
52
52
  const ARCHIVE_ANCHOR_PATTERN = /^\d{4}-\d{2}-\d{2}-.+$/;
53
53
  const ARCHIVE_DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/;
54
54
 
55
- export const ARCHIVE_RELATIVE_DIR = "openspec/changes/archive";
55
+ const ARCHIVE_RELATIVE_DIR = "openspec/changes/archive";
56
56
  export const SPECS_RELATIVE_DIR = "openspec/specs";
57
57
 
58
58
  /** The tag `mate artifact publish` creates, never a recomputed date. */
@@ -1,17 +1 @@
1
1
  export { runArtifactPendingCommand } from "./command";
2
- export type { PendingCommandDeps, PendingResult } from "./command";
3
- export {
4
- ARCHIVE_RELATIVE_DIR,
5
- SPECS_RELATIVE_DIR,
6
- discoverArchives,
7
- finishMarker,
8
- pendingArchives,
9
- unattributedSpecs,
10
- } from "./discovery";
11
- export type {
12
- ArchiveEntry,
13
- CommitState,
14
- SpecAttribution,
15
- UnattributedSpec,
16
- UncommittedSpecChange,
17
- } from "./discovery";
@@ -29,7 +29,7 @@ const GRAPHIFY_PATH_COMMANDS = new Set([
29
29
 
30
30
  const GRAPHIFY_GRAPH_COMMANDS = new Set(["query", "path", "explain", "affected", "tree"]);
31
31
 
32
- export function deriveGraphifyPaths(companionPath: string, repositoryId: string): GraphifyPaths {
32
+ function deriveGraphifyPaths(companionPath: string, repositoryId: string): GraphifyPaths {
33
33
  const graphifyOut = path.join(
34
34
  companionPath,
35
35
  GRAPHIFY_STORE_SEGMENT,
@@ -159,7 +159,7 @@ export async function runIndexCapCommand(args: string[], deps: SyncCapDeps = {})
159
159
  const repoPath = process.env.MATE_REPO_PATH ?? process.cwd();
160
160
  if (!(await isTokensaveInitialized())) {
161
161
  await ensureStoreExcluded(repoPath);
162
- const initOk = await runCapStep(() => runTokensave(["init"]));
162
+ const initOk = await runCapStep(() => runTokensave(["init", "--no-git-hook"]));
163
163
  if (!initOk) {
164
164
  failed = true;
165
165
  onStepDone("tokensave", false);
@@ -15,8 +15,9 @@ interface TokensaveCapDeps {
15
15
  * @description Forwards `[...args]` to the `tokensave` CLI, run from the
16
16
  * active working repository. Requires the `tokensave` capability to be enabled
17
17
  * in `.mate/config/framework.yaml` and a registered working repo for the
18
- * current `MATE_REPO_ID`. Ensures the `tokensave` binary is installed and
19
- * upgraded in that repo (via {@link ensureTokensaveInstalled}) before forwarding.
18
+ * current `MATE_REPO_ID`. Ensures the `tokensave` binary is installed in that
19
+ * repo (via {@link ensureTokensaveInstalled}) before forwarding. Never upgrades:
20
+ * this runs mid-session (e.g. `mate cap index`), and upgrades belong to `mate setup`.
20
21
  * @remarks The child process's exit code is propagated via `process.exitCode`.
21
22
  */
22
23
  export async function runTokensaveCapCommand(
@@ -49,7 +50,7 @@ export async function runTokensaveCapCommand(
49
50
  return;
50
51
  }
51
52
 
52
- if (!(await ensureInstalled(repo.path, { upgrade: true }))) {
53
+ if (!(await ensureInstalled(repo.path))) {
53
54
  process.exitCode = 1;
54
55
  return;
55
56
  }
@@ -5,6 +5,7 @@ import { runCompanionPrepareCommand } from "./prepare";
5
5
  import { runSetupCommand } from "../setup";
6
6
  import { runCompanionListCommand } from "./list";
7
7
  import { runCompanionSyncCommand } from "./sync";
8
+ import { runCompanionUpdateCommand } from "./update";
8
9
  import { runWorkspaceOpenCommand } from "./open";
9
10
  import { runCompanionTuiCommand } from "./tui";
10
11
 
@@ -31,6 +32,9 @@ export async function runCompanionCommand(
31
32
  case "sync":
32
33
  await runCompanionSyncCommand();
33
34
  return;
35
+ case "update":
36
+ await runCompanionUpdateCommand(argv);
37
+ return;
34
38
  case "open":
35
39
  await runWorkspaceOpenCommand();
36
40
  return;
@@ -8,6 +8,7 @@ import {
8
8
  updateHubPlugins,
9
9
  } from "../../../lib/orchestrator/companion-hub";
10
10
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
11
+ import { writeJsonStdout } from "../../write-json-stdout";
11
12
 
12
13
  function positionalArgs(argv: string[]): string[] {
13
14
  const values: string[] = [];
@@ -71,7 +72,7 @@ async function runHubSync(argv: string[]): Promise<void> {
71
72
  const results = await syncHub(process.cwd());
72
73
  const plugins = await updateHubPlugins(process.cwd());
73
74
  if (argv.includes("--json")) {
74
- console.log(JSON.stringify({ companions: results, plugins }));
75
+ await writeJsonStdout({ companions: results, plugins }, { space: 0 });
75
76
  return;
76
77
  }
77
78
  for (const result of results) console.log(`${result.id}: ${result.status} (${result.message})`);
@@ -1,6 +1,7 @@
1
1
  import { CompanionStore } from "../../../lib/orchestrator/companion-store";
2
2
  import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-context";
3
3
  import { renderRepoListTable } from "../../repo-list-table";
4
+ import { writeJsonStdout } from "../../write-json-stdout";
4
5
 
5
6
  /**
6
7
  * @command mate companion list
@@ -23,7 +24,7 @@ export async function runCompanionListCommand(argv: string[] = []): Promise<void
23
24
  const forceJson = argv.includes("--json");
24
25
 
25
26
  if (forceJson || !process.stdout.isTTY) {
26
- console.log(JSON.stringify({ repositories }, null, 2));
27
+ await writeJsonStdout({ repositories });
27
28
  return;
28
29
  }
29
30
 
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { FRAMEWORK_NAME } from "../../../framework";
4
4
  import { preparePrebuiltWorkspace } from "../../../lib/prebuilt-workspace-prepare";
5
5
 
6
- export const companionPrepareCommandDeps = {
6
+ const companionPrepareCommandDeps = {
7
7
  preparePrebuiltWorkspace,
8
8
  };
9
9
 
@@ -13,7 +13,7 @@ interface ParsedPrepareArgs {
13
13
  error?: string;
14
14
  }
15
15
 
16
- export function parsePrepareArgs(argv: string[]): ParsedPrepareArgs {
16
+ function parsePrepareArgs(argv: string[]): ParsedPrepareArgs {
17
17
  let bundle: string | undefined;
18
18
  const positional: string[] = [];
19
19
 
@@ -0,0 +1,334 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { FRAMEWORK_NAME } from "../../../framework";
6
+ import { ConfigStore } from "../../../lib/orchestrator/config-store";
7
+ import type { FrameworkConfig } from "../../../lib/orchestrator/types";
8
+ import { YamlFileStore } from "../../../lib/orchestrator/yaml-file-store";
9
+ import {
10
+ fetchLatestVersion,
11
+ getCurrentVersion,
12
+ isCanaryVersion,
13
+ isNewer,
14
+ } from "../../../lib/update-checker";
15
+ import { resolveCompanionRuntime } from "../../../runtime/env";
16
+ import { syncCompanionFiles } from "../../../tools/setup";
17
+ import { runCommand } from "../../../tools/setup/utils";
18
+ import { confirm } from "../../confirm";
19
+ import { defaultGitOps, type GitOps } from "../artifact/finish/git";
20
+ import { resolveCompanionPath } from "./tui";
21
+
22
+ /** Snapshot marker for a changed path that no longer exists on disk. */
23
+ export const DELETED = "<deleted>";
24
+
25
+ const SKILLS_LOCKFILE = "skills-lock.json";
26
+
27
+ /**
28
+ * OpenSpec CLI output (skills and opsx commands): fully regenerated by every update, so
29
+ * pre-existing uncommitted edits there are stale output, never local work worth separating.
30
+ */
31
+ const OPENSPEC_GENERATED = [
32
+ /^\.(claude|opencode)\/skills\/openspec-[^/]+(\/|$)/,
33
+ /^\.claude\/commands\/opsx(\/|$)/,
34
+ /^\.opencode\/commands\/opsx-[^/]+$/,
35
+ ];
36
+
37
+ export function isOpenSpecGenerated(relativePath: string): boolean {
38
+ return OPENSPEC_GENERATED.some((pattern) => pattern.test(relativePath));
39
+ }
40
+
41
+ function frameworkConfigPath(companionPath: string): string {
42
+ return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
43
+ }
44
+
45
+ /**
46
+ * Raw view of `framework.yaml`: `ConfigStore.load()` merges defaults, so saving
47
+ * its result would add keys the companion never declared.
48
+ */
49
+ class RawFrameworkConfigStore extends YamlFileStore<Record<string, unknown>> {
50
+ protected onMissing(): Promise<Record<string, unknown>> {
51
+ return Promise.resolve({});
52
+ }
53
+ }
54
+
55
+ async function hashTree(absolute: string, hash: crypto.Hash, relative: string): Promise<void> {
56
+ const stat = await fs.lstat(absolute);
57
+ if (stat.isDirectory()) {
58
+ const entries = (await fs.readdir(absolute)).toSorted();
59
+ for (const entry of entries) {
60
+ await hashTree(path.join(absolute, entry), hash, path.posix.join(relative, entry));
61
+ }
62
+ return;
63
+ }
64
+ hash.update(`${relative}\0`);
65
+ hash.update(stat.isSymbolicLink() ? await fs.readlink(absolute) : await fs.readFile(absolute));
66
+ hash.update("\0");
67
+ }
68
+
69
+ /** Porcelain collapses an untracked directory into one `dir/` entry, so directories hash their whole tree. */
70
+ async function snapshotPath(companionPath: string, relativePath: string): Promise<string> {
71
+ const absolute = path.join(companionPath, relativePath);
72
+ try {
73
+ const hash = crypto.createHash("sha256");
74
+ await hashTree(absolute, hash, ".");
75
+ return hash.digest("hex");
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return DELETED;
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ export interface CompanionUpdateDeps {
83
+ resolveCompanionPath: typeof resolveCompanionPath;
84
+ resolveProjectedCompanionPath: (cwd: string) => string | undefined;
85
+ getCurrentVersion: () => string;
86
+ fetchLatestVersion: () => Promise<string>;
87
+ loadConfig: (companionPath: string) => Promise<FrameworkConfig>;
88
+ syncCompanionFiles: (companionPath: string, config: FrameworkConfig) => Promise<void>;
89
+ /** Returns true when the file was rewritten. */
90
+ writeEngineFloor: (companionPath: string, range: string) => Promise<boolean>;
91
+ hasSkillsLock: (companionPath: string) => Promise<boolean>;
92
+ runSkillsUpdate: (companionPath: string) => Promise<void>;
93
+ createGitOps: (companionPath: string) => GitOps;
94
+ snapshotPath: (companionPath: string, relativePath: string) => Promise<string>;
95
+ confirm: (question: string) => Promise<boolean>;
96
+ isInteractive: () => boolean;
97
+ }
98
+
99
+ export const companionUpdateDeps: CompanionUpdateDeps = {
100
+ resolveCompanionPath,
101
+ resolveProjectedCompanionPath: (cwd) =>
102
+ resolveCompanionRuntime(process.env, cwd).context.companionPath || undefined,
103
+ getCurrentVersion,
104
+ fetchLatestVersion,
105
+ loadConfig: (companionPath) => new ConfigStore(frameworkConfigPath(companionPath)).load(),
106
+ syncCompanionFiles,
107
+ async writeEngineFloor(companionPath, range) {
108
+ const store = new RawFrameworkConfigStore(frameworkConfigPath(companionPath));
109
+ const raw = (await store.load()) ?? {};
110
+ const engines = (raw.engines ?? {}) as Record<string, string>;
111
+ if (engines[FRAMEWORK_NAME] === range) return false;
112
+ await store.save({ ...raw, engines: { ...engines, [FRAMEWORK_NAME]: range } });
113
+ return true;
114
+ },
115
+ hasSkillsLock: (companionPath) =>
116
+ fs
117
+ .access(path.join(companionPath, SKILLS_LOCKFILE))
118
+ .then(() => true)
119
+ .catch(() => false),
120
+ runSkillsUpdate: (companionPath) =>
121
+ runCommand("npx", ["skills", "update", "-p", "-y"], { cwd: companionPath }),
122
+ createGitOps: (companionPath) => defaultGitOps(companionPath, undefined),
123
+ snapshotPath,
124
+ confirm,
125
+ isInteractive: () => !!(process.stdin.isTTY && process.stdout.isTTY),
126
+ };
127
+
128
+ export interface UpdateChanges {
129
+ written: string[];
130
+ conflict: string[];
131
+ }
132
+
133
+ /** Content snapshot of every uncommitted path, taken before the update writes. */
134
+ export async function snapshotChangedPaths(
135
+ companionPath: string,
136
+ git: GitOps,
137
+ ): Promise<Map<string, string>> {
138
+ const before = new Map<string, string>();
139
+ for (const changed of await git.changedPaths()) {
140
+ before.set(changed, await companionUpdateDeps.snapshotPath(companionPath, changed));
141
+ }
142
+ return before;
143
+ }
144
+
145
+ /** Paths the update wrote, and the subset that already carried local edits (design D4). */
146
+ export async function diffChangedPaths(
147
+ companionPath: string,
148
+ git: GitOps,
149
+ before: Map<string, string>,
150
+ ): Promise<UpdateChanges> {
151
+ const written: string[] = [];
152
+ const conflict: string[] = [];
153
+ for (const changed of await git.changedPaths()) {
154
+ if (!before.has(changed)) written.push(changed);
155
+ }
156
+ for (const [changed, hash] of before) {
157
+ if ((await companionUpdateDeps.snapshotPath(companionPath, changed)) === hash) continue;
158
+ written.push(changed);
159
+ if (!isOpenSpecGenerated(changed)) conflict.push(changed);
160
+ }
161
+ return { written: written.toSorted(), conflict: conflict.toSorted() };
162
+ }
163
+
164
+ async function resolveCompanion(cwd: string): Promise<string | undefined> {
165
+ const resolved = await companionUpdateDeps.resolveCompanionPath(cwd);
166
+ if (resolved) return resolved;
167
+ return companionUpdateDeps.resolveProjectedCompanionPath(cwd);
168
+ }
169
+
170
+ function fail(message: string): void {
171
+ process.stderr.write(`${FRAMEWORK_NAME}: ${message}\n`);
172
+ process.exitCode = 1;
173
+ }
174
+
175
+ function errorMessage(error: unknown): string {
176
+ return error instanceof Error ? error.message : String(error);
177
+ }
178
+
179
+ function listPaths(paths: string[]): string {
180
+ return paths.map((changed) => ` ${changed}`).join("\n");
181
+ }
182
+
183
+ /** Version precondition; returns the running version when the update may proceed. */
184
+ async function checkVersion(): Promise<string | undefined> {
185
+ const current = companionUpdateDeps.getCurrentVersion();
186
+ if (isCanaryVersion(current)) {
187
+ fail(
188
+ `\`${FRAMEWORK_NAME} companion update\` requires a stable ${FRAMEWORK_NAME}; ${current} is a prerelease.`,
189
+ );
190
+ return undefined;
191
+ }
192
+ let latest: string;
193
+ try {
194
+ latest = await companionUpdateDeps.fetchLatestVersion();
195
+ } catch (error) {
196
+ fail(`could not check the registry for the newest version: ${errorMessage(error)}`);
197
+ return undefined;
198
+ }
199
+ if (isNewer(latest, current)) {
200
+ fail(
201
+ [
202
+ `${current} is installed, but the newest stable version is ${latest}.`,
203
+ ` Run \`${FRAMEWORK_NAME} update\` first.`,
204
+ ].join("\n"),
205
+ );
206
+ return undefined;
207
+ }
208
+ return current;
209
+ }
210
+
211
+ async function commitAndPush(
212
+ git: GitOps,
213
+ written: string[],
214
+ version: string,
215
+ yes: boolean,
216
+ ): Promise<void> {
217
+ if (!yes && !companionUpdateDeps.isInteractive()) {
218
+ process.stdout.write(
219
+ `${FRAMEWORK_NAME}: not an interactive shell; changes left uncommitted. Re-run with --yes to commit and push.\n`,
220
+ );
221
+ return;
222
+ }
223
+
224
+ const message = `chore(${FRAMEWORK_NAME}): update companion to ${FRAMEWORK_NAME} ${version}`;
225
+ if (!yes && !(await companionUpdateDeps.confirm(`Commit as "${message}"?`))) {
226
+ process.stdout.write(`${FRAMEWORK_NAME}: changes left uncommitted.\n`);
227
+ return;
228
+ }
229
+ try {
230
+ await git.add(written);
231
+ await git.commit(message, written);
232
+ } catch (error) {
233
+ fail(`commit failed: ${errorMessage(error)}`);
234
+ return;
235
+ }
236
+ process.stdout.write(`${FRAMEWORK_NAME}: committed "${message}".\n`);
237
+
238
+ if (!yes && !(await companionUpdateDeps.confirm("Push the commit?"))) return;
239
+ if (!(await git.hasUpstream())) {
240
+ process.stdout.write(
241
+ `${FRAMEWORK_NAME}: the current branch has no upstream; the commit stays local.\n`,
242
+ );
243
+ return;
244
+ }
245
+ const pushed = await git.push();
246
+ if (!pushed.ok) {
247
+ fail(`push failed; the commit stays local.\n ${pushed.error}`);
248
+ return;
249
+ }
250
+ process.stdout.write(`${FRAMEWORK_NAME}: pushed.\n`);
251
+ }
252
+
253
+ /**
254
+ * @command mate companion update
255
+ * @description Regenerates the companion's Mate-managed files and skills with
256
+ * the newest stable Mate, raises `engines.mate` to match, and offers to commit
257
+ * and push exactly the files the update wrote.
258
+ */
259
+ export async function runCompanionUpdateCommand(argv: string[]): Promise<void> {
260
+ const unknown = argv.filter((arg) => arg !== "--yes");
261
+ if (unknown.length > 0) {
262
+ fail(`unknown argument for companion update: ${unknown.join(" ")}`);
263
+ return;
264
+ }
265
+ const yes = argv.includes("--yes");
266
+
267
+ const companionPath = await resolveCompanion(process.cwd());
268
+ if (!companionPath) {
269
+ fail(
270
+ `no companion resolves for this directory. Run \`${FRAMEWORK_NAME} companion link\` first.`,
271
+ );
272
+ return;
273
+ }
274
+
275
+ const version = await checkVersion();
276
+ if (!version) return;
277
+
278
+ let git: GitOps;
279
+ let before: Map<string, string>;
280
+ try {
281
+ git = companionUpdateDeps.createGitOps(companionPath);
282
+ before = await snapshotChangedPaths(companionPath, git);
283
+ } catch (error) {
284
+ fail(errorMessage(error));
285
+ return;
286
+ }
287
+
288
+ try {
289
+ const config = await companionUpdateDeps.loadConfig(companionPath);
290
+ await companionUpdateDeps.syncCompanionFiles(companionPath, config);
291
+ } catch (error) {
292
+ fail(`companion file synchronization failed: ${errorMessage(error)}`);
293
+ return;
294
+ }
295
+
296
+ if (await companionUpdateDeps.hasSkillsLock(companionPath)) {
297
+ try {
298
+ await companionUpdateDeps.runSkillsUpdate(companionPath);
299
+ } catch (error) {
300
+ process.stderr.write(
301
+ `${FRAMEWORK_NAME}: warning: third-party skill refresh failed: ${errorMessage(error)}\n`,
302
+ );
303
+ }
304
+ }
305
+
306
+ try {
307
+ await companionUpdateDeps.writeEngineFloor(companionPath, `>=${version}`);
308
+ } catch (error) {
309
+ fail(`could not set engines.${FRAMEWORK_NAME}: ${errorMessage(error)}`);
310
+ return;
311
+ }
312
+
313
+ const { written, conflict } = await diffChangedPaths(companionPath, git, before);
314
+ if (written.length === 0) {
315
+ process.stdout.write(
316
+ `${FRAMEWORK_NAME}: ${companionPath} is already up to date with ${FRAMEWORK_NAME} ${version}.\n`,
317
+ );
318
+ return;
319
+ }
320
+ if (conflict.length > 0) {
321
+ fail(
322
+ [
323
+ "the update rewrote files that already had uncommitted changes; local edits and update output can no longer be separated, so nothing is committed:",
324
+ listPaths(conflict),
325
+ ].join("\n"),
326
+ );
327
+ return;
328
+ }
329
+
330
+ process.stdout.write(
331
+ [`${FRAMEWORK_NAME}: the update changed ${companionPath}:`, listPaths(written), ""].join("\n"),
332
+ );
333
+ await commitAndPush(git, written, version, yes);
334
+ }
@@ -10,6 +10,7 @@ import { resolveRootContext, type RootContext } from "../../lib/orchestrator/roo
10
10
  import type { CapabilityConfig, FrameworkConfig, HubMember } from "../../lib/orchestrator/types";
11
11
  import { getRequiredPluginDrift } from "../../tools/setup/policy";
12
12
  import { resolveCommandOnPath } from "../../tools/setup/utils";
13
+ import { writeJsonStdout } from "../write-json-stdout";
13
14
  import { printSection, renderKeyValueTable, renderTable } from "./status";
14
15
 
15
16
  interface DoctorDeps {
@@ -354,7 +355,7 @@ function renderHumanReport(report: DoctorReport): void {
354
355
  export async function runDoctorCommand(argv: string[] = [], deps: DoctorDeps = {}): Promise<void> {
355
356
  const report = await collectDoctorReport(deps);
356
357
  if (argv.includes("--json")) {
357
- console.log(JSON.stringify(report, null, 2));
358
+ await writeJsonStdout(report);
358
359
  return;
359
360
  }
360
361
  renderHumanReport(report);
@@ -15,8 +15,14 @@ import {
15
15
  type PluginInstallResult,
16
16
  } from "../../tools/setup/dynamic-plugins/install";
17
17
  import { registryConfigHint } from "../../tools/setup/dynamic-plugins/registry-hint";
18
+ import {
19
+ FrozenInstallError,
20
+ installDeclaredPluginsFrozen,
21
+ } from "../../tools/setup/dynamic-plugins/frozen";
22
+ import { verifyTrackedPluginOutputs } from "../../tools/setup/dynamic-plugins/staging";
23
+ import { verifyDeclaredPlugins } from "../../tools/setup/dynamic-plugins/verify";
18
24
 
19
- export function reportPluginInstallResults(results: PluginInstallResult[]): void {
25
+ export function reportPluginInstallResults(results: PluginInstallResult[]): boolean {
20
26
  for (const result of results) {
21
27
  if (result.status === "failed") {
22
28
  process.stderr.write(
@@ -27,6 +33,7 @@ export function reportPluginInstallResults(results: PluginInstallResult[]): void
27
33
  console.log(` installed plugin ${result.package}@${result.resolvedVersion}`);
28
34
  }
29
35
  }
36
+ return results.every((result) => result.status !== "failed");
30
37
  }
31
38
 
32
39
  function printPlanText(plan: Awaited<ReturnType<typeof inspectInstallPlan>>): void {
@@ -47,10 +54,17 @@ function printPlanText(plan: Awaited<ReturnType<typeof inspectInstallPlan>>): vo
47
54
  * @description Installs and verifies the core runtime and dependencies selected by the current companion.
48
55
  * @flags
49
56
  * - `--yes` — skip confirmation; required for non-TTY execution.
57
+ * - `--frozen-plugins` — deployment restore: install declared plugins exactly from the committed lockfile, verify plugin-generated tracked files are committed, never rewrite tracked files.
50
58
  */
51
59
  export async function runInstallCommand(argv: string[], cwd = process.cwd()): Promise<boolean> {
52
60
  const skipConfirm = argv.includes("--yes");
61
+ const frozen = argv.includes("--frozen-plugins");
53
62
  const context = await resolveInstallContext(cwd);
63
+ if (frozen && context.kind !== "companion") {
64
+ process.stderr.write(`${FRAMEWORK_NAME}: --frozen-plugins requires a companion context.\n`);
65
+ process.exitCode = 1;
66
+ return false;
67
+ }
54
68
  if (context.kind === "ambiguous") {
55
69
  process.stderr.write(`${context.message}\n`);
56
70
  process.exitCode = 1;
@@ -64,10 +78,35 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
64
78
  context.companionPath &&
65
79
  context.config.plugins?.length
66
80
  ) {
67
- reportPluginInstallResults(
68
- await installDeclaredPlugins(context.companionPath, context.config.plugins),
69
- );
81
+ let results: PluginInstallResult[];
82
+ try {
83
+ results = frozen
84
+ ? await installDeclaredPluginsFrozen(context.companionPath, context.config.plugins)
85
+ : await installDeclaredPlugins(context.companionPath, context.config.plugins);
86
+ } catch (error) {
87
+ if (!(error instanceof FrozenInstallError)) throw error;
88
+ process.stderr.write(`${FRAMEWORK_NAME}: frozen plugin install refused: ${error.message}\n`);
89
+ process.exitCode = 1;
90
+ return false;
91
+ }
92
+ if (!reportPluginInstallResults(results)) {
93
+ process.stderr.write(
94
+ `${FRAMEWORK_NAME}: a declared plugin failed to install; installation is incomplete.\n`,
95
+ );
96
+ process.exitCode = 1;
97
+ return false;
98
+ }
70
99
  await hydrateDynamicPlugins({ companionPath: context.companionPath });
100
+ if (frozen) {
101
+ const failures = await verifyDeclaredPlugins(context.companionPath);
102
+ if (failures.length > 0) {
103
+ for (const failure of failures) {
104
+ process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
105
+ }
106
+ process.exitCode = 1;
107
+ return false;
108
+ }
109
+ }
71
110
  }
72
111
  const plan = await inspectInstallPlan(buildInstallPlan(context));
73
112
  const missing = plan.requirements.filter((item) => !item.satisfied);
@@ -120,7 +159,22 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
120
159
  }
121
160
 
122
161
  try {
123
- await reconcileInstalledCompanion(plan);
162
+ if (frozen && plan.context.companionPath) {
163
+ const { syncCompanionFiles } = await import("../../tools/setup");
164
+ const drift = await verifyTrackedPluginOutputs(plan.context.companionPath, (staged) =>
165
+ syncCompanionFiles(staged, plan.context.config),
166
+ );
167
+ if (drift.length > 0) {
168
+ process.stderr.write(
169
+ `${FRAMEWORK_NAME}: plugin-generated files differ from the committed checkout: ${drift.join(", ")}\n` +
170
+ `Prepare and commit them in the companion's authoring flow; the checkout was left unchanged.\n`,
171
+ );
172
+ process.exitCode = 1;
173
+ return false;
174
+ }
175
+ } else {
176
+ await reconcileInstalledCompanion(plan);
177
+ }
124
178
  await saveCompleteInstallState(plan, execution.results);
125
179
  } catch (error) {
126
180
  process.stderr.write(