@uniqbit/mate-core 0.15.5 → 0.16.0-canary.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-plugin/.claude-plugin/plugin.json +2 -2
- package/claude-plugin/hooks/hooks.json +3 -6
- package/claude-plugin/hooks/session-guidance.mjs +8 -0
- package/claude-plugin/hooks/ts-loader.mjs +19 -0
- package/package.json +7 -5
- package/src/cli/commands/artifact/artifact.ts +19 -3
- package/src/cli/commands/artifact/finish/command.ts +183 -57
- package/src/cli/commands/artifact/finish/engine.ts +80 -91
- package/src/cli/commands/artifact/finish/finisher.ts +26 -33
- package/src/cli/commands/artifact/finish/git.ts +34 -23
- package/src/cli/commands/artifact/finish/index.ts +9 -3
- package/src/cli/commands/artifact/finish/openspec.ts +225 -97
- package/src/cli/commands/artifact/pending/command.ts +175 -0
- package/src/cli/commands/artifact/pending/discovery.ts +249 -0
- package/src/cli/commands/artifact/pending/index.ts +17 -0
- package/src/cli/commands/cap/index-cmd.ts +9 -1
- package/src/cli/commands/cap/index.ts +2 -6
- package/src/cli/commands/cap/tokensave.ts +4 -4
- package/src/cli/commands/companion/companion.ts +5 -1
- package/src/cli/commands/companion/link.ts +2 -2
- package/src/cli/commands/companion/sync.ts +92 -0
- package/src/cli/commands/doctor.ts +0 -3
- package/src/cli/commands/launch/shared.ts +23 -5
- package/src/cli/commands/report/collector.ts +72 -78
- package/src/cli/commands/report/contract.ts +40 -1
- package/src/cli/commands/report/highlight.ts +27 -0
- package/src/cli/commands/report/index.ts +11 -18
- package/src/cli/commands/report/renderer.ts +199 -2
- package/src/cli/commands/report/types.ts +26 -1
- package/src/cli/commands/shared/companion-selection.ts +107 -10
- package/src/cli/commands/studio/areas.ts +68 -0
- package/src/cli/commands/studio/index.ts +69 -0
- package/src/cli/commands/studio/inventory.ts +55 -0
- package/src/cli/commands/studio/mate-inventory.ts +43 -0
- package/src/cli/commands/studio/openspec-cli.ts +198 -0
- package/src/cli/commands/studio/payload.ts +184 -0
- package/src/cli/commands/studio/routes.ts +2 -0
- package/src/cli/commands/studio/selection.ts +61 -0
- package/src/cli/commands/studio/server.ts +201 -0
- package/src/cli/commands/studio/snapshot.ts +63 -0
- package/src/cli/commands/studio/topology.ts +199 -0
- package/src/cli/commands/studio/views/client.ts +197 -0
- package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
- package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
- package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
- package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
- package/src/cli/commands/studio/views/document.tsx +256 -0
- package/src/cli/commands/studio/views/error.tsx +20 -0
- package/src/cli/commands/studio/views/model.ts +34 -0
- package/src/cli/commands/studio/views/pairings.tsx +38 -0
- package/src/cli/commands/studio/views/skills/index.tsx +87 -0
- package/src/cli/commands/studio/views/specs/index.tsx +101 -0
- package/src/cli/commands/studio/views/styles.ts +389 -0
- package/src/cli/commands/studio/views/warnings.tsx +21 -0
- package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
- package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
- package/src/cli/commands/unwrap.ts +70 -0
- package/src/cli/commands/wrap.ts +164 -0
- package/src/cli/main.ts +68 -19
- package/src/cli/parse-flags.ts +36 -11
- package/src/cli/usage.ts +12 -3
- package/src/framework.ts +1 -7
- package/src/hooks/session-banner.ts +64 -11
- package/src/hooks/session-guidance.ts +40 -0
- package/src/hooks/validate-artifact-path.ts +108 -35
- package/src/lib/fs-utils.ts +9 -0
- package/src/lib/install.ts +33 -0
- package/src/lib/orchestrator/adapters/base.ts +14 -125
- package/src/lib/orchestrator/adapters/claude.ts +0 -11
- package/src/lib/orchestrator/adapters/opencode.ts +2 -32
- package/src/lib/orchestrator/companion-git-sync.ts +94 -84
- package/src/lib/orchestrator/config-store.ts +2 -21
- package/src/lib/orchestrator/editor.ts +12 -22
- package/src/lib/orchestrator/framework-context.ts +17 -6
- package/src/lib/orchestrator/global-config-store.ts +1 -1
- package/src/lib/orchestrator/launcher.ts +97 -7
- package/src/lib/orchestrator/opencode-guidance.ts +4 -56
- package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
- package/src/lib/orchestrator/projection-companion-link.ts +62 -0
- package/src/lib/orchestrator/projection-entries.ts +377 -0
- package/src/lib/orchestrator/projection-record.ts +56 -0
- package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
- package/src/lib/orchestrator/projection-types.ts +169 -0
- package/src/lib/orchestrator/repo-local-registry.ts +37 -133
- package/src/lib/orchestrator/repo-local-store.ts +96 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
- package/src/lib/orchestrator/types.ts +1 -0
- package/src/lib/orchestrator/working-repo-projection.ts +366 -0
- package/src/lib/orchestrator/workspace-inventory.ts +1 -1
- package/src/lib/package-paths.ts +11 -1
- package/src/lib/public-npm.ts +2 -1
- package/src/lib/update-checker.ts +15 -9
- package/src/opencode/companion-hooks.ts +89 -245
- package/src/opencode/companion-policy.ts +35 -10
- package/src/opencode/index.ts +1 -0
- package/src/opencode/projected-guidance.ts +56 -0
- package/src/opencode/tui.tsx +13 -4
- package/src/playbooks/companion-guidance.ts +32 -116
- package/src/plugins.ts +0 -1
- package/src/runtime/companion-git-state.ts +156 -0
- package/src/runtime/companion-git.ts +203 -0
- package/src/runtime/companion-guidance.ts +222 -0
- package/src/runtime/companion-sync.ts +298 -0
- package/src/runtime/env-names.ts +30 -0
- package/src/runtime/env.ts +67 -35
- package/src/runtime/framework.ts +10 -0
- package/src/runtime/freshness.ts +58 -0
- package/src/runtime/index.ts +104 -0
- package/src/runtime/install.ts +30 -0
- package/src/runtime/policy.ts +66 -0
- package/src/runtime/projected-guidance.ts +45 -0
- package/src/runtime/projection.ts +224 -0
- package/src/runtime/repo-local.ts +64 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
- package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
- package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
- package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
- package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +156 -0
- package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
- package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
- package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
- package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
- package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +156 -0
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
- package/src/templates/report-assets/README.md +32 -0
- package/src/templates/report-assets/mermaid.LICENSE +21 -0
- package/src/templates/report-assets/mermaid.min.js +4376 -0
- package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
- package/src/tools/setup/capabilities/graphify.ts +16 -7
- package/src/tools/setup/capabilities/openspec.ts +63 -56
- package/src/tools/setup/capabilities/tokensave.ts +116 -2
- package/src/tools/setup/engine.ts +34 -6
- package/src/tools/setup/mate.ts +42 -13
- package/src/tools/setup/plugin.ts +9 -0
- package/src/tools/setup/plugins/guidance.ts +11 -1
- package/src/tools/setup/providers/claude-format.ts +49 -4
- package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
- package/src/tools/setup/providers/claude.ts +55 -220
- package/src/tools/setup/providers/opencode.ts +41 -14
- package/src/tools/setup/runtime-documents.ts +174 -0
- package/src/tools/setup/surface-target.ts +50 -0
- package/src/tools/setup/working-repo-cleanup.ts +33 -26
- package/src/tools/setup/working-repo-local-state.ts +21 -1
- package/src/tools/setup.ts +25 -3
- package/wrappers/bin/graphify +57 -8
- package/wrappers/bin/openspec +50 -3
- package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
- package/src/cli/commands/cap/headroom.ts +0 -52
- package/src/cli/commands/workspace/list.ts +0 -25
- package/src/cli/commands/workspace/materialize.ts +0 -46
- package/src/cli/commands/workspace/workspace.ts +0 -22
- package/src/hooks/artifact-finish-nudge.ts +0 -244
- package/src/lib/orchestrator/headroom/proxy.ts +0 -116
- package/src/lib/orchestrator/workspace-materialize.ts +0 -80
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
- package/src/tools/setup/capabilities/headroom.ts +0 -57
- /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { repoLocalDirPath } from "../../runtime/repo-local";
|
|
7
|
+
import { pruneEmptyAncestors } from "../../tools/setup/utils";
|
|
8
|
+
import type { ManagedRegion, RenderedRuntimeDocument } from "./projection-types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Places the documents an Agent Runtime discovers by its own upward walk from
|
|
12
|
+
* the current directory. A Runtime Surface renders one as a value; this module
|
|
13
|
+
* writes it, records the regions it wrote at the Projection Root, and removes
|
|
14
|
+
* 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.
|
|
40
|
+
*/
|
|
41
|
+
export const CLAUDE_LOCAL_CONFIG_DOCUMENT = "~/.claude.json";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A document Mate does not own the surroundings of. Mate's regions are stripped
|
|
45
|
+
* from one exactly as from any other, but it is never deleted when emptied and
|
|
46
|
+
* no directory around it is ever pruned — the file is the user's, and everything
|
|
47
|
+
* else in it belongs to Claude Code.
|
|
48
|
+
*/
|
|
49
|
+
export function isExternalDocument(documentPath: string): boolean {
|
|
50
|
+
return documentPath.startsWith("~/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Injectable so a test never writes to the real `~/.claude.json`. An external
|
|
55
|
+
* target is the one destination outside a temporary fixture, so it is also the
|
|
56
|
+
* one that has to be overridable for the suite to stay hermetic.
|
|
57
|
+
*/
|
|
58
|
+
export const runtimeDocumentDeps = {
|
|
59
|
+
homeDir: (): string => os.homedir(),
|
|
60
|
+
};
|
|
61
|
+
|
|
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
|
+
function documentPathIn(repoPath: string, documentPath: string): string {
|
|
71
|
+
if (isExternalDocument(documentPath)) {
|
|
72
|
+
return path.join(runtimeDocumentDeps.homeDir(), ...documentPath.slice("~/".length).split("/"));
|
|
73
|
+
}
|
|
74
|
+
return path.join(path.resolve(repoPath), ...documentPath.split("/"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
78
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function serialize(value: unknown): string {
|
|
82
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `null` means the file is not there, and nothing else does. A read that failed
|
|
87
|
+
* for any other reason — a permission the user revoked, a path that turned out
|
|
88
|
+
* to be a directory — is not an absent document, and answering `null` to one
|
|
89
|
+
* would have the caller write a fresh document over whatever it could not read.
|
|
90
|
+
*/
|
|
91
|
+
async function readRaw(filePath: string): Promise<string | null> {
|
|
92
|
+
try {
|
|
93
|
+
return await fs.readFile(filePath, "utf8");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
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
|
+
/**
|
|
112
|
+
* The same read for a document Mate does not own, where an unparseable one is
|
|
113
|
+
* refused rather than treated as empty. `parseObject`'s fallback is right for
|
|
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.
|
|
120
|
+
*/
|
|
121
|
+
function parseDocument(raw: string | null, target: string): Record<string, unknown> {
|
|
122
|
+
if (raw === null) return {};
|
|
123
|
+
let parsed: unknown;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(raw) as unknown;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw new Error(`${target} is not valid JSON; refusing to rewrite it`, { cause: error });
|
|
128
|
+
}
|
|
129
|
+
if (!isRecord(parsed)) throw new Error(`${target} is not a JSON object; refusing to rewrite it`);
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
|
|
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
|
+
/** The container holding a region's final key, created on demand when writing. */
|
|
168
|
+
function containerFor(
|
|
169
|
+
document: Record<string, unknown>,
|
|
170
|
+
at: string[],
|
|
171
|
+
create: boolean,
|
|
172
|
+
): Record<string, unknown> | null {
|
|
173
|
+
let node = document;
|
|
174
|
+
for (const key of at.slice(0, -1)) {
|
|
175
|
+
const next = node[key];
|
|
176
|
+
if (isRecord(next)) {
|
|
177
|
+
node = next;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (!create) return null;
|
|
181
|
+
const created: Record<string, unknown> = {};
|
|
182
|
+
node[key] = created;
|
|
183
|
+
node = created;
|
|
184
|
+
}
|
|
185
|
+
return node;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function applyRegion(document: Record<string, unknown>, region: ManagedRegion): void {
|
|
189
|
+
const container = containerFor(document, region.at, true)!;
|
|
190
|
+
const key = region.at.at(-1)!;
|
|
191
|
+
if (region.kind === "value") {
|
|
192
|
+
container[key] = region.value;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (region.kind === "map") {
|
|
196
|
+
const found = isRecord(container[key]) ? (container[key] as Record<string, unknown>) : {};
|
|
197
|
+
/** Keys an older Mate wrote here, dropped before this Mate's own are set. */
|
|
198
|
+
const existing = region.stripPrefix
|
|
199
|
+
? Object.fromEntries(
|
|
200
|
+
Object.entries(found).filter(([name]) => !name.startsWith(region.stripPrefix!)),
|
|
201
|
+
)
|
|
202
|
+
: found;
|
|
203
|
+
container[key] = { ...existing, ...region.entries };
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const existing = Array.isArray(container[key]) ? (container[key] as unknown[]) : [];
|
|
207
|
+
const seen = new Set(existing.map((entry) => JSON.stringify(entry)));
|
|
208
|
+
container[key] = [
|
|
209
|
+
...existing,
|
|
210
|
+
...region.values.filter((value) => !seen.has(JSON.stringify(value))),
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Removes only what was recorded; a value a human has since changed stays. */
|
|
215
|
+
function revertRegion(document: Record<string, unknown>, region: ManagedRegion): void {
|
|
216
|
+
const container = containerFor(document, region.at, false);
|
|
217
|
+
if (!container) return;
|
|
218
|
+
const key = region.at.at(-1)!;
|
|
219
|
+
|
|
220
|
+
if (region.kind === "value") {
|
|
221
|
+
if (JSON.stringify(container[key]) === JSON.stringify(region.value)) delete container[key];
|
|
222
|
+
} else if (region.kind === "map") {
|
|
223
|
+
const existing = isRecord(container[key])
|
|
224
|
+
? { ...(container[key] as Record<string, unknown>) }
|
|
225
|
+
: null;
|
|
226
|
+
if (existing) {
|
|
227
|
+
for (const [name, entry] of Object.entries(region.entries)) {
|
|
228
|
+
if (JSON.stringify(existing[name]) === JSON.stringify(entry)) delete existing[name];
|
|
229
|
+
}
|
|
230
|
+
if (Object.keys(existing).length > 0) container[key] = existing;
|
|
231
|
+
else delete container[key];
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
const existing = Array.isArray(container[key]) ? (container[key] as unknown[]) : null;
|
|
235
|
+
if (existing) {
|
|
236
|
+
const removed = new Set(region.values.map((value) => JSON.stringify(value)));
|
|
237
|
+
const remaining = existing.filter((entry) => !removed.has(JSON.stringify(entry)));
|
|
238
|
+
if (remaining.length > 0) container[key] = remaining;
|
|
239
|
+
else delete container[key];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** An emptied container Mate created is not left behind as `{}`. */
|
|
244
|
+
for (let depth = region.at.length - 1; depth > 0; depth -= 1) {
|
|
245
|
+
const parent = containerFor(document, region.at.slice(0, depth), false);
|
|
246
|
+
const parentKey = region.at[depth - 1];
|
|
247
|
+
const node = parent?.[parentKey];
|
|
248
|
+
if (parent && isRecord(node) && Object.keys(node).length === 0) delete parent[parentKey];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Writes the document's managed regions into whatever is already there, and
|
|
254
|
+
* records them. An unchanged document is not rewritten, so `current` means
|
|
255
|
+
* untouched — which is what makes a second wrap modify no file.
|
|
256
|
+
*
|
|
257
|
+
* What the last wrap recorded comes back out before this wrap's regions go in,
|
|
258
|
+
* because placing is a reconciliation and not an append. `applyRegion` adds a
|
|
259
|
+
* list value only when it is not already there, so a value that has since
|
|
260
|
+
* changed — a bumped plugin version, a plugin root that moved — would otherwise
|
|
261
|
+
* settle in beside its predecessor, and the predecessor is no longer in the
|
|
262
|
+
* manifest for `removeRuntimeDocument` to reach. Reverting first is value-
|
|
263
|
+
* guarded, so what a human has edited since is not Mate's to take back and
|
|
264
|
+
* survives the round-trip as theirs.
|
|
265
|
+
*
|
|
266
|
+
* A render that no longer produces this destination withdraws it. `document()`
|
|
267
|
+
* drops empty regions and returns nothing when none are left, so a document
|
|
268
|
+
* whose last capability was disabled simply stops being rendered — and without
|
|
269
|
+
* the withdrawal below the entries the previous wrap wrote would stay live
|
|
270
|
+
* forever, a disabled MCP server still declared in the user's `~/.claude.json`.
|
|
271
|
+
* The withdrawal is the same one `unproject` performs, and it reaches only the
|
|
272
|
+
* one destination this call names, so a scope that projects a smaller set of
|
|
273
|
+
* documents can never take back a document it was not asked about.
|
|
274
|
+
*/
|
|
275
|
+
export async function placeRuntimeDocument(
|
|
276
|
+
repoPath: string,
|
|
277
|
+
documentPath: string,
|
|
278
|
+
documents?: readonly RenderedRuntimeDocument[],
|
|
279
|
+
): Promise<"written" | "current" | "skipped"> {
|
|
280
|
+
/**
|
|
281
|
+
* No render at all is not an empty render: a scope that supplies no documents
|
|
282
|
+
* — a launch, which projects this entry without rendering for it — is claiming
|
|
283
|
+
* nothing about this destination and must leave what a wrap recorded alone.
|
|
284
|
+
*/
|
|
285
|
+
if (!documents) return "skipped";
|
|
286
|
+
|
|
287
|
+
const rendered = documents.find((candidate) => candidate.path === documentPath);
|
|
288
|
+
if (!rendered) {
|
|
289
|
+
return (await removeRuntimeDocument(repoPath, documentPath)) === "removed"
|
|
290
|
+
? "written"
|
|
291
|
+
: "current";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const target = documentPathIn(repoPath, documentPath);
|
|
295
|
+
const raw = await readRaw(target);
|
|
296
|
+
const document = parseDocument(raw, target);
|
|
297
|
+
|
|
298
|
+
const manifest = await readManifest(repoPath);
|
|
299
|
+
for (const region of manifest.documents[documentPath] ?? []) revertRegion(document, region);
|
|
300
|
+
for (const region of rendered.regions) applyRegion(document, region);
|
|
301
|
+
const next = serialize(document);
|
|
302
|
+
|
|
303
|
+
const regions = [...rendered.regions] as ManagedRegion[];
|
|
304
|
+
const recorded = JSON.stringify(manifest.documents[documentPath]) === JSON.stringify(regions);
|
|
305
|
+
if (next === raw && recorded) return "current";
|
|
306
|
+
|
|
307
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
308
|
+
await writeAtomic(target, next);
|
|
309
|
+
manifest.documents[documentPath] = regions;
|
|
310
|
+
await writeManifest(repoPath, manifest);
|
|
311
|
+
return "written";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Strips the recorded regions and leaves the document Mate found; a document
|
|
316
|
+
* left empty is deleted along with any directory Mate created to hold it.
|
|
317
|
+
*/
|
|
318
|
+
export async function removeRuntimeDocument(
|
|
319
|
+
repoPath: string,
|
|
320
|
+
documentPath: string,
|
|
321
|
+
): Promise<"removed" | "absent"> {
|
|
322
|
+
const manifest = await readManifest(repoPath);
|
|
323
|
+
const regions = manifest.documents[documentPath];
|
|
324
|
+
if (!regions) return "absent";
|
|
325
|
+
|
|
326
|
+
await revertDocumentOnDisk(repoPath, documentPath, regions);
|
|
327
|
+
delete manifest.documents[documentPath];
|
|
328
|
+
await writeManifest(repoPath, manifest);
|
|
329
|
+
return "removed";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* One document's regions, taken back out of the file that holds them. Knows
|
|
334
|
+
* nothing of the manifest, which is what lets several destinations be reverted
|
|
335
|
+
* at once: every document is a distinct file, while the manifest recording them
|
|
336
|
+
* all is not.
|
|
337
|
+
*/
|
|
338
|
+
async function revertDocumentOnDisk(
|
|
339
|
+
repoPath: string,
|
|
340
|
+
documentPath: string,
|
|
341
|
+
regions: readonly ManagedRegion[],
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
const target = documentPathIn(repoPath, documentPath);
|
|
344
|
+
const raw = await readRaw(target);
|
|
345
|
+
if (raw === null) return;
|
|
346
|
+
|
|
347
|
+
const document = parseDocument(raw, target);
|
|
348
|
+
for (const region of regions) revertRegion(document, region);
|
|
349
|
+
if (Object.keys(document).length === 0 && !isExternalDocument(documentPath)) {
|
|
350
|
+
await fs.unlink(target);
|
|
351
|
+
const holder = path.dirname(target);
|
|
352
|
+
if (holder !== path.resolve(repoPath)) {
|
|
353
|
+
await pruneEmptyAncestors(holder, path.resolve(repoPath));
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
await writeAtomic(target, serialize(document));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Withdraws several destinations against a single read and a single write of the
|
|
362
|
+
* manifest. `removeRuntimeDocument` per destination cannot be run concurrently —
|
|
363
|
+
* each rewrites the whole manifest, so the last write restores the keys the
|
|
364
|
+
* others removed — and running it sequentially rewrites the manifest once per
|
|
365
|
+
* document. Reverting the files together and recording the outcome once is both
|
|
366
|
+
* safe and one write.
|
|
367
|
+
*
|
|
368
|
+
* A document that throws keeps its manifest entry, so the record still names
|
|
369
|
+
* what is left behind, and the destinations that did succeed are still
|
|
370
|
+
* withdrawn. The first error is returned rather than raised: unwrapping reports
|
|
371
|
+
* per document, and a rejected batch would lose which one failed.
|
|
372
|
+
*/
|
|
373
|
+
export async function removeRuntimeDocuments(
|
|
374
|
+
repoPath: string,
|
|
375
|
+
documentPaths: readonly string[],
|
|
376
|
+
): Promise<{ removed: string[]; absent: string[]; error?: { document: string; error: Error } }> {
|
|
377
|
+
const manifest = await readManifest(repoPath);
|
|
378
|
+
const recorded = documentPaths.filter((candidate) => manifest.documents[candidate] !== undefined);
|
|
379
|
+
const absent = documentPaths.filter((candidate) => manifest.documents[candidate] === undefined);
|
|
380
|
+
if (recorded.length === 0) return { removed: [], absent };
|
|
381
|
+
|
|
382
|
+
const settled = await Promise.allSettled(
|
|
383
|
+
recorded.map((documentPath) =>
|
|
384
|
+
revertDocumentOnDisk(repoPath, documentPath, manifest.documents[documentPath]!),
|
|
385
|
+
),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const removed: string[] = [];
|
|
389
|
+
let failure: { document: string; error: Error } | undefined;
|
|
390
|
+
for (const [index, result] of settled.entries()) {
|
|
391
|
+
const documentPath = recorded[index]!;
|
|
392
|
+
if (result.status === "rejected") {
|
|
393
|
+
failure ??= {
|
|
394
|
+
document: documentPath,
|
|
395
|
+
error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
|
|
396
|
+
};
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
delete manifest.documents[documentPath];
|
|
400
|
+
removed.push(documentPath);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await writeManifest(repoPath, manifest);
|
|
404
|
+
return { removed, absent, ...(failure ? { error: failure } : {}) };
|
|
405
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { GlobalConfigStore } from "./global-config-store";
|
|
2
|
+
import type { ProjectionFile } from "../../runtime/projection";
|
|
3
|
+
import type { CompanionSource, FrameworkConfig, LinkedRepository } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `owned` — Mate created the whole path; removal deletes it. `merged` — Mate
|
|
7
|
+
* maintains a marked region inside a path a human may also author; removal
|
|
8
|
+
* strips exactly that region and rewrites nothing else.
|
|
9
|
+
*/
|
|
10
|
+
export type ProjectionEntryKind = "owned" | "merged";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the caller is doing, not which files it wants. The entry set cannot be
|
|
14
|
+
* derived from the inputs: recording a Repository Link holds a companion and a
|
|
15
|
+
* repository yet deliberately writes no projection, because wrapping is an
|
|
16
|
+
* explicit act.
|
|
17
|
+
*/
|
|
18
|
+
export type ProjectionScope = "link" | "session" | "launch" | "workspace" | "wrap";
|
|
19
|
+
|
|
20
|
+
export type ProjectionEntryId =
|
|
21
|
+
| "git-excludes"
|
|
22
|
+
| "projection-root"
|
|
23
|
+
| "companion-link"
|
|
24
|
+
| "repo-local-framework"
|
|
25
|
+
| "repo-local-registry"
|
|
26
|
+
| "projection-pair"
|
|
27
|
+
| "claude-skill-links"
|
|
28
|
+
| "workspace-document"
|
|
29
|
+
| "capability-excludes"
|
|
30
|
+
| "claude-working-settings"
|
|
31
|
+
| "legacy-tokensave-claude-md"
|
|
32
|
+
| "claude-runtime-document"
|
|
33
|
+
| "claude-local-mcp-document"
|
|
34
|
+
| "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
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ProjectionInput {
|
|
69
|
+
repoPath: string;
|
|
70
|
+
companionPath?: string;
|
|
71
|
+
repository?: LinkedRepository;
|
|
72
|
+
source?: CompanionSource;
|
|
73
|
+
config?: FrameworkConfig;
|
|
74
|
+
/** Which companion paths Mate registered; injectable so fixtures stay hermetic. */
|
|
75
|
+
globalConfigStore?: GlobalConfigStore;
|
|
76
|
+
/** Rendered by the active Runtime Surfaces; placed by the `wrap` scope's entries. */
|
|
77
|
+
runtimeDocuments?: readonly RenderedRuntimeDocument[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ProjectionRemovalInput {
|
|
81
|
+
repoPath: string;
|
|
82
|
+
/** Companion paths Mate registered; only those are Mate's to strip. */
|
|
83
|
+
registeredCompanionPaths?: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type ProjectionWriteState = "written" | "current" | "skipped";
|
|
87
|
+
export type ProjectionRemoveState = "removed" | "absent" | "retained" | "covered";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every entry names its inverse. `self` removes its own path, `entry` is covered
|
|
91
|
+
* by another entry's removal, `retained` is deliberately left behind and says
|
|
92
|
+
* why — so a path is never merely absent from removal.
|
|
93
|
+
*/
|
|
94
|
+
export type ProjectionEntryRemoval =
|
|
95
|
+
| {
|
|
96
|
+
readonly by: "self";
|
|
97
|
+
remove(input: ProjectionRemovalInput): Promise<"removed" | "absent">;
|
|
98
|
+
}
|
|
99
|
+
| { readonly by: "entry"; readonly entry: ProjectionEntryId }
|
|
100
|
+
| { readonly by: "retained"; readonly because: string };
|
|
101
|
+
|
|
102
|
+
export interface ProjectionEntry {
|
|
103
|
+
readonly id: ProjectionEntryId;
|
|
104
|
+
readonly kind: ProjectionEntryKind;
|
|
105
|
+
/** Repo-relative, POSIX separators. An entry spanning siblings names its primary. */
|
|
106
|
+
readonly path: string;
|
|
107
|
+
readonly scopes: readonly ProjectionScope[];
|
|
108
|
+
/** `skipped` when an input this entry needs was not supplied. */
|
|
109
|
+
write(input: ProjectionInput): Promise<ProjectionWriteState>;
|
|
110
|
+
/**
|
|
111
|
+
* Whether a failed write is reported without failing the operation that
|
|
112
|
+
* triggered the projection. Only for an entry something else already covers:
|
|
113
|
+
* the link is an optimisation over the per-runtime allow-lists, so a platform
|
|
114
|
+
* that permits no link degrades rather than fails.
|
|
115
|
+
*/
|
|
116
|
+
readonly degradable?: boolean;
|
|
117
|
+
readonly removal: ProjectionEntryRemoval;
|
|
118
|
+
/** Whether `unwrap` withdraws this entry in addition to recorded documents. */
|
|
119
|
+
readonly removeOnUnwrap?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Whether the entry is on disk. Defaults to `path` existing, which is only
|
|
122
|
+
* the truth for an `owned` entry: a `merged` entry shares its host path with
|
|
123
|
+
* the human and must answer for its region alone.
|
|
124
|
+
*/
|
|
125
|
+
present?(repoPath: string): Promise<boolean>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ProjectionEntryOutcome {
|
|
129
|
+
id: ProjectionEntryId;
|
|
130
|
+
path: string;
|
|
131
|
+
kind: ProjectionEntryKind;
|
|
132
|
+
state: ProjectionWriteState | ProjectionRemoveState | "failed";
|
|
133
|
+
error?: Error;
|
|
134
|
+
degradable?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ProjectionResult {
|
|
138
|
+
root: string;
|
|
139
|
+
scope: ProjectionScope;
|
|
140
|
+
outcomes: ProjectionEntryOutcome[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ProjectionRemovalResult {
|
|
144
|
+
root: string;
|
|
145
|
+
outcomes: ProjectionEntryOutcome[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ProjectionEntryPresence {
|
|
149
|
+
id: ProjectionEntryId;
|
|
150
|
+
path: string;
|
|
151
|
+
kind: ProjectionEntryKind;
|
|
152
|
+
present: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What `describe` reports. No freshness verdict: that lives above this module. */
|
|
156
|
+
export interface ProjectionDescription {
|
|
157
|
+
root: string;
|
|
158
|
+
entries: ProjectionEntryPresence[];
|
|
159
|
+
projection: ProjectionFile | null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function anyChanged(outcomes: ProjectionEntryOutcome[]): boolean {
|
|
163
|
+
return outcomes.some((outcome) => outcome.state === "written" || outcome.state === "removed");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A degradable entry's failure is reported in the outcomes and nowhere else. */
|
|
167
|
+
export function firstFailure(outcomes: ProjectionEntryOutcome[]): ProjectionEntryOutcome | null {
|
|
168
|
+
return outcomes.find((outcome) => outcome.state === "failed" && !outcome.degradable) ?? null;
|
|
169
|
+
}
|