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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/claude-plugin/hooks/hooks.json +10 -1
  2. package/claude-plugin/hooks/session-banner.mjs +4 -3
  3. package/claude-plugin/hooks/session-prepare.mjs +9 -0
  4. package/opencode-plugin/src/add-dir.ts +10 -76
  5. package/opencode-plugin/src/audience-layer.ts +91 -0
  6. package/opencode-plugin/src/companion.ts +70 -18
  7. package/opencode-plugin/src/launch-environment.ts +29 -0
  8. package/opencode-plugin/src/server.ts +60 -8
  9. package/package.json +3 -3
  10. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  11. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  12. package/src/cli/commands/cap/index-cmd.ts +2 -11
  13. package/src/cli/commands/companion/link.ts +16 -11
  14. package/src/cli/commands/companion/list.ts +4 -5
  15. package/src/cli/commands/companion/open.ts +4 -5
  16. package/src/cli/commands/companion/tui.ts +1 -1
  17. package/src/cli/commands/doctor.ts +87 -5
  18. package/src/cli/commands/install.ts +8 -0
  19. package/src/cli/commands/report/renderer.ts +9 -19
  20. package/src/cli/commands/session/prepare.ts +119 -0
  21. package/src/cli/commands/shared/companion-selection.ts +2 -2
  22. package/src/cli/commands/studio/reports.ts +0 -1
  23. package/src/cli/commands/studio/server.ts +6 -2
  24. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  25. package/src/cli/commands/unwrap.ts +28 -3
  26. package/src/cli/commands/wrap.ts +79 -60
  27. package/src/cli/companion-selector.tsx +1 -1
  28. package/src/cli/main.ts +17 -0
  29. package/src/cli/usage.ts +5 -2
  30. package/src/hooks/session-banner.ts +19 -10
  31. package/src/hooks/session-guidance.ts +20 -2
  32. package/src/hooks/session-prepare.ts +101 -0
  33. package/src/lib/fs-utils.ts +13 -0
  34. package/src/lib/orchestrator/adapters/base.ts +45 -9
  35. package/src/lib/orchestrator/adapters/claude.ts +241 -30
  36. package/src/lib/orchestrator/adapters/opencode.ts +72 -34
  37. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  38. package/src/lib/orchestrator/companion-hub.ts +2 -1
  39. package/src/lib/orchestrator/config-store.ts +83 -1
  40. package/src/lib/orchestrator/framework-context.ts +5 -29
  41. package/src/lib/orchestrator/launcher.ts +36 -171
  42. package/src/lib/orchestrator/openspec-store.ts +202 -0
  43. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  44. package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
  45. package/src/lib/orchestrator/projection-entries.ts +124 -11
  46. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  47. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  48. package/src/lib/orchestrator/projection-record.ts +7 -1
  49. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  50. package/src/lib/orchestrator/projection-types.ts +47 -34
  51. package/src/lib/orchestrator/repository-link.ts +473 -0
  52. package/src/lib/orchestrator/root-context.ts +6 -2
  53. package/src/lib/orchestrator/session-prepare.ts +503 -0
  54. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  55. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  56. package/src/lib/orchestrator/working-repo-projection.ts +73 -160
  57. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  58. package/src/lib/orchestrator/wrap-pass.ts +222 -0
  59. package/src/lib/runtime-directory-refresh.ts +186 -0
  60. package/src/lib/update-checker.ts +6 -0
  61. package/src/opencode/companion-hooks.ts +30 -135
  62. package/src/opencode/companion-policy.ts +26 -10
  63. package/src/opencode/index.ts +1 -7
  64. package/src/opencode/projected-guidance.ts +6 -13
  65. package/src/opencode/session-notes.ts +76 -0
  66. package/src/opencode/session-prepare.ts +139 -0
  67. package/src/opencode/tui.tsx +56 -9
  68. package/src/runtime/audience-layer.ts +84 -0
  69. package/src/runtime/claude-delivery-plan.ts +73 -0
  70. package/src/runtime/claude-hook-group.ts +21 -0
  71. package/src/runtime/companion-lock.ts +121 -0
  72. package/src/runtime/companion-sync.ts +66 -9
  73. package/src/runtime/guidance.ts +17 -0
  74. package/src/runtime/opencode-audience-layer.ts +176 -0
  75. package/src/runtime/policy.ts +14 -1
  76. package/src/runtime/projection.ts +1 -1
  77. package/src/runtime/runtime-directory.ts +43 -0
  78. package/src/runtime/runtime-document-manifest.ts +352 -0
  79. package/src/runtime/session-prepare-runner.ts +148 -0
  80. package/src/runtime/wrap-state.ts +94 -0
  81. package/src/runtime/write-atomic.ts +27 -0
  82. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  83. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  84. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  85. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  86. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  87. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  88. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  89. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  90. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  91. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  92. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  93. package/src/tools/setup/audience-layer.ts +207 -0
  94. package/src/tools/setup/audience-registrations.ts +52 -0
  95. package/src/tools/setup/capabilities/openspec.ts +9 -0
  96. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  97. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  98. package/src/tools/setup/contribution-ledger.ts +207 -0
  99. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  100. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  101. package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
  102. package/src/tools/setup/engine.ts +152 -10
  103. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  104. package/src/tools/setup/mate.ts +1 -1
  105. package/src/tools/setup/plugin.ts +19 -4
  106. package/src/tools/setup/plugins/gitignore.ts +7 -1
  107. package/src/tools/setup/providers/claude-format.ts +11 -18
  108. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  109. package/src/tools/setup/providers/claude.ts +50 -8
  110. package/src/tools/setup/providers/opencode.ts +10 -0
  111. package/src/tools/setup/registry.ts +24 -2
  112. package/src/tools/setup/runtime-documents.ts +152 -32
  113. package/src/tools/setup/surface-target.ts +13 -4
  114. package/src/tools/setup/upgrade-sweep.ts +153 -0
  115. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  116. package/src/tools/setup/working-repo-local-state.ts +91 -14
  117. package/src/tools/setup.ts +11 -8
  118. package/wrappers/bin/openspec +42 -1
  119. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  120. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  121. package/src/lib/orchestrator/companion-store.ts +0 -94
  122. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  123. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  124. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  125. package/src/templates/report-assets/README.md +0 -32
  126. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  127. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -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;
@@ -1,8 +1,6 @@
1
- import { spawn } from "node:child_process";
2
- import fs from "node:fs";
3
1
  import path from "node:path";
4
2
 
5
- import type { Cleanup, Context } from "@opencode/plugin/promise/plugin";
3
+ import type { Context } from "@opencode/plugin/promise/plugin";
6
4
 
7
5
  import {
8
6
  companionForkRefusal,
@@ -11,6 +9,8 @@ import {
11
9
  } from "../runtime/companion-sync";
12
10
  import { hasLaunchEnvironment } from "../runtime/env";
13
11
  import { MATE_ENV } from "../runtime/env-names";
12
+ import { resolveProjection } from "../runtime/projection";
13
+ import { readWrapStateSync } from "../runtime/wrap-state";
14
14
  import {
15
15
  buildArtifactError,
16
16
  extractPatchPaths,
@@ -20,103 +20,13 @@ import {
20
20
  type CompanionContext,
21
21
  } from "./companion-policy";
22
22
 
23
- const REACT_DOCTOR_VERSION = "0.9.13";
24
-
25
- const REACT_DOCTOR_NON_LINT_FAILURES = [
26
- /No React dependency found/i,
27
- /Could not resolve config/i,
28
- /is not a git repository/i,
29
- ];
30
-
31
- const REACT_DOCTOR_EDIT_TOOLS = new Set(["write", "edit", "apply_patch"]);
32
- const REACT_DOCTOR_ARGS = [
33
- "--yes",
34
- "--verbose",
35
- "--scope",
36
- "changed",
37
- "--base",
38
- "HEAD",
39
- "--blocking",
40
- "warning",
41
- "--no-score",
42
- "--max-duration",
43
- "30",
44
- ];
45
-
46
- export type CommandResult = { exitCode: number | null; output: string };
47
- export type CommandRunner = (
48
- command: string,
49
- args: string[],
50
- cwd: string,
51
- ) => Promise<CommandResult>;
52
- type SessionPrompt = Pick<Context["session"], "prompt">;
53
-
54
- /** Combined stdout and stderr; rejects only when the process cannot be spawned. */
55
- export const runCommand: CommandRunner = (command, args, cwd) =>
56
- new Promise((resolve, reject) => {
57
- const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
58
- let output = "";
59
- child.stdout.setEncoding("utf8").on("data", (chunk: string) => (output += chunk));
60
- child.stderr.setEncoding("utf8").on("data", (chunk: string) => (output += chunk));
61
- child.once("error", reject);
62
- child.once("close", (exitCode) => resolve({ exitCode, output: output.trim() }));
63
- });
64
-
65
- function reactDoctorCommand(repo: string): { command: string; args: string[] } {
66
- const localBin = path.join(repo, "node_modules", ".bin", "react-doctor");
67
- const mateBin = process.env.MATE_REACT_DOCTOR_BIN_PATH;
68
- const doctorBin = mateBin && fs.existsSync(mateBin) ? mateBin : localBin;
69
- return fs.existsSync(doctorBin)
70
- ? { command: doctorBin, args: REACT_DOCTOR_ARGS }
71
- : {
72
- command: "npx",
73
- args: ["--yes", `react-doctor@${REACT_DOCTOR_VERSION}`, ...REACT_DOCTOR_ARGS],
74
- };
75
- }
76
-
77
- /**
78
- * Tracks edited sessions and scans each at most once per idle, never twice
79
- * concurrently for the same session.
80
- */
81
- export function createReactDoctorScanner(
82
- context: CompanionContext,
83
- session: SessionPrompt,
84
- run: CommandRunner = runCommand,
85
- ) {
86
- const dirtySessions = new Set<string>();
87
- const scansInFlight = new Set<string>();
88
-
89
- return {
90
- markEdited(tool: string, sessionID: string): void {
91
- if (REACT_DOCTOR_EDIT_TOOLS.has(tool)) dirtySessions.add(sessionID);
92
- },
93
- async idle(sessionID: string): Promise<void> {
94
- const repo = context.repositoryPath;
95
- if (!repo || scansInFlight.has(sessionID) || !dirtySessions.delete(sessionID)) return;
96
- scansInFlight.add(sessionID);
97
- try {
98
- const { command, args } = reactDoctorCommand(repo);
99
- const { exitCode, output } = await run(command, args, repo);
100
- if (exitCode === 0 || !output) return;
101
- if (REACT_DOCTOR_NON_LINT_FAILURES.some((re) => re.test(output))) return;
102
-
103
- await session
104
- .prompt({
105
- sessionID,
106
- text:
107
- "React Doctor found issues in the changed files. Review this output and fix " +
108
- "the regressions before finishing. For confirmed issues that cannot be fixed " +
109
- "now, create GitHub issues with the rule, file/line, confidence, impact, and " +
110
- `proposed fix.\n\n${output}`,
111
- } as never)
112
- .catch(() => {});
113
- } catch {
114
- /** Never break the session because a diagnostic scan failed. */
115
- } finally {
116
- scansInFlight.delete(sessionID);
117
- }
118
- },
119
- };
23
+ /** The Working Repository above `directory`, when it is wrapped for OpenCode. */
24
+ export function repositoryWrappedForOpenCode(directory: string): string | undefined {
25
+ const projection = resolveProjection(directory);
26
+ if (!projection || !readWrapStateSync(projection.repoRoot).runtimes.has("opencode")) {
27
+ return undefined;
28
+ }
29
+ return projection.repoRoot;
120
30
  }
121
31
 
122
32
  /**
@@ -126,14 +36,17 @@ export function createReactDoctorScanner(
126
36
  const repairedCompanions = new Set<string>();
127
37
 
128
38
  /**
129
- * Session start is the only point at which the companion may move without
130
- * invalidating a read the session has already taken. The launch-environment
131
- * gate comes first so a Managed Session does no Git work at all; the consent
132
- * gate is the operation's own.
39
+ * A fetch at session start, for a location Session Prepare does not serve.
40
+ * In a repository wrapped for OpenCode the plugin's background sync performs
41
+ * the same unattended operation, so this one steps aside. Elsewhere it only
42
+ * tries the companion lock: a held lock means another sync is already on it.
43
+ * The launch-environment gate comes first so a Managed Session does no Git
44
+ * work at all; the consent gate is the operation's own.
133
45
  */
134
46
  export async function repairCompanionGitOnce(
135
47
  context: CompanionContext,
136
48
  env: Record<string, string | undefined> = process.env,
49
+ directory?: string,
137
50
  ): Promise<string[]> {
138
51
  if (
139
52
  !context.companionPath ||
@@ -141,6 +54,7 @@ export async function repairCompanionGitOnce(
141
54
  (env[MATE_ENV.repositoryPath] !== undefined || env[MATE_ENV.gitAutoMode] !== "1"))
142
55
  )
143
56
  return [];
57
+ if (directory && repositoryWrappedForOpenCode(directory)) return [];
144
58
  if (repairedCompanions.has(context.companionPath)) return [];
145
59
  repairedCompanions.add(context.companionPath);
146
60
 
@@ -149,7 +63,10 @@ export async function repairCompanionGitOnce(
149
63
  * unattended. The V2 server API has no toast; the notes reach the operator
150
64
  * through the persisted record and the TUI's staleness lines.
151
65
  */
152
- return unattendedSyncStalenessLines(await syncCompanionUnattended(context.companionPath));
66
+ return unattendedSyncStalenessLines(
67
+ await syncCompanionUnattended(context.companionPath, { lock: "try" }),
68
+ "opencode",
69
+ );
153
70
  }
154
71
 
155
72
  /** Test seam: the once-per-session guard is process-wide by design. */
@@ -182,6 +99,7 @@ export async function guardToolInput(
182
99
  context: CompanionContext,
183
100
  tool: string,
184
101
  input: unknown,
102
+ env: Record<string, string | undefined> = process.env,
185
103
  ): Promise<void> {
186
104
  const args = (input ?? {}) as Record<string, unknown>;
187
105
  const filePaths =
@@ -196,50 +114,27 @@ export async function guardToolInput(
196
114
  throw new Error(buildArtifactError(context, filePath));
197
115
  }
198
116
  // oxlint-disable-next-line no-await-in-loop -- the first refusal wins
199
- await refuseForkedCompanionWrite(context, filePath);
117
+ await refuseForkedCompanionWrite(context, filePath, env);
200
118
  }
201
119
  }
202
120
 
203
- export type CompanionHooksOptions = {
204
- /** Test seam for the React Doctor process. */
205
- run?: CommandRunner;
206
- };
207
-
208
121
  /**
209
122
  * Registers the companion guardrails on an OpenCode V2 plugin context.
210
- * The caller resolves `context` and skips this when no companion resolved.
123
+ * The caller resolves `context` and skips this when no companion resolved;
124
+ * `env` is the launch environment the caller decided to trust.
211
125
  */
212
126
  export async function registerCompanionHooks(
213
127
  api: Context,
214
128
  context: CompanionContext,
215
- options: CompanionHooksOptions = {},
216
- ): Promise<Cleanup | undefined> {
129
+ env: Record<string, string | undefined> = process.env,
130
+ ): Promise<void> {
217
131
  /**
218
132
  * Anything escaping the repair would cost the session every guardrail
219
133
  * below to save a synchronization that is optional by design.
220
134
  */
221
- await repairCompanionGitOnce(context).catch(() => []);
135
+ await repairCompanionGitOnce(context, env, api.location.directory).catch(() => []);
222
136
 
223
137
  await api.tool.hook("execute.before", (event) =>
224
- guardToolInput(context, event.tool, event.input),
138
+ guardToolInput(context, event.tool, event.input, env),
225
139
  );
226
-
227
- if (!context.repositoryPath || !context.reactDoctorEnabled) return;
228
-
229
- const scanner = createReactDoctorScanner(context, api.session, options.run);
230
- await api.tool.hook("execute.after", (event) => {
231
- if (event.status === "completed") scanner.markEdited(event.tool, event.sessionID);
232
- });
233
-
234
- const controller = new AbortController();
235
- void (async () => {
236
- try {
237
- for await (const event of api.event.subscribe({ signal: controller.signal })) {
238
- if (event.type === "session.idle") await scanner.idle(event.data.sessionID);
239
- }
240
- } catch {
241
- /** Plugin shutdown aborts the subscription; event delivery is best effort. */
242
- }
243
- })();
244
- return () => controller.abort();
245
140
  }
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { persistedCompanionGitStalenessLines } from "../runtime/companion-sync";
6
6
  import { resolveCompanionRuntime, type CompanionRuntimeContext } from "../runtime/env";
7
7
  import { projectionFreshness, projectionStalenessLines } from "../runtime/freshness";
8
+ import { readSessionNotes } from "./session-notes";
8
9
 
9
10
  export type CompanionContext = CompanionRuntimeContext & {
10
11
  agentsMd: string;
@@ -189,14 +190,29 @@ export function readContext(
189
190
  }
190
191
  }
191
192
 
192
- return {
193
- ...context,
194
- agentsMd,
195
- stalenessLines: projection
196
- ? [
197
- ...projectionStalenessLines(projection, projectionFreshness(projection)),
198
- ...persistedCompanionGitStalenessLines(context.companionPath),
199
- ]
200
- : [],
201
- };
193
+ return { ...context, agentsMd, stalenessLines: stalenessLinesFor(context, projection) };
194
+ }
195
+
196
+ function stalenessLinesFor(
197
+ context: CompanionRuntimeContext,
198
+ projection: ReturnType<typeof resolveCompanionRuntime>["projection"],
199
+ ): string[] {
200
+ if (!projection) return [];
201
+ /** A sync note repeats the persisted Git line it came from. */
202
+ return [
203
+ ...new Set([
204
+ ...projectionStalenessLines(projection, projectionFreshness(projection)),
205
+ ...persistedCompanionGitStalenessLines(context.companionPath, undefined, "opencode"),
206
+ ...(context.repositoryPath ? readSessionNotes(context.repositoryPath) : []),
207
+ ]),
208
+ ];
209
+ }
210
+
211
+ /** The session state alone, cheap enough for the TUI to poll: no `AGENTS.md` read, no Git. */
212
+ export function readStalenessLines(
213
+ env: Record<string, string | undefined> = process.env,
214
+ cwd: string = process.cwd(),
215
+ ): string[] {
216
+ const { context, projection } = resolveCompanionRuntime(env, cwd);
217
+ return stalenessLinesFor(context, projection);
202
218
  }
@@ -5,12 +5,6 @@
5
5
  * test). Must not reach OpenTUI or Solid: the Mate TUI plugin lives on the
6
6
  * `./opencode/tui` subpath.
7
7
  */
8
- export {
9
- createReactDoctorScanner,
10
- guardToolInput,
11
- registerCompanionHooks,
12
- type CommandRunner,
13
- type CompanionHooksOptions,
14
- } from "./companion-hooks";
8
+ export { guardToolInput, registerCompanionHooks } from "./companion-hooks";
15
9
  export { resolveOpenCodeGuidance, type OpenCodeGuidanceResolution } from "./projected-guidance";
16
10
  export * from "./companion-policy";
@@ -1,14 +1,13 @@
1
1
  import { buildOpenCodeGuidance } from "../runtime/companion-guidance";
2
2
  import { MATE_ENV } from "../runtime/env-names";
3
- import { hasLaunchEnvironment } from "../runtime/env";
4
3
  import { parseGuidanceContent, type MateGuidanceFile } from "../runtime/guidance";
5
4
  import { readCompanionPolicy } from "../runtime/policy";
6
5
  import { resolveProjection } from "../runtime/projection";
7
6
 
8
7
  /**
9
8
  * `null` guidance with no errors is the inert case — no companion resolves, so
10
- * the plugin contributes nothing. Errors are startup failures: a managed launch
11
- * that failed to inject its payload is broken and must say so.
9
+ * the plugin contributes nothing. Errors are startup failures: a payload that
10
+ * does not parse, or projected guidance that cannot be built.
12
11
  */
13
12
  export interface OpenCodeGuidanceResolution {
14
13
  guidance: MateGuidanceFile | null;
@@ -26,9 +25,10 @@ export interface OpenCodeGuidanceResolution {
26
25
  * means a Capability toggled after a wrap changes the guidance on the next
27
26
  * session with no re-wrap.
28
27
  *
29
- * A missing payload while a launch environment *is* present stays a hard error.
30
- * That combination means a managed launch failed to inject, which the projection
31
- * must not paper over.
28
+ * Without a payload the projection decides, whatever other `MATE_*` variables
29
+ * `env` carries: those are inherited by every descendant of a Mate session and
30
+ * do not mark a managed launch. A present but malformed payload stays a hard
31
+ * error.
32
32
  */
33
33
  export function resolveOpenCodeGuidance(
34
34
  env: Record<string, string | undefined> = process.env,
@@ -40,13 +40,6 @@ export function resolveOpenCodeGuidance(
40
40
  return { guidance: errors.length > 0 ? null : guidance, errors };
41
41
  }
42
42
 
43
- if (hasLaunchEnvironment(env)) {
44
- return {
45
- guidance: null,
46
- errors: [`missing ${MATE_ENV.guidanceJson} in the launch environment`],
47
- };
48
- }
49
-
50
43
  const projection = resolveProjection(cwd);
51
44
  if (!projection) return { guidance: null, errors: [] };
52
45
 
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { repoLocalDirPath } from "../runtime/repo-local";
6
+ import { writeAtomic } from "../runtime/write-atomic";
7
+
8
+ /**
9
+ * The last plugin prepare's operator notes, per phase. The V2 server API has
10
+ * no toast, so the TUI reads them with its staleness lines and toasts new ones.
11
+ */
12
+ export type SessionNotePhase = "prepare" | "sync";
13
+
14
+ type SessionNotes = Partial<Record<SessionNotePhase, string[]>>;
15
+
16
+ function notesPath(repoPath: string): string {
17
+ return path.join(repoLocalDirPath(repoPath), "session-notes.json");
18
+ }
19
+
20
+ function readAll(repoPath: string): SessionNotes {
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(notesPath(repoPath), "utf8")) as unknown;
23
+ return typeof parsed === "object" && parsed !== null ? (parsed as SessionNotes) : {};
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Per notes file, the record still in flight. Each record reads the file and
31
+ * writes it back; the background sync's report can overlap the next prepare's,
32
+ * and an unordered write would bring back notes the other just cleared.
33
+ */
34
+ const inFlight = new Map<string, Promise<void>>();
35
+
36
+ /** Replaces one phase's notes; an empty list clears them. Best-effort. */
37
+ export function recordSessionNotes(
38
+ repoPath: string,
39
+ phase: SessionNotePhase,
40
+ notes: readonly string[],
41
+ ): Promise<void> {
42
+ const target = notesPath(repoPath);
43
+ const recorded = (inFlight.get(target) ?? Promise.resolve()).then(() =>
44
+ writeNotes(repoPath, target, phase, notes),
45
+ );
46
+ inFlight.set(target, recorded);
47
+ void recorded.then(() => {
48
+ if (inFlight.get(target) === recorded) inFlight.delete(target);
49
+ });
50
+ return recorded;
51
+ }
52
+
53
+ async function writeNotes(
54
+ repoPath: string,
55
+ target: string,
56
+ phase: SessionNotePhase,
57
+ notes: readonly string[],
58
+ ): Promise<void> {
59
+ const next: SessionNotes = { ...readAll(repoPath), [phase]: [...notes] };
60
+ try {
61
+ if (Object.values(next).every((lines) => !lines?.length)) {
62
+ await fsp.rm(target, { force: true });
63
+ return;
64
+ }
65
+ await fsp.mkdir(path.dirname(target), { recursive: true });
66
+ await writeAtomic(target, `${JSON.stringify(next, null, 2)}\n`);
67
+ } catch {
68
+ /* the log already carries the notes */
69
+ }
70
+ }
71
+
72
+ export function readSessionNotes(repoPath: string): string[] {
73
+ return Object.values(readAll(repoPath)).flatMap((lines) =>
74
+ Array.isArray(lines) ? lines.filter((line) => typeof line === "string") : [],
75
+ );
76
+ }