@uniqbit/mate-core 0.18.3 → 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/opencode-plugin/src/add-dir.ts +10 -76
- package/opencode-plugin/src/audience-layer.ts +91 -0
- package/opencode-plugin/src/companion.ts +28 -11
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +25 -8
- package/package.json +1 -1
- package/src/cli/commands/cap/index-cmd.ts +2 -11
- package/src/cli/commands/doctor.ts +86 -4
- package/src/cli/commands/install.ts +8 -0
- package/src/cli/commands/studio/terminal-launch.ts +16 -7
- package/src/cli/commands/wrap.ts +29 -4
- package/src/cli/usage.ts +2 -1
- package/src/hooks/session-guidance.ts +20 -2
- package/src/lib/orchestrator/adapters/base.ts +3 -2
- package/src/lib/orchestrator/adapters/claude.ts +130 -20
- package/src/lib/orchestrator/adapters/opencode.ts +55 -15
- package/src/lib/orchestrator/config-store.ts +74 -1
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +88 -35
- package/src/lib/orchestrator/projection-entries.ts +46 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
- package/src/lib/orchestrator/projection-types.ts +9 -1
- package/src/lib/orchestrator/session-prepare.ts +82 -4
- package/src/lib/orchestrator/working-repo-projection.ts +2 -1
- package/src/lib/orchestrator/wrap-pass.ts +92 -1
- package/src/opencode/companion-hooks.ts +7 -4
- package/src/opencode/projected-guidance.ts +6 -13
- package/src/runtime/audience-layer.ts +84 -0
- 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-document-manifest.ts +17 -0
- package/src/runtime/wrap-state.ts +4 -0
- 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/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 +7 -4
- package/src/tools/setup/engine.ts +141 -10
- package/src/tools/setup/isolation-diagnostics.ts +139 -0
- package/src/tools/setup/plugin.ts +19 -4
- package/src/tools/setup/plugins/gitignore.ts +7 -1
- package/src/tools/setup/providers/claude.ts +27 -2
- package/src/tools/setup/providers/opencode.ts +10 -0
- package/src/tools/setup/registry.ts +24 -2
- 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 +9 -1
- package/src/tools/setup.ts +9 -6
- package/wrappers/bin/openspec +42 -1
|
@@ -7,8 +7,14 @@ import {
|
|
|
7
7
|
syncCompanionUnattended,
|
|
8
8
|
type UnattendedSyncOutcome,
|
|
9
9
|
} from "../../runtime/companion-sync";
|
|
10
|
-
import type
|
|
10
|
+
import { readWrapStateSync, type WrapState } from "../../runtime/wrap-state";
|
|
11
11
|
import { syncCompanionFiles } from "../../tools/setup";
|
|
12
|
+
import { renderAudienceLayer, type RenderedAudienceLayer } from "../../tools/setup/audience-layer";
|
|
13
|
+
import {
|
|
14
|
+
readAudienceSelection,
|
|
15
|
+
resolveAudience,
|
|
16
|
+
} from "../../tools/setup/dynamic-plugins/audiences";
|
|
17
|
+
import { buildSetupInstallationPlan } from "../../tools/setup/engine";
|
|
12
18
|
import type { CapabilityPlugin, LaunchPreflightContext } from "../../tools/setup/plugin";
|
|
13
19
|
import { attendedLockOptions, syncCompanionGit } from "./companion-git-sync";
|
|
14
20
|
import { readCompanionConfig } from "./config-store";
|
|
@@ -34,7 +40,8 @@ export type PrepareStepName =
|
|
|
34
40
|
| "file-sync"
|
|
35
41
|
| "launch-projection"
|
|
36
42
|
| "working-target"
|
|
37
|
-
| "preflight"
|
|
43
|
+
| "preflight"
|
|
44
|
+
| "audience-layer";
|
|
38
45
|
|
|
39
46
|
export interface PrepareStep {
|
|
40
47
|
step: PrepareStepName;
|
|
@@ -61,6 +68,8 @@ export interface PrepareResult {
|
|
|
61
68
|
failure?: unknown;
|
|
62
69
|
/** The wrap state the `working-target` step's pass left; a launch reads no manifest itself. */
|
|
63
70
|
wrapState?: WrapState;
|
|
71
|
+
/** The active audience's layer, rendered for an attended prepare only. */
|
|
72
|
+
audienceLayer?: RenderedAudienceLayer;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
export interface PrepareSessionInput {
|
|
@@ -89,9 +98,10 @@ const SYNC_LOCK_TIMEOUT_MS = 5 * 60_000;
|
|
|
89
98
|
*/
|
|
90
99
|
async function runCapabilityPreflight(context: LaunchPreflightContext): Promise<void> {
|
|
91
100
|
const { config, providerId } = context;
|
|
101
|
+
/** Base only: an audience plugin's `forProvider` hooks never run. */
|
|
92
102
|
const preflights = getActiveDistribution()
|
|
93
|
-
.registry.
|
|
94
|
-
.flatMap((plugin) => {
|
|
103
|
+
.registry.baseRegistrations()
|
|
104
|
+
.flatMap(({ plugin }) => {
|
|
95
105
|
if (plugin.kind !== "capability" || !plugin.isEnabled(config)) return [];
|
|
96
106
|
const capability = plugin as CapabilityPlugin;
|
|
97
107
|
const preflight = capability.forProvider?.[providerId]?.preflight;
|
|
@@ -137,6 +147,11 @@ export const sessionPrepareDeps = {
|
|
|
137
147
|
withCompanionLock,
|
|
138
148
|
/** As the launch decides it: Git may prompt only with a terminal on both ends. */
|
|
139
149
|
isInteractiveTerminal: () => !!(process.stdin.isTTY && process.stdout.isTTY),
|
|
150
|
+
/** The launch's `MATE_AUDIENCE`; read by an attended prepare only. */
|
|
151
|
+
audienceSelection: () => readAudienceSelection(process.env),
|
|
152
|
+
/** The audience a checkout is wrapped for, from its manifest; `null` for base. */
|
|
153
|
+
wrappedAudience: (repoPath: string) => readWrapStateSync(repoPath).audience,
|
|
154
|
+
renderAudienceLayer,
|
|
140
155
|
};
|
|
141
156
|
|
|
142
157
|
function message(error: unknown): string {
|
|
@@ -149,6 +164,7 @@ class StepLog {
|
|
|
149
164
|
readonly warnings: string[] = [];
|
|
150
165
|
failure: unknown;
|
|
151
166
|
wrapState: WrapState | undefined;
|
|
167
|
+
audienceLayer: RenderedAudienceLayer | undefined;
|
|
152
168
|
stopped = false;
|
|
153
169
|
companionChanged = false;
|
|
154
170
|
|
|
@@ -321,12 +337,71 @@ async function runLocal(
|
|
|
321
337
|
});
|
|
322
338
|
}
|
|
323
339
|
|
|
340
|
+
/**
|
|
341
|
+
* The active audience's launch layer, after the base prepare. Only a managed
|
|
342
|
+
* launch reaches here: a bare session's prepare never renders one, whatever
|
|
343
|
+
* `MATE_AUDIENCE` its environment carries. An audience the companion does not
|
|
344
|
+
* declare renders nothing; hydration already warned about it.
|
|
345
|
+
*/
|
|
346
|
+
async function renderLayer(
|
|
347
|
+
input: PrepareSessionInput,
|
|
348
|
+
config: FrameworkConfig,
|
|
349
|
+
run: StepLog,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
const deps = sessionPrepareDeps;
|
|
352
|
+
/** Adopted from the wrap: the wrap delivers it, so the launch renders no layer of its own. */
|
|
353
|
+
const wrapped = input.repository ? deps.wrappedAudience(input.repository.path) : null;
|
|
354
|
+
if (wrapped) {
|
|
355
|
+
run.push({ step: "audience-layer", outcome: "ok", detail: `${wrapped}: from the wrap` });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const audience = resolveAudience(config, deps.audienceSelection());
|
|
359
|
+
if (!audience.ok || audience.active === null) return;
|
|
360
|
+
try {
|
|
361
|
+
const registry = getActiveDistribution().registry;
|
|
362
|
+
const layer = await deps.renderAudienceLayer({
|
|
363
|
+
companionPath: input.companionPath,
|
|
364
|
+
config,
|
|
365
|
+
audience: audience.active,
|
|
366
|
+
activeProviders: buildSetupInstallationPlan(config, registry.baseRegistrations())
|
|
367
|
+
.activeProviders,
|
|
368
|
+
registrations: registry.audienceRegistrations(audience.active),
|
|
369
|
+
});
|
|
370
|
+
run.audienceLayer = layer;
|
|
371
|
+
run.push({
|
|
372
|
+
step: "audience-layer",
|
|
373
|
+
outcome: "ok",
|
|
374
|
+
detail: `${layer.audience}: ${layer.rendered ? "rendered" : "current"}`,
|
|
375
|
+
});
|
|
376
|
+
} catch (error) {
|
|
377
|
+
run.fail("audience-layer", error);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A checkout wrapped for one audience refuses a launch for another before
|
|
383
|
+
* anything runs: the wrap's on-disk content would reach that session.
|
|
384
|
+
*/
|
|
385
|
+
function audienceConflict(input: PrepareSessionInput, config: FrameworkConfig): string | null {
|
|
386
|
+
if (!input.repository) return null;
|
|
387
|
+
const wrapped = sessionPrepareDeps.wrappedAudience(input.repository.path);
|
|
388
|
+
if (!wrapped) return null;
|
|
389
|
+
const selected = resolveAudience(config, sessionPrepareDeps.audienceSelection());
|
|
390
|
+
if (!selected.ok || selected.active === null || selected.active === wrapped) return null;
|
|
391
|
+
return `${FRAMEWORK_NAME}: ${input.repository.id} is wrapped for audience "${wrapped}", but MATE_AUDIENCE selects "${selected.active}". Run \`${FRAMEWORK_NAME} wrap --audience ${selected.active}\` in ${input.repository.path}, or launch from another worktree wrapped for "${selected.active}".`;
|
|
392
|
+
}
|
|
393
|
+
|
|
324
394
|
async function runAttended(
|
|
325
395
|
input: PrepareSessionInput,
|
|
326
396
|
config: FrameworkConfig,
|
|
327
397
|
run: StepLog,
|
|
328
398
|
): Promise<void> {
|
|
329
399
|
const deps = sessionPrepareDeps;
|
|
400
|
+
const conflict = audienceConflict(input, config);
|
|
401
|
+
if (conflict) {
|
|
402
|
+
run.fail("audience-layer", new LaunchPreflightError(conflict));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
330
405
|
if (input.skipGit) {
|
|
331
406
|
run.push({ step: "git-sync", outcome: "skipped", detail: "--no-git" });
|
|
332
407
|
} else if (config.git !== "auto") {
|
|
@@ -347,6 +422,8 @@ async function runAttended(
|
|
|
347
422
|
}
|
|
348
423
|
}
|
|
349
424
|
await runLocal(input, config, run);
|
|
425
|
+
if (run.stopped) return;
|
|
426
|
+
await renderLayer(input, config, run);
|
|
350
427
|
}
|
|
351
428
|
|
|
352
429
|
/** The sync's own config read: only a fast-forward can have changed it. */
|
|
@@ -401,6 +478,7 @@ export async function prepareSession(input: PrepareSessionInput): Promise<Prepar
|
|
|
401
478
|
...(run.companionChanged ? { companionChanged: true as const } : {}),
|
|
402
479
|
...(run.failure === undefined ? {} : { failure: run.failure }),
|
|
403
480
|
...(run.wrapState ? { wrapState: run.wrapState } : {}),
|
|
481
|
+
...(run.audienceLayer ? { audienceLayer: run.audienceLayer } : {}),
|
|
404
482
|
});
|
|
405
483
|
|
|
406
484
|
const locked = await deps.withCompanionLock(input.companionPath, lockOptions(input), async () => {
|
|
@@ -120,7 +120,8 @@ export async function unproject(input: ProjectionRemovalInput): Promise<Projecti
|
|
|
120
120
|
}
|
|
121
121
|
try {
|
|
122
122
|
// oxlint-disable-next-line no-await-in-loop -- reverse catalogue order is the contract
|
|
123
|
-
|
|
123
|
+
const removed = outcome(entry, await removal.remove(input));
|
|
124
|
+
outcomes.push(removal.retains ? { ...removed, retains: removal.retains } : removed);
|
|
124
125
|
} catch (error) {
|
|
125
126
|
outcomes.push(outcome(entry, "failed", toError(error)));
|
|
126
127
|
}
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
3
|
import { renderWorkingRuntimeDocuments } from "../../tools/setup";
|
|
4
|
+
import { renderAudienceLayer } from "../../tools/setup/audience-layer";
|
|
5
|
+
import { loadAudienceRegistrations } from "../../tools/setup/audience-registrations";
|
|
6
|
+
import { declaredAudiences } from "../../tools/setup/dynamic-plugins/audiences";
|
|
7
|
+
import { buildSetupInstallationPlan } from "../../tools/setup/engine";
|
|
8
|
+
import { getActiveDistribution } from "../../distribution";
|
|
9
|
+
import { audienceLayerDir } from "../../runtime/audience-layer";
|
|
10
|
+
import {
|
|
11
|
+
readManifest,
|
|
12
|
+
recordedAudience,
|
|
13
|
+
updateManifest,
|
|
14
|
+
withAudience,
|
|
15
|
+
} from "../../runtime/runtime-document-manifest";
|
|
4
16
|
import { readCompanionConfig } from "./config-store";
|
|
5
17
|
import { GlobalConfigStore } from "./global-config-store";
|
|
6
18
|
import { entryFailureMessage, firstFailure, type ProjectionChangedPaths } from "./projection-types";
|
|
7
19
|
import type { WrapRuntime } from "../../runtime/runtime-document-manifest";
|
|
8
20
|
import { readWrapState, type WrapState } from "../../runtime/wrap-state";
|
|
21
|
+
import type { NormalizedRegistration } from "../../tools/setup/registry";
|
|
9
22
|
import type { FrameworkConfig, LinkedRepository } from "./types";
|
|
10
23
|
import { project } from "./working-repo-projection";
|
|
11
24
|
|
|
@@ -13,6 +26,8 @@ export interface WrapPassDependencies {
|
|
|
13
26
|
renderWorkingRuntimeDocuments: typeof renderWorkingRuntimeDocuments;
|
|
14
27
|
readCompanionConfig: typeof readCompanionConfig;
|
|
15
28
|
project: typeof project;
|
|
29
|
+
loadAudienceRegistrations: typeof loadAudienceRegistrations;
|
|
30
|
+
renderAudienceLayer: typeof renderAudienceLayer;
|
|
16
31
|
/** The registered companions, whose stale skill links a re-pin withdraws; the user's registry when absent. */
|
|
17
32
|
globalConfigStore?: GlobalConfigStore;
|
|
18
33
|
}
|
|
@@ -21,6 +36,8 @@ export const wrapPassDeps: WrapPassDependencies = {
|
|
|
21
36
|
renderWorkingRuntimeDocuments,
|
|
22
37
|
readCompanionConfig,
|
|
23
38
|
project,
|
|
39
|
+
loadAudienceRegistrations,
|
|
40
|
+
renderAudienceLayer,
|
|
24
41
|
};
|
|
25
42
|
|
|
26
43
|
export interface WrapPassOptions {
|
|
@@ -28,6 +45,12 @@ export interface WrapPassOptions {
|
|
|
28
45
|
runtimes?: readonly WrapRuntime[];
|
|
29
46
|
/** The companion config the caller already read; read from files otherwise. */
|
|
30
47
|
config?: FrameworkConfig;
|
|
48
|
+
/**
|
|
49
|
+
* The audience to wrap the checkout for: a name records it, `null` withdraws
|
|
50
|
+
* it, absent keeps the one the manifest records. Never read from the
|
|
51
|
+
* environment: it is the only source of a bare session's audience.
|
|
52
|
+
*/
|
|
53
|
+
audience?: string | null;
|
|
31
54
|
}
|
|
32
55
|
|
|
33
56
|
export type WrapPassResult =
|
|
@@ -47,9 +70,21 @@ export type WrapPassResult =
|
|
|
47
70
|
runtimes: ReadonlySet<WrapRuntime>;
|
|
48
71
|
/** The wrap state as this pass left it. */
|
|
49
72
|
wrapState: WrapState;
|
|
73
|
+
/** The audience the checkout is wrapped for after this pass. */
|
|
74
|
+
audience: string | null;
|
|
50
75
|
}
|
|
51
76
|
| { kind: "failed"; message: string };
|
|
52
77
|
|
|
78
|
+
/** An audience a checkout may be wrapped for: declared by the companion, never the reserved base. */
|
|
79
|
+
function wrappableAudience(config: FrameworkConfig, audience: string): boolean {
|
|
80
|
+
return audience !== "base" && declaredAudiences(config).includes(audience);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function describeUnwrappable(config: FrameworkConfig, audience: string): string {
|
|
84
|
+
const declared = declaredAudiences(config).filter((name) => name !== "base");
|
|
85
|
+
return `audience "${audience}" cannot be wrapped: the companion declares ${declared.length > 0 ? declared.join(", ") : "no audiences"}.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
/**
|
|
54
89
|
* One pass of the `wrap` scope, restricted to the requested runtimes by each
|
|
55
90
|
* entry's runtime tag. Best-effort per entry like every projection: a failed
|
|
@@ -63,10 +98,50 @@ export async function runWrapPass(
|
|
|
63
98
|
): Promise<WrapPassResult> {
|
|
64
99
|
const resolvedCompanionPath = path.resolve(companionPath);
|
|
65
100
|
const config = options.config ?? (await deps.readCompanionConfig(resolvedCompanionPath));
|
|
101
|
+
|
|
102
|
+
const audienceWarnings: string[] = [];
|
|
103
|
+
let audience: string | null;
|
|
104
|
+
if (options.audience === undefined) {
|
|
105
|
+
const recorded = recordedAudience(await readManifest(repository.path));
|
|
106
|
+
/** A recorded audience the companion no longer declares falls back to base, never fails a prepare. */
|
|
107
|
+
if (recorded && !wrappableAudience(config, recorded)) {
|
|
108
|
+
audienceWarnings.push(
|
|
109
|
+
`${describeUnwrappable(config, recorded)} ${repository.id} is wrapped for base only.`,
|
|
110
|
+
);
|
|
111
|
+
audience = null;
|
|
112
|
+
} else {
|
|
113
|
+
audience = recorded;
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
audience = options.audience;
|
|
117
|
+
if (audience && !wrappableAudience(config, audience)) {
|
|
118
|
+
return { kind: "failed", message: describeUnwrappable(config, audience) };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let wrapped: { audience: string; registrations: NormalizedRegistration[] } | undefined;
|
|
123
|
+
if (audience) {
|
|
124
|
+
const loaded = await deps.loadAudienceRegistrations(resolvedCompanionPath, config, audience);
|
|
125
|
+
audienceWarnings.push(...loaded.warnings);
|
|
126
|
+
await deps.renderAudienceLayer({
|
|
127
|
+
companionPath: resolvedCompanionPath,
|
|
128
|
+
config,
|
|
129
|
+
audience,
|
|
130
|
+
activeProviders: buildSetupInstallationPlan(
|
|
131
|
+
config,
|
|
132
|
+
getActiveDistribution().registry.baseRegistrations(),
|
|
133
|
+
).activeProviders,
|
|
134
|
+
registrations: loaded.registrations,
|
|
135
|
+
});
|
|
136
|
+
wrapped = { audience, registrations: loaded.registrations };
|
|
137
|
+
}
|
|
138
|
+
|
|
66
139
|
const rendered = await deps.renderWorkingRuntimeDocuments(
|
|
67
140
|
resolvedCompanionPath,
|
|
68
141
|
config,
|
|
69
142
|
repository.path,
|
|
143
|
+
undefined,
|
|
144
|
+
wrapped,
|
|
70
145
|
);
|
|
71
146
|
const result = await deps.project("wrap", {
|
|
72
147
|
repoPath: repository.path,
|
|
@@ -74,6 +149,7 @@ export async function runWrapPass(
|
|
|
74
149
|
repository,
|
|
75
150
|
config,
|
|
76
151
|
runtimeDocuments: rendered.documents,
|
|
152
|
+
audienceLayerDir: audience ? audienceLayerDir(resolvedCompanionPath, audience) : null,
|
|
77
153
|
globalConfigStore: deps.globalConfigStore ?? new GlobalConfigStore(),
|
|
78
154
|
...(options.runtimes ? { runtimes: options.runtimes } : {}),
|
|
79
155
|
});
|
|
@@ -81,6 +157,12 @@ export async function runWrapPass(
|
|
|
81
157
|
const failure = firstFailure(result.outcomes);
|
|
82
158
|
if (failure) return { kind: "failed", message: entryFailureMessage(failure, repository.id) };
|
|
83
159
|
|
|
160
|
+
const before = recordedAudience(await readManifest(repository.path));
|
|
161
|
+
const audienceChanged = before !== audience;
|
|
162
|
+
if (audienceChanged) {
|
|
163
|
+
await updateManifest(repository.path, (manifest) => withAudience(manifest, audience));
|
|
164
|
+
}
|
|
165
|
+
|
|
84
166
|
/** Read once under the caller's lock, so it is exactly what the pass recorded. */
|
|
85
167
|
let wrapState: WrapState;
|
|
86
168
|
try {
|
|
@@ -112,7 +194,10 @@ export async function runWrapPass(
|
|
|
112
194
|
.map((outcome) => outcome.path);
|
|
113
195
|
|
|
114
196
|
return {
|
|
115
|
-
kind:
|
|
197
|
+
kind:
|
|
198
|
+
audienceChanged || result.outcomes.some((outcome) => outcome.state === "written")
|
|
199
|
+
? "written"
|
|
200
|
+
: "current",
|
|
116
201
|
projectionRoot: result.root,
|
|
117
202
|
companionPath: resolvedCompanionPath,
|
|
118
203
|
documents: [...new Set(documents)],
|
|
@@ -122,10 +207,16 @@ export async function runWrapPass(
|
|
|
122
207
|
removed: result.outcomes.flatMap((outcome) => outcome.changed?.removed ?? []),
|
|
123
208
|
},
|
|
124
209
|
warnings: [
|
|
210
|
+
...audienceWarnings,
|
|
125
211
|
...rendered.warnings,
|
|
126
212
|
...result.outcomes.flatMap((outcome) => outcome.warnings ?? []),
|
|
213
|
+
/** A degradable failure does not fail the pass, but it is never silent. */
|
|
214
|
+
...result.outcomes
|
|
215
|
+
.filter((outcome) => outcome.state === "failed" && outcome.degradable)
|
|
216
|
+
.map((outcome) => entryFailureMessage(outcome, repository.id)),
|
|
127
217
|
],
|
|
128
218
|
runtimes,
|
|
129
219
|
wrapState,
|
|
220
|
+
audience,
|
|
130
221
|
};
|
|
131
222
|
}
|
|
@@ -99,6 +99,7 @@ export async function guardToolInput(
|
|
|
99
99
|
context: CompanionContext,
|
|
100
100
|
tool: string,
|
|
101
101
|
input: unknown,
|
|
102
|
+
env: Record<string, string | undefined> = process.env,
|
|
102
103
|
): Promise<void> {
|
|
103
104
|
const args = (input ?? {}) as Record<string, unknown>;
|
|
104
105
|
const filePaths =
|
|
@@ -113,25 +114,27 @@ export async function guardToolInput(
|
|
|
113
114
|
throw new Error(buildArtifactError(context, filePath));
|
|
114
115
|
}
|
|
115
116
|
// oxlint-disable-next-line no-await-in-loop -- the first refusal wins
|
|
116
|
-
await refuseForkedCompanionWrite(context, filePath);
|
|
117
|
+
await refuseForkedCompanionWrite(context, filePath, env);
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
122
|
* Registers the companion guardrails on an OpenCode V2 plugin context.
|
|
122
|
-
* The caller resolves `context` and skips this when no companion resolved
|
|
123
|
+
* The caller resolves `context` and skips this when no companion resolved;
|
|
124
|
+
* `env` is the launch environment the caller decided to trust.
|
|
123
125
|
*/
|
|
124
126
|
export async function registerCompanionHooks(
|
|
125
127
|
api: Context,
|
|
126
128
|
context: CompanionContext,
|
|
129
|
+
env: Record<string, string | undefined> = process.env,
|
|
127
130
|
): Promise<void> {
|
|
128
131
|
/**
|
|
129
132
|
* Anything escaping the repair would cost the session every guardrail
|
|
130
133
|
* below to save a synchronization that is optional by design.
|
|
131
134
|
*/
|
|
132
|
-
await repairCompanionGitOnce(context,
|
|
135
|
+
await repairCompanionGitOnce(context, env, api.location.directory).catch(() => []);
|
|
133
136
|
|
|
134
137
|
await api.tool.hook("execute.before", (event) =>
|
|
135
|
-
guardToolInput(context, event.tool, event.input),
|
|
138
|
+
guardToolInput(context, event.tool, event.input, env),
|
|
136
139
|
);
|
|
137
140
|
}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { buildOpenCodeGuidance } from "../runtime/companion-guidance";
|
|
2
2
|
import { MATE_ENV } from "../runtime/env-names";
|
|
3
|
-
import { hasLaunchEnvironment } from "../runtime/env";
|
|
4
3
|
import { parseGuidanceContent, type MateGuidanceFile } from "../runtime/guidance";
|
|
5
4
|
import { readCompanionPolicy } from "../runtime/policy";
|
|
6
5
|
import { resolveProjection } from "../runtime/projection";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* `null` guidance with no errors is the inert case — no companion resolves, so
|
|
10
|
-
* the plugin contributes nothing. Errors are startup failures: a
|
|
11
|
-
*
|
|
9
|
+
* the plugin contributes nothing. Errors are startup failures: a payload that
|
|
10
|
+
* does not parse, or projected guidance that cannot be built.
|
|
12
11
|
*/
|
|
13
12
|
export interface OpenCodeGuidanceResolution {
|
|
14
13
|
guidance: MateGuidanceFile | null;
|
|
@@ -26,9 +25,10 @@ export interface OpenCodeGuidanceResolution {
|
|
|
26
25
|
* means a Capability toggled after a wrap changes the guidance on the next
|
|
27
26
|
* session with no re-wrap.
|
|
28
27
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* Without a payload the projection decides, whatever other `MATE_*` variables
|
|
29
|
+
* `env` carries: those are inherited by every descendant of a Mate session and
|
|
30
|
+
* do not mark a managed launch. A present but malformed payload stays a hard
|
|
31
|
+
* error.
|
|
32
32
|
*/
|
|
33
33
|
export function resolveOpenCodeGuidance(
|
|
34
34
|
env: Record<string, string | undefined> = process.env,
|
|
@@ -40,13 +40,6 @@ export function resolveOpenCodeGuidance(
|
|
|
40
40
|
return { guidance: errors.length > 0 ? null : guidance, errors };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
if (hasLaunchEnvironment(env)) {
|
|
44
|
-
return {
|
|
45
|
-
guidance: null,
|
|
46
|
-
errors: [`missing ${MATE_ENV.guidanceJson} in the launch environment`],
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
43
|
const projection = resolveProjection(cwd);
|
|
51
44
|
if (!projection) return { guidance: null, errors: [] };
|
|
52
45
|
|
|
@@ -0,0 +1,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
|
+
}
|
package/src/runtime/guidance.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Versioned companion guidance contract. The CLI builds the guidance data
|
|
3
5
|
* from its playbooks at launch time and delivers it through the
|
|
@@ -16,6 +18,12 @@ export type MateGuidanceFile = {
|
|
|
16
18
|
companionGuidance: string;
|
|
17
19
|
codebaseExplorationGuidance: string;
|
|
18
20
|
errors: string[];
|
|
21
|
+
/**
|
|
22
|
+
* The active audience's layer, for a launch that hosts its own server only.
|
|
23
|
+
* The plugin applies it only from a payload it trusts; a masked payload
|
|
24
|
+
* carries none, so the shared service and bare sessions never see one.
|
|
25
|
+
*/
|
|
26
|
+
audienceLayer?: string;
|
|
19
27
|
};
|
|
20
28
|
|
|
21
29
|
export type GuidanceValidation = {
|
|
@@ -76,12 +84,21 @@ export function validateGuidanceData(value: unknown): GuidanceValidation {
|
|
|
76
84
|
errors.push("injected codebase exploration guidance is malformed");
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
const audienceLayer =
|
|
88
|
+
typeof value.audienceLayer === "string" && path.isAbsolute(value.audienceLayer)
|
|
89
|
+
? value.audienceLayer
|
|
90
|
+
: undefined;
|
|
91
|
+
if (value.audienceLayer !== undefined && audienceLayer === undefined) {
|
|
92
|
+
errors.push("injected audience layer is not an absolute path");
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
return {
|
|
80
96
|
guidance: {
|
|
81
97
|
version: GUIDANCE_FILE_VERSION,
|
|
82
98
|
companionGuidance,
|
|
83
99
|
codebaseExplorationGuidance,
|
|
84
100
|
errors,
|
|
101
|
+
...(audienceLayer ? { audienceLayer } : {}),
|
|
85
102
|
},
|
|
86
103
|
errors,
|
|
87
104
|
};
|