@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.
- package/claude-plugin/hooks/hooks.json +10 -1
- package/claude-plugin/hooks/session-banner.mjs +4 -3
- package/claude-plugin/hooks/session-prepare.mjs +9 -0
- package/opencode-plugin/src/add-dir.ts +10 -76
- package/opencode-plugin/src/audience-layer.ts +91 -0
- package/opencode-plugin/src/companion.ts +70 -18
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +60 -8
- package/package.json +3 -3
- package/src/cli/commands/artifact/finish/openspec.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +60 -21
- package/src/cli/commands/cap/index-cmd.ts +2 -11
- package/src/cli/commands/companion/link.ts +16 -11
- package/src/cli/commands/companion/list.ts +4 -5
- package/src/cli/commands/companion/open.ts +4 -5
- package/src/cli/commands/companion/tui.ts +1 -1
- package/src/cli/commands/doctor.ts +87 -5
- package/src/cli/commands/install.ts +8 -0
- package/src/cli/commands/report/renderer.ts +9 -19
- package/src/cli/commands/session/prepare.ts +119 -0
- package/src/cli/commands/shared/companion-selection.ts +2 -2
- package/src/cli/commands/studio/reports.ts +0 -1
- package/src/cli/commands/studio/server.ts +6 -2
- package/src/cli/commands/studio/terminal-launch.ts +16 -7
- package/src/cli/commands/unwrap.ts +28 -3
- package/src/cli/commands/wrap.ts +79 -60
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/main.ts +17 -0
- package/src/cli/usage.ts +5 -2
- package/src/hooks/session-banner.ts +19 -10
- package/src/hooks/session-guidance.ts +20 -2
- package/src/hooks/session-prepare.ts +101 -0
- package/src/lib/fs-utils.ts +13 -0
- package/src/lib/orchestrator/adapters/base.ts +45 -9
- package/src/lib/orchestrator/adapters/claude.ts +241 -30
- package/src/lib/orchestrator/adapters/opencode.ts +72 -34
- package/src/lib/orchestrator/companion-git-sync.ts +28 -1
- package/src/lib/orchestrator/companion-hub.ts +2 -1
- package/src/lib/orchestrator/config-store.ts +83 -1
- package/src/lib/orchestrator/framework-context.ts +5 -29
- package/src/lib/orchestrator/launcher.ts +36 -171
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
- package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
- package/src/lib/orchestrator/projection-entries.ts +124 -11
- package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
- package/src/lib/orchestrator/projection-record.ts +7 -1
- package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
- package/src/lib/orchestrator/projection-types.ts +47 -34
- package/src/lib/orchestrator/repository-link.ts +473 -0
- package/src/lib/orchestrator/root-context.ts +6 -2
- package/src/lib/orchestrator/session-prepare.ts +503 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +3 -1
- package/src/lib/orchestrator/working-repo-projection.ts +73 -160
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +222 -0
- package/src/lib/runtime-directory-refresh.ts +186 -0
- package/src/lib/update-checker.ts +6 -0
- package/src/opencode/companion-hooks.ts +30 -135
- package/src/opencode/companion-policy.ts +26 -10
- package/src/opencode/index.ts +1 -7
- package/src/opencode/projected-guidance.ts +6 -13
- package/src/opencode/session-notes.ts +76 -0
- package/src/opencode/session-prepare.ts +139 -0
- package/src/opencode/tui.tsx +56 -9
- package/src/runtime/audience-layer.ts +84 -0
- package/src/runtime/claude-delivery-plan.ts +73 -0
- package/src/runtime/claude-hook-group.ts +21 -0
- package/src/runtime/companion-lock.ts +121 -0
- package/src/runtime/companion-sync.ts +66 -9
- package/src/runtime/guidance.ts +17 -0
- package/src/runtime/opencode-audience-layer.ts +176 -0
- package/src/runtime/policy.ts +14 -1
- package/src/runtime/projection.ts +1 -1
- package/src/runtime/runtime-directory.ts +43 -0
- package/src/runtime/runtime-document-manifest.ts +352 -0
- package/src/runtime/session-prepare-runner.ts +148 -0
- package/src/runtime/wrap-state.ts +94 -0
- package/src/runtime/write-atomic.ts +27 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
- package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
- package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
- package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
- package/src/tools/setup/audience-isolation.fixture.ts +325 -0
- package/src/tools/setup/audience-layer.ts +207 -0
- package/src/tools/setup/audience-registrations.ts +52 -0
- package/src/tools/setup/capabilities/openspec.ts +9 -0
- package/src/tools/setup/capabilities/react-doctor.ts +34 -50
- package/src/tools/setup/capabilities/tokensave.ts +11 -0
- package/src/tools/setup/contribution-ledger.ts +207 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
- package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
- package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
- package/src/tools/setup/engine.ts +152 -10
- package/src/tools/setup/isolation-diagnostics.ts +139 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/plugin.ts +19 -4
- package/src/tools/setup/plugins/gitignore.ts +7 -1
- package/src/tools/setup/providers/claude-format.ts +11 -18
- package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
- package/src/tools/setup/providers/claude.ts +50 -8
- package/src/tools/setup/providers/opencode.ts +10 -0
- package/src/tools/setup/registry.ts +24 -2
- package/src/tools/setup/runtime-documents.ts +152 -32
- package/src/tools/setup/surface-target.ts +13 -4
- package/src/tools/setup/upgrade-sweep.ts +153 -0
- package/src/tools/setup/working-repo-cleanup.ts +2 -0
- package/src/tools/setup/working-repo-local-state.ts +91 -14
- package/src/tools/setup.ts +11 -8
- package/wrappers/bin/openspec +42 -1
- package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
- package/src/lib/orchestrator/companion-resolver.ts +0 -179
- package/src/lib/orchestrator/companion-store.ts +0 -94
- package/src/lib/orchestrator/repo-local-registry.ts +0 -143
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
- package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
- package/src/templates/report-assets/README.md +0 -32
- package/src/templates/report-assets/mermaid.LICENSE +0 -21
- 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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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(
|
|
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
|
-
|
|
216
|
-
): Promise<
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
}
|
package/src/opencode/index.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
+
}
|