@uniqbit/mate-core 0.18.3-canary.0 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/companion.ts +47 -12
- package/opencode-plugin/src/server.ts +39 -4
- 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/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 +1 -1
- 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/unwrap.ts +28 -3
- package/src/cli/commands/wrap.ts +53 -59
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/main.ts +17 -0
- package/src/cli/usage.ts +3 -1
- package/src/hooks/session-banner.ts +19 -10
- package/src/hooks/session-prepare.ts +101 -0
- package/src/lib/fs-utils.ts +13 -0
- package/src/lib/orchestrator/adapters/base.ts +43 -8
- package/src/lib/orchestrator/adapters/claude.ts +130 -29
- package/src/lib/orchestrator/adapters/opencode.ts +17 -19
- 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 +56 -2
- package/src/lib/orchestrator/framework-context.ts +5 -29
- package/src/lib/orchestrator/launcher.ts +36 -171
- package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
- package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
- package/src/lib/orchestrator/projection-entries.ts +78 -11
- package/src/lib/orchestrator/projection-opencode-copies.ts +230 -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 +38 -33
- 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 +425 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +3 -1
- package/src/lib/orchestrator/types.ts +4 -1
- package/src/lib/orchestrator/working-repo-projection.ts +71 -159
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +131 -0
- package/src/lib/runtime-directory-refresh.ts +186 -0
- package/src/lib/update-checker.ts +6 -0
- package/src/opencode/companion-hooks.ts +24 -132
- package/src/opencode/companion-policy.ts +26 -10
- package/src/opencode/index.ts +1 -7
- 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/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/runtime-directory.ts +43 -0
- package/src/runtime/runtime-document-manifest.ts +335 -0
- package/src/runtime/session-prepare-runner.ts +148 -0
- package/src/runtime/wrap-state.ts +90 -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/capabilities/react-doctor.ts +34 -50
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
- package/src/tools/setup/engine.ts +15 -4
- package/src/tools/setup/mate.ts +1 -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 +23 -6
- package/src/tools/setup/runtime-documents.ts +152 -32
- package/src/tools/setup/working-repo-local-state.ts +82 -13
- package/src/tools/setup.ts +2 -2
- 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,230 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
copies,
|
|
8
|
+
readManifest,
|
|
9
|
+
updateManifest,
|
|
10
|
+
withCopies,
|
|
11
|
+
type CopyRecord,
|
|
12
|
+
} from "../../runtime/runtime-document-manifest";
|
|
13
|
+
import { writeAtomic } from "../../runtime/write-atomic";
|
|
14
|
+
import { pruneEmptyAncestors } from "../../tools/setup/utils";
|
|
15
|
+
import { reconcileWorkingRepoCopyExcludes } from "../../tools/setup/working-repo-local-state";
|
|
16
|
+
import type { ProjectionWriteReport } from "./projection-types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Copies the companion's `.opencode/` into the Working Repository's own
|
|
20
|
+
* `.opencode/`, the one slot OpenCode reads beside the user's global config.
|
|
21
|
+
* Regular files, not links: OpenCode does not live-reload a linked folder.
|
|
22
|
+
* Mate touches only the files its manifest records as owned.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const OPENCODE_DIR = ".opencode";
|
|
26
|
+
/** `opencode.json` is merged into the wrapped document instead of copied. */
|
|
27
|
+
const SKIPPED_TOP_LEVEL = new Set(["opencode.json"]);
|
|
28
|
+
const SKIPPED_NAMES = new Set(["node_modules", ".DS_Store"]);
|
|
29
|
+
|
|
30
|
+
interface CopySource {
|
|
31
|
+
absolute: string;
|
|
32
|
+
content: Buffer;
|
|
33
|
+
sha256: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sha256(content: Buffer): string {
|
|
37
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function walk(dir: string, relative: string, into: Map<string, CopySource>): Promise<void> {
|
|
41
|
+
let entries: Dirent[];
|
|
42
|
+
try {
|
|
43
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
|
|
49
|
+
if (SKIPPED_NAMES.has(entry.name)) continue;
|
|
50
|
+
if (relative === "" && SKIPPED_TOP_LEVEL.has(entry.name)) continue;
|
|
51
|
+
const absolute = path.join(dir, entry.name);
|
|
52
|
+
const rel = relative ? `${relative}/${entry.name}` : entry.name;
|
|
53
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential walk keeps the order stable
|
|
54
|
+
const stat = await fs.stat(absolute).catch(() => null);
|
|
55
|
+
if (!stat) continue;
|
|
56
|
+
if (stat.isDirectory()) {
|
|
57
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
58
|
+
await walk(absolute, rel, into);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!stat.isFile()) continue;
|
|
62
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
63
|
+
const content = await fs.readFile(absolute);
|
|
64
|
+
into.set(`${OPENCODE_DIR}/${rel}`, { absolute, content, sha256: sha256(content) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function companionSources(companionPath: string): Promise<Map<string, CopySource>> {
|
|
69
|
+
const sources = new Map<string, CopySource>();
|
|
70
|
+
await walk(path.join(companionPath, OPENCODE_DIR), "", sources);
|
|
71
|
+
return sources;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function destination(repoPath: string, relativePath: string): string {
|
|
75
|
+
return path.join(path.resolve(repoPath), ...relativePath.split("/"));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `null` when absent; a directory or other non-file reads as a foreign entry. */
|
|
79
|
+
async function readDestination(target: string): Promise<Buffer | "foreign" | null> {
|
|
80
|
+
const stat = await fs.lstat(target).catch(() => null);
|
|
81
|
+
if (!stat) return null;
|
|
82
|
+
if (!stat.isFile()) return "foreign";
|
|
83
|
+
return fs.readFile(target);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeCopy(target: string, content: Buffer): Promise<void> {
|
|
87
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
88
|
+
await writeAtomic(target, content);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function deleteCopy(repoPath: string, relativePath: string): Promise<void> {
|
|
92
|
+
const target = destination(repoPath, relativePath);
|
|
93
|
+
await fs.rm(target, { force: true });
|
|
94
|
+
await pruneEmptyAncestors(path.dirname(target), path.resolve(repoPath));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface CopyPlan {
|
|
98
|
+
write: Array<{ relativePath: string; source: CopySource; warning?: string }>;
|
|
99
|
+
remove: string[];
|
|
100
|
+
shadowed: Array<{ relativePath: string; source: CopySource }>;
|
|
101
|
+
records: Record<string, CopyRecord>;
|
|
102
|
+
previous: Record<string, CopyRecord>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function planCopies(repoPath: string, companionPath: string): Promise<CopyPlan> {
|
|
106
|
+
const previous = copies(await readManifest(repoPath), "opencode");
|
|
107
|
+
const sources = await companionSources(companionPath);
|
|
108
|
+
const plan: CopyPlan = { write: [], remove: [], shadowed: [], records: {}, previous };
|
|
109
|
+
|
|
110
|
+
for (const [relativePath, source] of sources) {
|
|
111
|
+
const owned = previous[relativePath];
|
|
112
|
+
// oxlint-disable-next-line no-await-in-loop -- one stat per file
|
|
113
|
+
const current = await readDestination(destination(repoPath, relativePath));
|
|
114
|
+
if (current === "foreign" || (!owned && current !== null)) {
|
|
115
|
+
plan.shadowed.push({ relativePath, source });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const currentSha = current === null ? null : sha256(current);
|
|
119
|
+
const edited = owned !== undefined && currentSha !== null && currentSha !== owned.sha256;
|
|
120
|
+
/** Local edit survives until its companion source changes. */
|
|
121
|
+
const keepEdit = edited && source.sha256 === owned.sha256;
|
|
122
|
+
if (currentSha !== source.sha256 && !keepEdit) {
|
|
123
|
+
plan.write.push({
|
|
124
|
+
relativePath,
|
|
125
|
+
source,
|
|
126
|
+
warning: edited
|
|
127
|
+
? `${relativePath} was edited locally; the edit was replaced from the companion. Edit ${source.absolute} instead.`
|
|
128
|
+
: undefined,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
plan.records[relativePath] = { runtime: "opencode", sha256: source.sha256 };
|
|
132
|
+
}
|
|
133
|
+
for (const relativePath of Object.keys(previous)) {
|
|
134
|
+
if (!sources.has(relativePath)) plan.remove.push(relativePath);
|
|
135
|
+
}
|
|
136
|
+
return plan;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sameRecords(a: Record<string, CopyRecord>, b: Record<string, CopyRecord>): boolean {
|
|
140
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function shadowWarning(relativePath: string, source: CopySource): string {
|
|
144
|
+
return `${relativePath} in the working repository shadows the companion file ${source.absolute}; the repository file wins.`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Adds, updates and deletes owned copies; an unowned path is never touched.
|
|
149
|
+
* Excludes widen and new paths are recorded before any write, so an
|
|
150
|
+
* interrupted reconcile leaves no unrecorded copy and none Git would list.
|
|
151
|
+
*/
|
|
152
|
+
export async function reconcileCompanionCopies(
|
|
153
|
+
repoPath: string,
|
|
154
|
+
companionPath: string,
|
|
155
|
+
): Promise<ProjectionWriteReport> {
|
|
156
|
+
const plan = await planCopies(repoPath, companionPath);
|
|
157
|
+
const warnings = plan.shadowed.map(({ relativePath, source }) =>
|
|
158
|
+
shadowWarning(relativePath, source),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const covering = [...new Set([...Object.keys(plan.previous), ...Object.keys(plan.records)])];
|
|
162
|
+
const excludesWidened = await reconcileWorkingRepoCopyExcludes(repoPath, covering);
|
|
163
|
+
/** Previous records win: a path not yet rewritten keeps the hash on disk. */
|
|
164
|
+
const interim = { ...plan.records, ...plan.previous };
|
|
165
|
+
if (!sameRecords(interim, plan.previous)) {
|
|
166
|
+
await updateManifest(repoPath, (manifest) => withCopies(manifest, "opencode", interim));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const { relativePath, source, warning } of plan.write) {
|
|
170
|
+
// oxlint-disable-next-line no-await-in-loop -- bounded by the companion folder
|
|
171
|
+
await writeCopy(destination(repoPath, relativePath), source.content);
|
|
172
|
+
if (warning) warnings.push(warning);
|
|
173
|
+
}
|
|
174
|
+
for (const relativePath of plan.remove) {
|
|
175
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
176
|
+
await deleteCopy(repoPath, relativePath);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const recordsWritten = !sameRecords(plan.records, plan.previous);
|
|
180
|
+
if (recordsWritten) {
|
|
181
|
+
await updateManifest(repoPath, (manifest) => withCopies(manifest, "opencode", plan.records));
|
|
182
|
+
}
|
|
183
|
+
const excludesNarrowed = await reconcileWorkingRepoCopyExcludes(
|
|
184
|
+
repoPath,
|
|
185
|
+
Object.keys(plan.records),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const written = plan.write.map(({ relativePath }) => relativePath);
|
|
189
|
+
return {
|
|
190
|
+
state:
|
|
191
|
+
written.length > 0 ||
|
|
192
|
+
plan.remove.length > 0 ||
|
|
193
|
+
recordsWritten ||
|
|
194
|
+
excludesWidened ||
|
|
195
|
+
excludesNarrowed
|
|
196
|
+
? "written"
|
|
197
|
+
: "current",
|
|
198
|
+
warnings,
|
|
199
|
+
changed: { written, removed: plan.remove },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Deletes every owned copy, then its exclude line, and prunes what that
|
|
205
|
+
* emptied: a copy is never on disk without the line that hides it.
|
|
206
|
+
*/
|
|
207
|
+
export async function withdrawCompanionCopies(repoPath: string): Promise<ProjectionWriteReport> {
|
|
208
|
+
const owned = Object.keys(copies(await readManifest(repoPath), "opencode"));
|
|
209
|
+
for (const relativePath of owned) {
|
|
210
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
211
|
+
await deleteCopy(repoPath, relativePath);
|
|
212
|
+
}
|
|
213
|
+
if (owned.length > 0) {
|
|
214
|
+
await updateManifest(repoPath, (manifest) => withCopies(manifest, "opencode", {}));
|
|
215
|
+
}
|
|
216
|
+
const excludesChanged = await reconcileWorkingRepoCopyExcludes(repoPath, []);
|
|
217
|
+
return {
|
|
218
|
+
state: owned.length > 0 || excludesChanged ? "written" : "current",
|
|
219
|
+
changed: { written: [], removed: owned },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function removeCompanionCopies(repoPath: string): Promise<"removed" | "absent"> {
|
|
224
|
+
return (await withdrawCompanionCopies(repoPath)).state === "written" ? "removed" : "absent";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Whether any owned copy is recorded. */
|
|
228
|
+
export async function companionCopiesPresent(repoPath: string): Promise<boolean> {
|
|
229
|
+
return Object.keys(copies(await readManifest(repoPath), "opencode")).length > 0;
|
|
230
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
GRAPHIFY_OUTPUT_SUBDIR,
|
|
11
11
|
GRAPHIFY_STORE_SEGMENT,
|
|
12
12
|
} from "../../tools/setup/capabilities/graphify";
|
|
13
|
+
import { runtimeWrapperBinPath } from "../../runtime/runtime-directory";
|
|
13
14
|
import { getReactDoctorBinPath, getWrapperBinPath } from "../package-paths";
|
|
14
15
|
import type { LinkedRepository } from "./types";
|
|
15
16
|
|
|
@@ -25,17 +26,22 @@ const COMPANION_GRAPHIFY_REPOSITORY_ID = "__companion__";
|
|
|
25
26
|
* Both bin paths and `GRAPHIFY_OUT` are unconditional: a path exists whether or
|
|
26
27
|
* not its Capability is enabled, and enablement is a predicate, read live from
|
|
27
28
|
* the companion rather than cached here.
|
|
29
|
+
*
|
|
30
|
+
* The persisted record names the runtime directory so it survives a reinstall;
|
|
31
|
+
* a launch passes the live install path, since its runtime-directory refresh
|
|
32
|
+
* may only have warned.
|
|
28
33
|
*/
|
|
29
34
|
export function buildProjection(
|
|
30
35
|
companionPath: string,
|
|
31
36
|
repository: LinkedRepository,
|
|
37
|
+
wrapperBinPath: string = runtimeWrapperBinPath(),
|
|
32
38
|
): MateProjection {
|
|
33
39
|
return {
|
|
34
40
|
version,
|
|
35
41
|
companionPath,
|
|
36
42
|
repositoryPath: repository.path,
|
|
37
43
|
repositoryId: repository.id,
|
|
38
|
-
wrapperBinPath
|
|
44
|
+
wrapperBinPath,
|
|
39
45
|
reactDoctorBinPath: getReactDoctorBinPath(),
|
|
40
46
|
graphifyOut: path.join(
|
|
41
47
|
companionPath,
|
|
@@ -1,44 +1,38 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { FRAMEWORK_NAME } from "../../framework";
|
|
8
|
+
import { gitEnvironment } from "../../runtime/companion-git";
|
|
9
|
+
import {
|
|
10
|
+
documentRegions,
|
|
11
|
+
readManifest,
|
|
12
|
+
updateManifest,
|
|
13
|
+
withDocument,
|
|
14
|
+
withoutDocuments,
|
|
15
|
+
type ManagedRegion,
|
|
16
|
+
type RenderedRuntimeDocument,
|
|
17
|
+
} from "../../runtime/runtime-document-manifest";
|
|
18
|
+
import { writeAtomic } from "../../runtime/write-atomic";
|
|
7
19
|
import { pruneEmptyAncestors } from "../../tools/setup/utils";
|
|
8
|
-
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
CLAUDE_LOCAL_CONFIG_DOCUMENT,
|
|
23
|
+
CLAUDE_MCP_DOCUMENT,
|
|
24
|
+
CLAUDE_SETTINGS_DOCUMENT,
|
|
25
|
+
OPENCODE_CONFIG_DOCUMENT,
|
|
26
|
+
} from "../../runtime/runtime-document-manifest";
|
|
9
27
|
|
|
10
28
|
/**
|
|
11
29
|
* Places the documents an Agent Runtime discovers by its own upward walk from
|
|
12
30
|
* the current directory. A Runtime Surface renders one as a value; this module
|
|
13
31
|
* writes it, records the regions it wrote at the Projection Root, and removes
|
|
14
32
|
* exactly those again — which is what makes a working-target document swept by
|
|
15
|
-
* `mate working cleanup` without cleanup holding a list of them.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const MANIFEST_FILE = "runtime-documents.json";
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The three destinations, repo-relative with POSIX separators. Declared here
|
|
22
|
-
* rather than beside the renderers so the entry catalogue names a destination
|
|
23
|
-
* without reaching into an Agent Runtime's Runtime Surface.
|
|
24
|
-
*/
|
|
25
|
-
export const CLAUDE_SETTINGS_DOCUMENT = ".claude/settings.local.json";
|
|
26
|
-
export const CLAUDE_MCP_DOCUMENT = ".mcp.json";
|
|
27
|
-
export const OPENCODE_CONFIG_DOCUMENT = ".opencode/opencode.json";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The one destination outside the Working Repository. Claude Code has no
|
|
31
|
-
* project file that declares a pre-approved MCP server: `.mcp.json` servers sit
|
|
32
|
-
* "pending approval" until a human accepts them in a session, and the settings
|
|
33
|
-
* document can only enable a server `.mcp.json` already declares. Local scope —
|
|
34
|
-
* `projects[<repo>].mcpServers` in the user's `~/.claude.json`, what
|
|
35
|
-
* `claude mcp add --scope local` writes — is the only channel that is live on
|
|
36
|
-
* first use.
|
|
37
|
-
*
|
|
38
|
-
* Written `~`-prefixed rather than resolved so the manifest recording it stays
|
|
39
|
-
* machine-independent, and so an external target is identifiable as one.
|
|
33
|
+
* `mate working cleanup` without cleanup holding a list of them. The manifest
|
|
34
|
+
* — format, I/O and queries — belongs to `runtime/runtime-document-manifest`.
|
|
40
35
|
*/
|
|
41
|
-
export const CLAUDE_LOCAL_CONFIG_DOCUMENT = "~/.claude.json";
|
|
42
36
|
|
|
43
37
|
/**
|
|
44
38
|
* A document Mate does not own the surroundings of. Mate's regions are stripped
|
|
@@ -50,6 +44,38 @@ export function isExternalDocument(documentPath: string): boolean {
|
|
|
50
44
|
return documentPath.startsWith("~/");
|
|
51
45
|
}
|
|
52
46
|
|
|
47
|
+
const execFileAsync = promisify(execFile);
|
|
48
|
+
|
|
49
|
+
/** Whether Git tracks the repo-relative path; any `ls-files` failure reads as untracked. */
|
|
50
|
+
async function gitTracks(repoPath: string, relativePath: string): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
const { stdout } = await execFileAsync("git", ["ls-files", "-z", "--", relativePath], {
|
|
53
|
+
cwd: path.resolve(repoPath),
|
|
54
|
+
encoding: "utf8",
|
|
55
|
+
env: gitEnvironment(),
|
|
56
|
+
});
|
|
57
|
+
return stdout.split("\0").includes(relativePath);
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Excludes don't hide tracked files, so regions with machine-absolute paths
|
|
65
|
+
* would land in a committed document: warn, still write. Best-effort: any
|
|
66
|
+
* `ls-files` failure reads as "not tracked".
|
|
67
|
+
*/
|
|
68
|
+
export async function trackedDocumentWarnings(
|
|
69
|
+
repoPath: string,
|
|
70
|
+
documentPath: string,
|
|
71
|
+
repositoryId: string,
|
|
72
|
+
): Promise<string[]> {
|
|
73
|
+
if (!(await gitTracks(repoPath, documentPath))) return [];
|
|
74
|
+
return [
|
|
75
|
+
`${documentPath} is tracked by Git in ${repositoryId}; ${FRAMEWORK_NAME} writes machine-absolute companion paths into it, so committing it would push this machine's paths to everyone else on the repository. Untrack it with \`git rm --cached ${documentPath}\` if those paths should stay local.`,
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
/**
|
|
54
80
|
* Injectable so a test never writes to the real `~/.claude.json`. An external
|
|
55
81
|
* target is the one destination outside a temporary fixture, so it is also the
|
|
@@ -59,14 +85,6 @@ export const runtimeDocumentDeps = {
|
|
|
59
85
|
homeDir: (): string => os.homedir(),
|
|
60
86
|
};
|
|
61
87
|
|
|
62
|
-
interface Manifest {
|
|
63
|
-
documents: Record<string, ManagedRegion[]>;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function manifestPath(repoPath: string): string {
|
|
67
|
-
return path.join(repoLocalDirPath(repoPath), MANIFEST_FILE);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
88
|
function documentPathIn(repoPath: string, documentPath: string): string {
|
|
71
89
|
if (isExternalDocument(documentPath)) {
|
|
72
90
|
return path.join(runtimeDocumentDeps.homeDir(), ...documentPath.slice("~/".length).split("/"));
|
|
@@ -97,26 +115,9 @@ async function readRaw(filePath: string): Promise<string | null> {
|
|
|
97
115
|
}
|
|
98
116
|
}
|
|
99
117
|
|
|
100
|
-
function parseObject(raw: string | null): Record<string, unknown> {
|
|
101
|
-
if (raw === null) return {};
|
|
102
|
-
try {
|
|
103
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
104
|
-
if (isRecord(parsed)) return parsed;
|
|
105
|
-
} catch {
|
|
106
|
-
/* unparseable — start from an empty object */
|
|
107
|
-
}
|
|
108
|
-
return {};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
118
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* the manifest — Mate wrote it and can rebuild it — and ruinous here: a torn
|
|
115
|
-
* read of `~/.claude.json` would be written back as the handful of keys Mate
|
|
116
|
-
* contributes, taking Claude Code's auth, every project's history and its MCP
|
|
117
|
-
* approvals with it, silently and without a backup. Throwing leaves the file
|
|
118
|
-
* as it was and hands the entry catch in `project()` a failed outcome to
|
|
119
|
-
* report, which is the answer an operator can act on.
|
|
119
|
+
* Unparseable throws, never reads as empty: writing back a torn
|
|
120
|
+
* `~/.claude.json` would wipe the user's auth and history.
|
|
120
121
|
*/
|
|
121
122
|
function parseDocument(raw: string | null, target: string): Record<string, unknown> {
|
|
122
123
|
if (raw === null) return {};
|
|
@@ -130,40 +131,6 @@ function parseDocument(raw: string | null, target: string): Record<string, unkno
|
|
|
130
131
|
return parsed;
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
/**
|
|
134
|
-
* Writes via a sibling temp file + `fs.rename()` so an interrupted write cannot
|
|
135
|
-
* leave a half-written document behind. A reader racing this one — a Claude
|
|
136
|
-
* Code session holding `~/.claude.json` open — observes the old file or the new
|
|
137
|
-
* one, never a truncated one.
|
|
138
|
-
*/
|
|
139
|
-
async function writeAtomic(target: string, contents: string): Promise<void> {
|
|
140
|
-
const tempPath = `${target}.${crypto.randomUUID()}.tmp`;
|
|
141
|
-
try {
|
|
142
|
-
await fs.writeFile(tempPath, contents, "utf8");
|
|
143
|
-
await fs.rename(tempPath, target);
|
|
144
|
-
} catch (error) {
|
|
145
|
-
await fs.rm(tempPath, { force: true }).catch(() => {});
|
|
146
|
-
throw error;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function readManifest(repoPath: string): Promise<Manifest> {
|
|
151
|
-
const parsed = parseObject(await readRaw(manifestPath(repoPath)));
|
|
152
|
-
return {
|
|
153
|
-
documents: isRecord(parsed.documents) ? (parsed.documents as Manifest["documents"]) : {},
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function writeManifest(repoPath: string, manifest: Manifest): Promise<void> {
|
|
158
|
-
const target = manifestPath(repoPath);
|
|
159
|
-
if (Object.keys(manifest.documents).length === 0) {
|
|
160
|
-
await fs.unlink(target).catch(() => {});
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
164
|
-
await writeAtomic(target, serialize(manifest));
|
|
165
|
-
}
|
|
166
|
-
|
|
167
134
|
/** The container holding a region's final key, created on demand when writing. */
|
|
168
135
|
function containerFor(
|
|
169
136
|
document: Record<string, unknown>,
|
|
@@ -185,6 +152,11 @@ function containerFor(
|
|
|
185
152
|
return node;
|
|
186
153
|
}
|
|
187
154
|
|
|
155
|
+
function keyPresent(document: Record<string, unknown>, at: string[]): boolean {
|
|
156
|
+
const container = containerFor(document, at, false);
|
|
157
|
+
return container !== null && Object.hasOwn(container, at.at(-1)!);
|
|
158
|
+
}
|
|
159
|
+
|
|
188
160
|
function applyRegion(document: Record<string, unknown>, region: ManagedRegion): void {
|
|
189
161
|
const container = containerFor(document, region.at, true)!;
|
|
190
162
|
const key = region.at.at(-1)!;
|
|
@@ -295,19 +267,22 @@ export async function placeRuntimeDocument(
|
|
|
295
267
|
const raw = await readRaw(target);
|
|
296
268
|
const document = parseDocument(raw, target);
|
|
297
269
|
|
|
298
|
-
const
|
|
299
|
-
for (const region of
|
|
300
|
-
|
|
270
|
+
const prior = documentRegions(await readManifest(repoPath), documentPath);
|
|
271
|
+
for (const region of prior ?? []) revertRegion(document, region);
|
|
272
|
+
/** After the revert, a key still present is the human's: a yielding region gives way to it. */
|
|
273
|
+
const applied = rendered.regions.filter(
|
|
274
|
+
(region) => !(region.kind === "value" && region.yields && keyPresent(document, region.at)),
|
|
275
|
+
);
|
|
276
|
+
for (const region of applied) applyRegion(document, region);
|
|
301
277
|
const next = serialize(document);
|
|
302
278
|
|
|
303
|
-
const
|
|
304
|
-
const recorded = JSON.stringify(manifest.documents[documentPath]) === JSON.stringify(regions);
|
|
279
|
+
const recorded = JSON.stringify(prior) === JSON.stringify(applied);
|
|
305
280
|
if (next === raw && recorded) return "current";
|
|
306
281
|
|
|
282
|
+
/** The file first: a failed write leaves the manifest naming what is really there. */
|
|
307
283
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
308
284
|
await writeAtomic(target, next);
|
|
309
|
-
manifest
|
|
310
|
-
await writeManifest(repoPath, manifest);
|
|
285
|
+
await updateManifest(repoPath, (manifest) => withDocument(manifest, documentPath, applied));
|
|
311
286
|
return "written";
|
|
312
287
|
}
|
|
313
288
|
|
|
@@ -319,13 +294,11 @@ export async function removeRuntimeDocument(
|
|
|
319
294
|
repoPath: string,
|
|
320
295
|
documentPath: string,
|
|
321
296
|
): Promise<"removed" | "absent"> {
|
|
322
|
-
const
|
|
323
|
-
const regions = manifest.documents[documentPath];
|
|
297
|
+
const regions = documentRegions(await readManifest(repoPath), documentPath);
|
|
324
298
|
if (!regions) return "absent";
|
|
325
299
|
|
|
326
300
|
await revertDocumentOnDisk(repoPath, documentPath, regions);
|
|
327
|
-
|
|
328
|
-
await writeManifest(repoPath, manifest);
|
|
301
|
+
await updateManifest(repoPath, (manifest) => withoutDocuments(manifest, [documentPath]));
|
|
329
302
|
return "removed";
|
|
330
303
|
}
|
|
331
304
|
|
|
@@ -358,10 +331,10 @@ async function revertDocumentOnDisk(
|
|
|
358
331
|
}
|
|
359
332
|
|
|
360
333
|
/**
|
|
361
|
-
* Withdraws several destinations against a single
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
334
|
+
* Withdraws several destinations against a single manifest update.
|
|
335
|
+
* `removeRuntimeDocument` per destination cannot be run concurrently — each
|
|
336
|
+
* rewrites the whole manifest, so the last write restores the keys the others
|
|
337
|
+
* removed — and running it sequentially rewrites the manifest once per
|
|
365
338
|
* document. Reverting the files together and recording the outcome once is both
|
|
366
339
|
* safe and one write.
|
|
367
340
|
*
|
|
@@ -375,13 +348,13 @@ export async function removeRuntimeDocuments(
|
|
|
375
348
|
documentPaths: readonly string[],
|
|
376
349
|
): Promise<{ removed: string[]; absent: string[]; error?: { document: string; error: Error } }> {
|
|
377
350
|
const manifest = await readManifest(repoPath);
|
|
378
|
-
const recorded = documentPaths.filter((candidate) => manifest
|
|
379
|
-
const absent = documentPaths.filter((candidate) => manifest
|
|
351
|
+
const recorded = documentPaths.filter((candidate) => documentRegions(manifest, candidate));
|
|
352
|
+
const absent = documentPaths.filter((candidate) => !documentRegions(manifest, candidate));
|
|
380
353
|
if (recorded.length === 0) return { removed: [], absent };
|
|
381
354
|
|
|
382
355
|
const settled = await Promise.allSettled(
|
|
383
356
|
recorded.map((documentPath) =>
|
|
384
|
-
revertDocumentOnDisk(repoPath, documentPath, manifest
|
|
357
|
+
revertDocumentOnDisk(repoPath, documentPath, documentRegions(manifest, documentPath)!),
|
|
385
358
|
),
|
|
386
359
|
);
|
|
387
360
|
|
|
@@ -396,29 +369,9 @@ export async function removeRuntimeDocuments(
|
|
|
396
369
|
};
|
|
397
370
|
continue;
|
|
398
371
|
}
|
|
399
|
-
delete manifest.documents[documentPath];
|
|
400
372
|
removed.push(documentPath);
|
|
401
373
|
}
|
|
402
374
|
|
|
403
|
-
await
|
|
375
|
+
await updateManifest(repoPath, (current) => withoutDocuments(current, removed));
|
|
404
376
|
return { removed, absent, ...(failure ? { error: failure } : {}) };
|
|
405
377
|
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Every destination the manifest records, in the order it recorded them. This
|
|
409
|
-
* is what makes a Working Repository "wrapped": the manifest is written only by
|
|
410
|
-
* a pass that placed a runtime document, and `mate wrap` is the only pass that
|
|
411
|
-
* places one. Reported as data so neither the launch refusal nor `mate unwrap`
|
|
412
|
-
* holds a list of destinations that the entry catalogue could outgrow.
|
|
413
|
-
*/
|
|
414
|
-
export async function recordedRuntimeDocuments(repoPath: string): Promise<string[]> {
|
|
415
|
-
return Object.keys((await readManifest(repoPath)).documents);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/** Present when Mate recorded regions for it, not merely when the file exists. */
|
|
419
|
-
export async function runtimeDocumentPresent(
|
|
420
|
-
repoPath: string,
|
|
421
|
-
documentPath: string,
|
|
422
|
-
): Promise<boolean> {
|
|
423
|
-
return (await readManifest(repoPath)).documents[documentPath] !== undefined;
|
|
424
|
-
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GlobalConfigStore } from "./global-config-store";
|
|
2
2
|
import type { ProjectionFile } from "../../runtime/projection";
|
|
3
|
+
import type { RenderedRuntimeDocument, WrapRuntime } from "../../runtime/runtime-document-manifest";
|
|
3
4
|
import type { CompanionSource, FrameworkConfig, LinkedRepository } from "./types";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -32,38 +33,8 @@ export type ProjectionEntryId =
|
|
|
32
33
|
| "claude-runtime-document"
|
|
33
34
|
| "claude-local-mcp-document"
|
|
34
35
|
| "mcp-runtime-document"
|
|
35
|
-
| "opencode-runtime-document"
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* A region of a runtime document that Mate maintains. JSON carries no comment
|
|
39
|
-
* syntax, so the region is recorded rather than marked in the file: `list`
|
|
40
|
-
* appends values not already there, `map` sets entries by key, `value` sets one
|
|
41
|
-
* key — and removal takes back exactly those, leaving anything a human added.
|
|
42
|
-
*
|
|
43
|
-
* `stripPrefix` is the one exception to "removal takes back exactly what was
|
|
44
|
-
* written": it deletes keys Mate never wrote, and removal does not restore
|
|
45
|
-
* them. It exists for keys an older Mate wrote and a current one must not
|
|
46
|
-
* leave behind, so a re-wrap heals a repository an old version configured.
|
|
47
|
-
*/
|
|
48
|
-
export type ManagedRegion =
|
|
49
|
-
| { readonly at: string[]; readonly kind: "list"; readonly values: unknown[] }
|
|
50
|
-
| {
|
|
51
|
-
readonly at: string[];
|
|
52
|
-
readonly kind: "map";
|
|
53
|
-
readonly entries: Record<string, unknown>;
|
|
54
|
-
readonly stripPrefix?: string;
|
|
55
|
-
}
|
|
56
|
-
| { readonly at: string[]; readonly kind: "value"; readonly value: unknown };
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* What a Runtime Surface hands the Projection Root: the managed content of one
|
|
60
|
-
* document, without a destination. The surface renders; the entry places.
|
|
61
|
-
*/
|
|
62
|
-
export interface RenderedRuntimeDocument {
|
|
63
|
-
/** Repo-relative with POSIX separators, or `~/`-prefixed for a user-global target. */
|
|
64
|
-
readonly path: string;
|
|
65
|
-
readonly regions: readonly ManagedRegion[];
|
|
66
|
-
}
|
|
36
|
+
| "opencode-runtime-document"
|
|
37
|
+
| "opencode-companion-copies";
|
|
67
38
|
|
|
68
39
|
export interface ProjectionInput {
|
|
69
40
|
repoPath: string;
|
|
@@ -75,6 +46,8 @@ export interface ProjectionInput {
|
|
|
75
46
|
globalConfigStore?: GlobalConfigStore;
|
|
76
47
|
/** Rendered by the active Runtime Surfaces; placed by the `wrap` scope's entries. */
|
|
77
48
|
runtimeDocuments?: readonly RenderedRuntimeDocument[];
|
|
49
|
+
/** Restricts runtime-tagged entries to these runtimes; every runtime when absent. */
|
|
50
|
+
runtimes?: readonly WrapRuntime[];
|
|
78
51
|
}
|
|
79
52
|
|
|
80
53
|
export interface ProjectionRemovalInput {
|
|
@@ -86,6 +59,23 @@ export interface ProjectionRemovalInput {
|
|
|
86
59
|
export type ProjectionWriteState = "written" | "current" | "skipped";
|
|
87
60
|
export type ProjectionRemoveState = "removed" | "absent" | "retained" | "covered";
|
|
88
61
|
|
|
62
|
+
/** Repo-relative paths an entry maintaining several files wrote or withdrew. */
|
|
63
|
+
export interface ProjectionChangedPaths {
|
|
64
|
+
written: string[];
|
|
65
|
+
removed: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a write may return instead of a bare state, when it has something to
|
|
70
|
+
* tell the operator. The entry reports; it never prints — the caller presents
|
|
71
|
+
* warnings and changed paths without knowing which entry produced them.
|
|
72
|
+
*/
|
|
73
|
+
export interface ProjectionWriteReport {
|
|
74
|
+
state: ProjectionWriteState;
|
|
75
|
+
warnings?: string[];
|
|
76
|
+
changed?: ProjectionChangedPaths;
|
|
77
|
+
}
|
|
78
|
+
|
|
89
79
|
/**
|
|
90
80
|
* Every entry names its inverse. `self` removes its own path, `entry` is covered
|
|
91
81
|
* by another entry's removal, `retained` is deliberately left behind and says
|
|
@@ -105,8 +95,10 @@ export interface ProjectionEntry {
|
|
|
105
95
|
/** Repo-relative, POSIX separators. An entry spanning siblings names its primary. */
|
|
106
96
|
readonly path: string;
|
|
107
97
|
readonly scopes: readonly ProjectionScope[];
|
|
98
|
+
/** Set on entries that belong to one Agent Runtime; see {@link ProjectionInput.runtimes}. */
|
|
99
|
+
readonly runtime?: WrapRuntime;
|
|
108
100
|
/** `skipped` when an input this entry needs was not supplied. */
|
|
109
|
-
write(input: ProjectionInput): Promise<ProjectionWriteState>;
|
|
101
|
+
write(input: ProjectionInput): Promise<ProjectionWriteState | ProjectionWriteReport>;
|
|
110
102
|
/**
|
|
111
103
|
* Whether a failed write is reported without failing the operation that
|
|
112
104
|
* triggered the projection. Only for an entry something else already covers:
|
|
@@ -132,6 +124,12 @@ export interface ProjectionEntryOutcome {
|
|
|
132
124
|
state: ProjectionWriteState | ProjectionRemoveState | "failed";
|
|
133
125
|
error?: Error;
|
|
134
126
|
degradable?: boolean;
|
|
127
|
+
/** The entry's runtime tag; absent for runtime-neutral entries. */
|
|
128
|
+
runtime?: WrapRuntime;
|
|
129
|
+
/** Present only when the entry had something to say. */
|
|
130
|
+
warnings?: string[];
|
|
131
|
+
/** Present only when the entry wrote or withdrew paths it reports one by one. */
|
|
132
|
+
changed?: ProjectionChangedPaths;
|
|
135
133
|
}
|
|
136
134
|
|
|
137
135
|
export interface ProjectionResult {
|
|
@@ -163,3 +161,10 @@ export interface ProjectionDescription {
|
|
|
163
161
|
export function firstFailure(outcomes: ProjectionEntryOutcome[]): ProjectionEntryOutcome | null {
|
|
164
162
|
return outcomes.find((outcome) => outcome.state === "failed" && !outcome.degradable) ?? null;
|
|
165
163
|
}
|
|
164
|
+
|
|
165
|
+
/** Names the failed entry: "the projection" alone would not say what to fix. */
|
|
166
|
+
export function entryFailureMessage(outcome: ProjectionEntryOutcome, repositoryId: string): string {
|
|
167
|
+
const reason = outcome.error?.message ?? "projection failed";
|
|
168
|
+
const target = outcome.id === "projection-pair" ? "the projection" : outcome.path;
|
|
169
|
+
return `failed to write ${target} for ${repositoryId}: ${reason}`;
|
|
170
|
+
}
|