@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,139 @@
|
|
|
1
|
+
import type { Cleanup, Context } from "@opencode/plugin/promise/plugin";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PREPARE_TIMEOUT_MS,
|
|
5
|
+
type PrepareRun,
|
|
6
|
+
type PrepareRunner,
|
|
7
|
+
} from "../runtime/session-prepare-runner";
|
|
8
|
+
import { recordSessionNotes, type SessionNotePhase } from "./session-notes";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plugin side of `mate session prepare`: a bare OpenCode session in a wrapped
|
|
12
|
+
* repo gets a managed launch's local prepare, then a background Git sync.
|
|
13
|
+
* Spawned, so a crash or stale module never reaches the shared service.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface SessionPrepareOptions {
|
|
17
|
+
directory: string;
|
|
18
|
+
repoPath: string;
|
|
19
|
+
companionPath: string;
|
|
20
|
+
/** Mate-declared MCP servers; only these are held back until the local phase ends. */
|
|
21
|
+
mateServers: readonly string[];
|
|
22
|
+
run: PrepareRunner;
|
|
23
|
+
log?: (line: string) => void;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
debounceMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PREPARE_DEBOUNCE_MS = 5_000;
|
|
29
|
+
|
|
30
|
+
/** Keyed by companion: several repositories, and several locations, share one companion. */
|
|
31
|
+
const lastSynced = new Map<string, number>();
|
|
32
|
+
|
|
33
|
+
/** Test seam: the debounce is process-wide. */
|
|
34
|
+
export function resetPrepareDebounce(): void {
|
|
35
|
+
lastSynced.clear();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function logger(options: SessionPrepareOptions): (line: string) => void {
|
|
39
|
+
return options.log ?? ((line: string) => console.error(line));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Logs the notes and records them for the TUI's toast. */
|
|
43
|
+
async function report(
|
|
44
|
+
options: SessionPrepareOptions,
|
|
45
|
+
run: PrepareRun,
|
|
46
|
+
phase: SessionNotePhase,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const log = logger(options);
|
|
49
|
+
const verdict = run.ok ? "warning" : "failed";
|
|
50
|
+
for (const note of run.notes) log(`mate: session ${phase} ${verdict}: ${note}`);
|
|
51
|
+
await recordSessionNotes(options.repoPath, phase, run.notes);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function failedRun(error: unknown): PrepareRun {
|
|
55
|
+
return { ok: false, notes: [error instanceof Error ? error.message : String(error)] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** No remote: OpenCode holds MCP servers and skills until `setup` returns. */
|
|
59
|
+
async function prepareLocal(options: SessionPrepareOptions): Promise<void> {
|
|
60
|
+
const run = await options
|
|
61
|
+
.run({ sync: false, timeoutMs: options.timeoutMs ?? PREPARE_TIMEOUT_MS })
|
|
62
|
+
.catch(failedRun);
|
|
63
|
+
await report(options, run, "prepare");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Never awaited: the session runs on while the companion is fetched. */
|
|
67
|
+
function startSync(api: Context, options: SessionPrepareOptions): void {
|
|
68
|
+
lastSynced.set(options.companionPath, Date.now());
|
|
69
|
+
void options
|
|
70
|
+
.run({ sync: true })
|
|
71
|
+
.catch(failedRun)
|
|
72
|
+
.then(async (run) => {
|
|
73
|
+
await report(options, run, "sync");
|
|
74
|
+
if (!run.changed) return;
|
|
75
|
+
await api.mcp.reload().catch(() => {});
|
|
76
|
+
logger(options)("mate: companion updated; MCP servers reloaded.");
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function eventDirectory(event: unknown): string | undefined {
|
|
81
|
+
const record = event as {
|
|
82
|
+
location?: { directory?: string };
|
|
83
|
+
data?: { location?: { directory?: string } };
|
|
84
|
+
};
|
|
85
|
+
return record.location?.directory ?? record.data?.location?.directory;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Holds the Mate MCP servers back, runs the local phase before `setup`
|
|
90
|
+
* returns, releases them, then syncs the companion in the background; syncs
|
|
91
|
+
* again on new sessions of this location. Neither a failed nor a slow prepare
|
|
92
|
+
* keeps the session from starting, and none is ever terminated.
|
|
93
|
+
*/
|
|
94
|
+
export async function registerSessionPrepare(
|
|
95
|
+
api: Context,
|
|
96
|
+
options: SessionPrepareOptions,
|
|
97
|
+
): Promise<Cleanup> {
|
|
98
|
+
const held = new Set(options.mateServers);
|
|
99
|
+
let gated = held.size > 0;
|
|
100
|
+
if (gated) {
|
|
101
|
+
await api.mcp.transform((editor) => {
|
|
102
|
+
if (!gated) return;
|
|
103
|
+
for (const name of held) {
|
|
104
|
+
if (editor.get(name)) {
|
|
105
|
+
editor.update(name, (config) => {
|
|
106
|
+
config.disabled = true;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
await prepareLocal(options);
|
|
115
|
+
} finally {
|
|
116
|
+
if (gated) {
|
|
117
|
+
gated = false;
|
|
118
|
+
await api.mcp.reload().catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
startSync(api, options);
|
|
122
|
+
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const debounceMs = options.debounceMs ?? PREPARE_DEBOUNCE_MS;
|
|
125
|
+
void (async () => {
|
|
126
|
+
try {
|
|
127
|
+
for await (const event of api.event.subscribe({ signal: controller.signal })) {
|
|
128
|
+
if (event.type !== "session.created") continue;
|
|
129
|
+
if (eventDirectory(event) !== options.directory) continue;
|
|
130
|
+
const last = lastSynced.get(options.companionPath) ?? 0;
|
|
131
|
+
if (Date.now() - last < debounceMs) continue;
|
|
132
|
+
startSync(api, options);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
/* subscription closed: the location is shutting down */
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
return () => controller.abort();
|
|
139
|
+
}
|
package/src/opencode/tui.tsx
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* OpenCode V2 TUI plugin module (`{ id, setup }`), OpenCode 2.x only.
|
|
5
5
|
* Solid JSX, not React: `key` is not a reconciliation prop here and `TextProps`
|
|
6
6
|
* does not accept it, so the jsx-key rules cannot be satisfied — only suppressed.
|
|
7
|
-
*
|
|
7
|
+
* Arrays render through `.map`: Solid re-runs it whole on a store change, fine for a few lines.
|
|
8
8
|
*/
|
|
9
9
|
import { mateVersion } from "../runtime/install";
|
|
10
|
-
import { readContext } from "./companion-policy";
|
|
10
|
+
import { readContext, readStalenessLines } from "./companion-policy";
|
|
11
11
|
|
|
12
12
|
/** Minimal structural slice of the OpenCode V2 TUI API; the published SDK ships no V2 TUI types. */
|
|
13
13
|
export type MateTuiApi = {
|
|
@@ -20,6 +20,14 @@ export type MateTuiApi = {
|
|
|
20
20
|
};
|
|
21
21
|
ui: {
|
|
22
22
|
slot(claim: MateSlotClaim): () => void;
|
|
23
|
+
toast: { show(options: { title?: string; message: string; variant?: "warning" }): void };
|
|
24
|
+
};
|
|
25
|
+
storage: {
|
|
26
|
+
/** A host Solid store: reads inside a render are reactive. */
|
|
27
|
+
memory<Value extends object>(
|
|
28
|
+
key: string,
|
|
29
|
+
options: { readonly initial: Value },
|
|
30
|
+
): readonly [Value, (mutation: (draft: Value) => void) => void];
|
|
23
31
|
};
|
|
24
32
|
};
|
|
25
33
|
|
|
@@ -31,13 +39,26 @@ export type MateSlotClaim = {
|
|
|
31
39
|
|
|
32
40
|
export type MateTuiPluginModule = {
|
|
33
41
|
id: string;
|
|
34
|
-
setup(api: MateTuiApi): void | Promise<void>;
|
|
42
|
+
setup(api: MateTuiApi): void | (() => void) | Promise<void | (() => void)>;
|
|
35
43
|
};
|
|
36
44
|
|
|
45
|
+
/** A background sync finishes after the first render; the lines must follow it. */
|
|
46
|
+
export const STALENESS_POLL_MS = 5_000;
|
|
47
|
+
|
|
48
|
+
type SessionNotes = { lines: string[] };
|
|
49
|
+
|
|
37
50
|
const MIDNIGHT_PURPLE_BRIGHT = "#c084fc";
|
|
38
51
|
const MATE_VERSION = process.env.MATE_VERSION ?? mateVersion();
|
|
39
52
|
|
|
40
|
-
function SessionContext({
|
|
53
|
+
function SessionContext({
|
|
54
|
+
api,
|
|
55
|
+
notes,
|
|
56
|
+
sidebar = false,
|
|
57
|
+
}: {
|
|
58
|
+
api: MateTuiApi;
|
|
59
|
+
notes: SessionNotes;
|
|
60
|
+
sidebar?: boolean;
|
|
61
|
+
}) {
|
|
41
62
|
const context = readContext();
|
|
42
63
|
const text = api.theme.text;
|
|
43
64
|
const warning = text.feedback?.warning?.base ?? text.muted;
|
|
@@ -54,21 +75,47 @@ function SessionContext({ api, sidebar = false }: { api: MateTuiApi; sidebar?: b
|
|
|
54
75
|
<text fg={MIDNIGHT_PURPLE_BRIGHT}>mate v{MATE_VERSION}</text>
|
|
55
76
|
<text fg={text.muted}>repo: {context.repositoryPath}</text>
|
|
56
77
|
<text fg={text.muted}>mate: {context.companionPath}</text>
|
|
57
|
-
{
|
|
78
|
+
{notes.lines.map((note) => (
|
|
58
79
|
<text fg={warning}>{note}</text>
|
|
59
80
|
))}
|
|
60
81
|
</box>
|
|
61
82
|
);
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
|
|
85
|
+
/** Polls the session lines; a line that appears after startup is also raised as a toast. */
|
|
86
|
+
function setup(api: MateTuiApi): (() => void) | undefined {
|
|
65
87
|
const context = readContext();
|
|
66
88
|
if (!context.companionPath || !context.repositoryPath) {
|
|
67
|
-
return;
|
|
89
|
+
return undefined;
|
|
68
90
|
}
|
|
69
91
|
|
|
70
|
-
api.
|
|
71
|
-
|
|
92
|
+
const [notes, setNotes] = api.storage.memory<SessionNotes>("mate-session-notes", {
|
|
93
|
+
initial: { lines: context.stalenessLines },
|
|
94
|
+
});
|
|
95
|
+
setNotes((draft) => {
|
|
96
|
+
draft.lines = context.stalenessLines;
|
|
97
|
+
});
|
|
98
|
+
const timer = setInterval(() => {
|
|
99
|
+
const lines = readStalenessLines();
|
|
100
|
+
if (lines.join("\n") === notes.lines.join("\n")) return;
|
|
101
|
+
const shown = new Set(notes.lines);
|
|
102
|
+
const added = lines.filter((line) => !shown.has(line));
|
|
103
|
+
setNotes((draft) => {
|
|
104
|
+
draft.lines = lines;
|
|
105
|
+
});
|
|
106
|
+
for (const message of added) api.ui.toast.show({ title: "mate", message, variant: "warning" });
|
|
107
|
+
}, STALENESS_POLL_MS);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
|
|
110
|
+
api.ui.slot({
|
|
111
|
+
prepend: "home.footer",
|
|
112
|
+
render: () => <SessionContext api={api} notes={notes} />,
|
|
113
|
+
});
|
|
114
|
+
api.ui.slot({
|
|
115
|
+
append: "sidebar.content",
|
|
116
|
+
render: () => <SessionContext api={api} notes={notes} sidebar />,
|
|
117
|
+
});
|
|
118
|
+
return () => clearInterval(timer);
|
|
72
119
|
}
|
|
73
120
|
|
|
74
121
|
const plugin: MateTuiPluginModule = {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { FRAMEWORK_NAME } from "./framework";
|
|
6
|
+
import { mateHome } from "./runtime-directory";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Audience Layer locations. A layer holds one audience's plugin contributions
|
|
10
|
+
* for one companion, rendered for managed launches only. It lives under Mate's
|
|
11
|
+
* machine-local state, never inside a repository: a layer inside the companion
|
|
12
|
+
* would be readable by every session with companion access, and could carry
|
|
13
|
+
* values resolved from plugin configuration.
|
|
14
|
+
*
|
|
15
|
+
* <state>/audience-layers/<companion-key>/<audience>/
|
|
16
|
+
* <fingerprint>/ one complete render; never changed once renamed into place
|
|
17
|
+
* current symlink to the newest complete render
|
|
18
|
+
*
|
|
19
|
+
* A launch attaches the fingerprint directory itself, so a later re-render
|
|
20
|
+
* never changes what a running session reads.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Mirrors `AUDIENCE_NAME_PATTERN`; repeated here because `runtime/` imports nothing from core. */
|
|
24
|
+
const SAFE_AUDIENCE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
25
|
+
|
|
26
|
+
export const CURRENT_LAYER_LINK = "current";
|
|
27
|
+
|
|
28
|
+
export function audienceLayersRoot(homeDir?: string): string {
|
|
29
|
+
return path.join(mateHome(homeDir), "state", "audience-layers");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A short, stable key for one companion checkout: a hash of its resolved path. */
|
|
33
|
+
export function companionLayerKey(companionPath: string): string {
|
|
34
|
+
return crypto.createHash("sha256").update(path.resolve(companionPath)).digest("hex").slice(0, 16);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The directory holding one audience's layers for one companion. */
|
|
38
|
+
export function audienceLayerDir(
|
|
39
|
+
companionPath: string,
|
|
40
|
+
audience: string,
|
|
41
|
+
homeDir?: string,
|
|
42
|
+
): string {
|
|
43
|
+
if (!SAFE_AUDIENCE.test(audience)) {
|
|
44
|
+
throw new Error(`"${audience}" is not a valid audience name for a layer directory`);
|
|
45
|
+
}
|
|
46
|
+
return path.join(audienceLayersRoot(homeDir), companionLayerKey(companionPath), audience);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The newest complete layer of `audience`, or `null` when none was rendered on this machine. */
|
|
50
|
+
export function currentAudienceLayer(
|
|
51
|
+
companionPath: string,
|
|
52
|
+
audience: string,
|
|
53
|
+
homeDir?: string,
|
|
54
|
+
): string | null {
|
|
55
|
+
try {
|
|
56
|
+
const dir = audienceLayerDir(companionPath, audience, homeDir);
|
|
57
|
+
const layer = path.join(dir, fs.readlinkSync(path.join(dir, CURRENT_LAYER_LINK)));
|
|
58
|
+
return fs.existsSync(layer) ? layer : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MANAGED_MARKER = new RegExp(
|
|
65
|
+
`^<!-- ${FRAMEWORK_NAME}:managed:[^\\n]* (?:start|end) -->$`,
|
|
66
|
+
"gm",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** A layer guidance document's text, managed-block markers removed; empty when absent. */
|
|
70
|
+
export function layerGuidanceText(text: string): string {
|
|
71
|
+
return text
|
|
72
|
+
.replace(MANAGED_MARKER, "")
|
|
73
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `file` (`CLAUDE.md`, `AGENTS.md`) of the layer at `root`, markers removed; empty when absent. */
|
|
78
|
+
export function readLayerGuidanceSync(root: string, file: string): string {
|
|
79
|
+
try {
|
|
80
|
+
return layerGuidanceText(fs.readFileSync(path.join(root, file), "utf8"));
|
|
81
|
+
} catch {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ClaudeHookGroup } from "./claude-hook-group";
|
|
2
|
+
import { NOT_WRAPPED, readWrapStateSync, type WrapState } from "./wrap-state";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Claude Delivery Plan: the owner of each Claude contribution, as one pure
|
|
6
|
+
* function. Callers act on it and decide no ownership. In `runtime/` so hooks
|
|
7
|
+
* may import it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `managed-repo`: `mate claude` with a Working Repository; its prepare just
|
|
12
|
+
* reconciled the Claude wrap, which the launcher asserts. `managed-companion`:
|
|
13
|
+
* a companion-scoped launch. `unmanaged`: a session with no launch environment.
|
|
14
|
+
*/
|
|
15
|
+
export type SessionKind = "managed-repo" | "managed-companion" | "unmanaged";
|
|
16
|
+
|
|
17
|
+
export interface ClaudeDeliveryPlan {
|
|
18
|
+
/** Capability hook groups; `launchSettingsExclude` is what `--settings` must not repeat. */
|
|
19
|
+
readonly hooks: {
|
|
20
|
+
readonly owner: "wrap" | "settings" | "none";
|
|
21
|
+
readonly launchSettingsExclude: readonly ClaudeHookGroup[];
|
|
22
|
+
};
|
|
23
|
+
/** Mate MCP servers. */
|
|
24
|
+
readonly mcp: "wrap" | "mcp-config" | "none";
|
|
25
|
+
/** Companion directory access. */
|
|
26
|
+
readonly dirAccess: "wrap" | "add-dir" | "none";
|
|
27
|
+
/** Wrap reconciliation. */
|
|
28
|
+
readonly reconcile: "attended-prepare" | "prepare-hook" | "none";
|
|
29
|
+
/** Companion Git synchronization. */
|
|
30
|
+
readonly gitSync: "launch-preflight" | "prepare-hook" | "banner";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function planClaudeDelivery(kind: SessionKind, wrap: WrapState): ClaudeDeliveryPlan {
|
|
34
|
+
switch (kind) {
|
|
35
|
+
case "managed-repo":
|
|
36
|
+
return {
|
|
37
|
+
hooks: { owner: "wrap", launchSettingsExclude: wrap.carriedClaudeHookGroups },
|
|
38
|
+
mcp: "wrap",
|
|
39
|
+
dirAccess: "wrap",
|
|
40
|
+
reconcile: "attended-prepare",
|
|
41
|
+
gitSync: "launch-preflight",
|
|
42
|
+
};
|
|
43
|
+
case "managed-companion":
|
|
44
|
+
return {
|
|
45
|
+
hooks: { owner: "settings", launchSettingsExclude: [] },
|
|
46
|
+
mcp: "mcp-config",
|
|
47
|
+
dirAccess: "add-dir",
|
|
48
|
+
reconcile: "none",
|
|
49
|
+
gitSync: "launch-preflight",
|
|
50
|
+
};
|
|
51
|
+
case "unmanaged":
|
|
52
|
+
return wrap.runtimes.has("claude")
|
|
53
|
+
? {
|
|
54
|
+
hooks: { owner: "wrap", launchSettingsExclude: [] },
|
|
55
|
+
mcp: "wrap",
|
|
56
|
+
dirAccess: "wrap",
|
|
57
|
+
reconcile: "prepare-hook",
|
|
58
|
+
gitSync: "prepare-hook",
|
|
59
|
+
}
|
|
60
|
+
: {
|
|
61
|
+
hooks: { owner: "none", launchSettingsExclude: [] },
|
|
62
|
+
mcp: "none",
|
|
63
|
+
dirAccess: "none",
|
|
64
|
+
reconcile: "none",
|
|
65
|
+
gitSync: "banner",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** For session-start hooks: a plain session's plan from a lenient read; no root, not wrapped. */
|
|
71
|
+
export function unmanagedClaudeDelivery(repoRoot: string | undefined): ClaudeDeliveryPlan {
|
|
72
|
+
return planClaudeDelivery("unmanaged", repoRoot ? readWrapStateSync(repoRoot) : NOT_WRAPPED);
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude hook-group shape and its one identity. A `runtime/` leaf so hooks and
|
|
3
|
+
* Wrap State can compare groups without loading setup code.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ClaudeHookCommand {
|
|
7
|
+
type?: string;
|
|
8
|
+
command?: string;
|
|
9
|
+
args?: string[];
|
|
10
|
+
timeout?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ClaudeHookGroup {
|
|
14
|
+
matcher?: string;
|
|
15
|
+
hooks?: ClaudeHookCommand[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The one hook-group identity: structurally identical groups are the same group. */
|
|
19
|
+
export function hookGroupKey(group: ClaudeHookGroup): string {
|
|
20
|
+
return JSON.stringify(group);
|
|
21
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fsSync from "node:fs";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { mateHome } from "./runtime-directory";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Per-companion lock under `<mate home>/locks/`, outside the companion's tree;
|
|
11
|
+
* repos share a companion, so a per-repo lock would race pulls. Re-entrant per
|
|
12
|
+
* async call chain, not per process.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface CompanionLockOptions {
|
|
16
|
+
homeDir?: string;
|
|
17
|
+
/** `false` tries once and returns `acquired: false` at once when the lock is held. */
|
|
18
|
+
wait?: boolean;
|
|
19
|
+
/** How long to wait for another holder before giving up. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
pollMs?: number;
|
|
22
|
+
/** Called once, when the lock is found held and the caller starts waiting. */
|
|
23
|
+
onWait?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CompanionLockResult<T> = { acquired: true; value: T } | { acquired: false };
|
|
27
|
+
|
|
28
|
+
function companionKey(companionPath: string): string {
|
|
29
|
+
let resolved = path.resolve(companionPath);
|
|
30
|
+
try {
|
|
31
|
+
resolved = fsSync.realpathSync(resolved);
|
|
32
|
+
} catch {
|
|
33
|
+
/* an unresolvable companion is still keyed by its spelling */
|
|
34
|
+
}
|
|
35
|
+
return crypto.createHash("sha256").update(resolved).digest("hex").slice(0, 32);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function companionLockPath(companionPath: string, homeDir?: string): string {
|
|
39
|
+
return path.join(mateHome(homeDir), "locks", `${companionKey(companionPath)}.lock`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const heldLocks = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
43
|
+
|
|
44
|
+
/** An unreadable holder id is only trusted this long, so a corrupt lock cannot block forever. */
|
|
45
|
+
const UNREADABLE_LOCK_GRACE_MS = 10_000;
|
|
46
|
+
|
|
47
|
+
async function holderAlive(lockPath: string, raw: string): Promise<boolean> {
|
|
48
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
49
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
50
|
+
const stat = await fs.stat(lockPath).catch(() => null);
|
|
51
|
+
return stat !== null && Date.now() - stat.mtimeMs < UNREADABLE_LOCK_GRACE_MS;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The holder's pid is written to a private file first and hard-linked into
|
|
63
|
+
* place, so the lock never exists without its holder: a contender reading an
|
|
64
|
+
* empty file in between would otherwise take it as stale and remove it.
|
|
65
|
+
*/
|
|
66
|
+
async function tryAcquire(lockPath: string): Promise<boolean> {
|
|
67
|
+
const tempPath = `${lockPath}.${crypto.randomUUID()}.tmp`;
|
|
68
|
+
await fs.writeFile(tempPath, String(process.pid));
|
|
69
|
+
try {
|
|
70
|
+
await fs.link(tempPath, lockPath);
|
|
71
|
+
return true;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
74
|
+
} finally {
|
|
75
|
+
await fs.rm(tempPath, { force: true });
|
|
76
|
+
}
|
|
77
|
+
const raw = await fs.readFile(lockPath, "utf8").catch(() => null);
|
|
78
|
+
if (raw === null || (await holderAlive(lockPath, raw))) return false;
|
|
79
|
+
/** A dead holder's lock is removed only if it is still the one just read. */
|
|
80
|
+
if ((await fs.readFile(lockPath, "utf8").catch(() => null)) === raw) {
|
|
81
|
+
await fs.rm(lockPath, { force: true });
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Runs `task` while holding the companion's lock; `acquired: false` after the
|
|
88
|
+
* bounded wait, or at once with `wait: false`. A caller already holding it in
|
|
89
|
+
* this call chain runs `task` directly.
|
|
90
|
+
*/
|
|
91
|
+
export async function withCompanionLock<T>(
|
|
92
|
+
companionPath: string,
|
|
93
|
+
options: CompanionLockOptions,
|
|
94
|
+
task: () => Promise<T>,
|
|
95
|
+
): Promise<CompanionLockResult<T>> {
|
|
96
|
+
const lockPath = companionLockPath(companionPath, options.homeDir);
|
|
97
|
+
const held = heldLocks.getStore() ?? new Set<string>();
|
|
98
|
+
if (held.has(lockPath)) return { acquired: true, value: await task() };
|
|
99
|
+
|
|
100
|
+
const timeoutMs = options.wait === false ? 0 : (options.timeoutMs ?? 30_000);
|
|
101
|
+
const pollMs = options.pollMs ?? 200;
|
|
102
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
103
|
+
|
|
104
|
+
const deadline = Date.now() + timeoutMs;
|
|
105
|
+
let waiting = false;
|
|
106
|
+
// oxlint-disable-next-line no-await-in-loop -- polling is the point
|
|
107
|
+
while (!(await tryAcquire(lockPath))) {
|
|
108
|
+
if (Date.now() >= deadline) return { acquired: false };
|
|
109
|
+
if (!waiting) {
|
|
110
|
+
waiting = true;
|
|
111
|
+
options.onWait?.();
|
|
112
|
+
}
|
|
113
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return { acquired: true, value: await heldLocks.run(new Set([...held, lockPath]), task) };
|
|
118
|
+
} finally {
|
|
119
|
+
await fs.rm(lockPath, { force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type CompanionForkState,
|
|
31
31
|
type CompanionGit,
|
|
32
32
|
} from "./companion-git";
|
|
33
|
+
import { withCompanionLock } from "./companion-lock";
|
|
33
34
|
import { hasLaunchEnvironment } from "./env";
|
|
34
35
|
import { FRAMEWORK_NAME } from "./framework";
|
|
35
36
|
import { readCompanionPolicy } from "./policy";
|
|
@@ -37,10 +38,20 @@ import { readCompanionPolicy } from "./policy";
|
|
|
37
38
|
/** The one command that finishes what the unattended half declines to do. */
|
|
38
39
|
export const COMPANION_SYNC_COMMAND = `${FRAMEWORK_NAME} companion sync`;
|
|
39
40
|
|
|
41
|
+
/** OpenCode's recovery is the managed launch: it runs the sync at a terminal that can answer Git. */
|
|
42
|
+
export const OPENCODE_LAUNCH_COMMAND = `${FRAMEWORK_NAME} opencode`;
|
|
43
|
+
|
|
44
|
+
/** A Claude-wrapped repository's equivalent: the attended launch reconciles the wrap. */
|
|
45
|
+
export const CLAUDE_LAUNCH_COMMAND = `${FRAMEWORK_NAME} claude`;
|
|
46
|
+
|
|
47
|
+
/** Which runtime's operator reads the note; it decides the recovery named. */
|
|
48
|
+
export type StalenessAudience = "claude" | "opencode";
|
|
49
|
+
|
|
40
50
|
/** A cold link costs this once; the recorded completion then suppresses retries. */
|
|
41
51
|
export const COMPANION_SYNC_TIMEOUT_MS = 20_000;
|
|
42
52
|
|
|
43
|
-
|
|
53
|
+
/** `busy`: another operation held the companion lock; nothing ran and nothing is owed. */
|
|
54
|
+
export type UnattendedSyncStatus = "not-applicable" | "fresh" | "busy" | "complete" | "unfinished";
|
|
44
55
|
|
|
45
56
|
export interface UnattendedSyncOutcome {
|
|
46
57
|
status: UnattendedSyncStatus;
|
|
@@ -58,6 +69,12 @@ export interface UnattendedSyncOptions {
|
|
|
58
69
|
homeDir?: string;
|
|
59
70
|
/** Environment Git runs with. */
|
|
60
71
|
env?: Record<string, string | undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* `wait` (the default) queues behind another holder of the companion lock,
|
|
74
|
+
* within the time budget; `try` skips at once, for a hook that must not
|
|
75
|
+
* stall a session start.
|
|
76
|
+
*/
|
|
77
|
+
lock?: "wait" | "try";
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
const UNFINISHED_GIT_PATHS = [
|
|
@@ -115,6 +132,24 @@ export async function syncCompanionUnattended(
|
|
|
115
132
|
return { status: "fresh", changed: false, companionPath };
|
|
116
133
|
}
|
|
117
134
|
|
|
135
|
+
const locked = await withCompanionLock(
|
|
136
|
+
companionPath,
|
|
137
|
+
{ homeDir, wait: options.lock !== "try", timeoutMs },
|
|
138
|
+
() => syncHoldingLock(companionPath, { timeoutMs, now, homeDir, env }),
|
|
139
|
+
);
|
|
140
|
+
return locked.acquired ? locked.value : { status: "busy", changed: false, companionPath };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function syncHoldingLock(
|
|
144
|
+
companionPath: string,
|
|
145
|
+
options: {
|
|
146
|
+
timeoutMs: number;
|
|
147
|
+
now: Date;
|
|
148
|
+
homeDir?: string;
|
|
149
|
+
env?: Record<string, string | undefined>;
|
|
150
|
+
},
|
|
151
|
+
): Promise<UnattendedSyncOutcome> {
|
|
152
|
+
const { timeoutMs, now, homeDir, env } = options;
|
|
118
153
|
const git = companionGit(companionPath, {
|
|
119
154
|
prompt: "never",
|
|
120
155
|
budgetMs: timeoutMs,
|
|
@@ -191,13 +226,36 @@ export async function syncCompanionUnattended(
|
|
|
191
226
|
* answer a credential prompt and no way to tell a fast-forward from a
|
|
192
227
|
* conflicted rebase.
|
|
193
228
|
*/
|
|
194
|
-
export function unattendedSyncStalenessLines(
|
|
229
|
+
export function unattendedSyncStalenessLines(
|
|
230
|
+
outcome: UnattendedSyncOutcome,
|
|
231
|
+
audience: StalenessAudience = "claude",
|
|
232
|
+
): string[] {
|
|
195
233
|
if (outcome.status !== "unfinished") return [];
|
|
234
|
+
const reason = outcome.reason ?? "unknown reason";
|
|
235
|
+
if (audience === "opencode") return launchRecoveryStalenessLines(outcome, ["opencode"]);
|
|
196
236
|
return [
|
|
197
|
-
`companion Git synchronization unfinished: ${
|
|
237
|
+
`companion Git synchronization unfinished: ${reason} — run \`${COMPANION_SYNC_COMMAND}\``,
|
|
198
238
|
];
|
|
199
239
|
}
|
|
200
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The note for a session prepare, naming the managed launch of each wrapped
|
|
243
|
+
* runtime as the recovery: it runs the sync at a terminal that can answer Git.
|
|
244
|
+
*/
|
|
245
|
+
export function launchRecoveryStalenessLines(
|
|
246
|
+
outcome: UnattendedSyncOutcome,
|
|
247
|
+
runtimes: readonly StalenessAudience[],
|
|
248
|
+
): string[] {
|
|
249
|
+
if (outcome.status !== "unfinished") return [];
|
|
250
|
+
const reason = outcome.reason ?? "unknown reason";
|
|
251
|
+
const wrapped = new Set(runtimes);
|
|
252
|
+
const launches = [
|
|
253
|
+
...(wrapped.has("claude") ? [CLAUDE_LAUNCH_COMMAND] : []),
|
|
254
|
+
...(wrapped.has("opencode") || wrapped.size === 0 ? [OPENCODE_LAUNCH_COMMAND] : []),
|
|
255
|
+
].map((command) => `\`${command}\``);
|
|
256
|
+
return [`Companion not updated: ${reason}. Restart with ${launches.join(" or ")} to fix it.`];
|
|
257
|
+
}
|
|
258
|
+
|
|
201
259
|
/**
|
|
202
260
|
* The note a session-state reader surfaces, taken from the persisted record so
|
|
203
261
|
* a render costs no Git. Operator-facing only, exactly as the live note is.
|
|
@@ -205,16 +263,15 @@ export function unattendedSyncStalenessLines(outcome: UnattendedSyncOutcome): st
|
|
|
205
263
|
export function persistedCompanionGitStalenessLines(
|
|
206
264
|
companionPath: string,
|
|
207
265
|
homeDir?: string,
|
|
266
|
+
audience: StalenessAudience = "claude",
|
|
208
267
|
): string[] {
|
|
209
268
|
if (!companionPath) return [];
|
|
210
269
|
const reason = readCompanionGitRecord(companionPath, homeDir).unfinishedReason;
|
|
211
270
|
if (!reason) return [];
|
|
212
|
-
return unattendedSyncStalenessLines(
|
|
213
|
-
status: "unfinished",
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
reason,
|
|
217
|
-
});
|
|
271
|
+
return unattendedSyncStalenessLines(
|
|
272
|
+
{ status: "unfinished", changed: false, companionPath, reason },
|
|
273
|
+
audience,
|
|
274
|
+
);
|
|
218
275
|
}
|
|
219
276
|
|
|
220
277
|
export interface ForkGuardOptions {
|