@uniqbit/mate-core 0.18.3 → 0.18.5
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/companion.ts +20 -10
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +11 -7
- package/package.json +1 -1
- package/src/cli/commands/artifact/finish/openspec.ts +40 -8
- package/src/cli/commands/cap/index-cmd.ts +2 -11
- package/src/cli/commands/doctor.ts +88 -0
- package/src/lib/orchestrator/adapters/base.ts +3 -2
- package/src/lib/orchestrator/adapters/opencode.ts +33 -10
- package/src/lib/orchestrator/config-store.ts +6 -1
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-entries.ts +45 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
- package/src/lib/orchestrator/projection-types.ts +7 -1
- package/src/lib/orchestrator/working-repo-projection.ts +2 -1
- package/src/lib/orchestrator/wrap-pass.ts +4 -0
- package/src/opencode/companion-hooks.ts +7 -4
- package/src/opencode/projected-guidance.ts +6 -13
- package/src/runtime/projection.ts +1 -1
- package/src/tools/setup/capabilities/openspec.ts +149 -18
- package/src/tools/setup/capabilities/tokensave.ts +11 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +59 -11
- package/src/tools/setup/dynamic-plugins/declarations.ts +2 -1
- package/src/tools/setup/working-repo-cleanup.ts +2 -0
- package/src/tools/setup/working-repo-local-state.ts +9 -1
- package/wrappers/bin/openspec +42 -1
|
@@ -6,6 +6,7 @@ import { MATE_ENV } from "../../src/runtime/env";
|
|
|
6
6
|
import { COMPANION_POLICY_MARKER, type MateGuidanceFile } from "../../src/runtime/guidance";
|
|
7
7
|
|
|
8
8
|
import type { CompanionContext } from "../../src/opencode/companion-policy";
|
|
9
|
+
import type { LaunchEnvironment } from "./launch-environment";
|
|
9
10
|
import { resolveOpenCodeGuidance } from "../../src/opencode/projected-guidance";
|
|
10
11
|
import {
|
|
11
12
|
runtimeDirectoryPath,
|
|
@@ -23,8 +24,8 @@ function prependPathEntry(pathValue: string | undefined, entry: string): string
|
|
|
23
24
|
* keeps current. `null` when neither exists: guidance naming an unresolved
|
|
24
25
|
* placeholder would send the model to a path that is not there.
|
|
25
26
|
*/
|
|
26
|
-
function resolveWrapperBinPath(): string | null {
|
|
27
|
-
if (
|
|
27
|
+
function resolveWrapperBinPath(env: LaunchEnvironment): string | null {
|
|
28
|
+
if (env.MATE_WRAPPER_BIN_PATH) return env.MATE_WRAPPER_BIN_PATH;
|
|
28
29
|
const runtime = runtimeWrapperBinPath();
|
|
29
30
|
return fs.existsSync(runtime) ? runtime : null;
|
|
30
31
|
}
|
|
@@ -54,8 +55,8 @@ function buildStartupError(details: string[]): Error {
|
|
|
54
55
|
* an Unmanaged Session in a wrapped repository builds the same payload from the
|
|
55
56
|
* companion the projection names, with the Capability names read live.
|
|
56
57
|
*/
|
|
57
|
-
function loadGuidance(directory: string): MateGuidanceFile | null {
|
|
58
|
-
const { guidance, errors } = resolveOpenCodeGuidance(
|
|
58
|
+
function loadGuidance(env: LaunchEnvironment, directory: string): MateGuidanceFile | null {
|
|
59
|
+
const { guidance, errors } = resolveOpenCodeGuidance(env, directory);
|
|
59
60
|
if (errors.length > 0) throw buildStartupError(errors);
|
|
60
61
|
return guidance;
|
|
61
62
|
}
|
|
@@ -101,9 +102,10 @@ function applyCompanionShellEnv(
|
|
|
101
102
|
context: CompanionContext,
|
|
102
103
|
env: Record<string, string | undefined>,
|
|
103
104
|
wrapperBinPath: string,
|
|
105
|
+
launchEnv: LaunchEnvironment,
|
|
104
106
|
): void {
|
|
105
107
|
env.MATE_NAME = context.frameworkName;
|
|
106
|
-
env.MATE_VERSION =
|
|
108
|
+
env.MATE_VERSION = launchEnv.MATE_VERSION ?? "unknown";
|
|
107
109
|
env.MATE_ARTIFACT_PATH = context.companionPath;
|
|
108
110
|
env.MATE_WRAPPER_BIN_PATH = wrapperBinPath;
|
|
109
111
|
if (context.repositoryPath) {
|
|
@@ -171,12 +173,20 @@ function mergeCompanionSystem(system: unknown[], companion: string): string[] {
|
|
|
171
173
|
return parts.length > 0 ? [parts.join("\n\n")] : [];
|
|
172
174
|
}
|
|
173
175
|
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Registers guidance, compaction context, shell env, and `companion_paths`.
|
|
178
|
+
* `env` is the launch environment the caller decided to trust; `{}` resolves
|
|
179
|
+
* everything from the Projection Root.
|
|
180
|
+
*/
|
|
181
|
+
export async function registerCompanion(
|
|
182
|
+
api: Context,
|
|
183
|
+
context: CompanionContext,
|
|
184
|
+
env: LaunchEnvironment = process.env,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
const guidance = loadGuidance(env, api.location.directory);
|
|
177
187
|
if (!guidance) return;
|
|
178
188
|
/** Reported, not thrown: the guardrails registered before this must stay live. */
|
|
179
|
-
const wrapperBinPath = resolveWrapperBinPath();
|
|
189
|
+
const wrapperBinPath = resolveWrapperBinPath(env);
|
|
180
190
|
if (!wrapperBinPath) {
|
|
181
191
|
console.error(`mate: ${missingRuntimeDirectoryMessage()}`);
|
|
182
192
|
return;
|
|
@@ -199,7 +209,7 @@ export async function registerCompanion(api: Context, context: CompanionContext)
|
|
|
199
209
|
});
|
|
200
210
|
|
|
201
211
|
await api.shell.hook("create.before", (event) => {
|
|
202
|
-
applyCompanionShellEnv(context, event.env, wrapperBinPath);
|
|
212
|
+
applyCompanionShellEnv(context, event.env, wrapperBinPath, env);
|
|
203
213
|
});
|
|
204
214
|
|
|
205
215
|
await api.tool.transform((editor) => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MATE_ENV } from "../../src/runtime/env";
|
|
2
|
+
|
|
3
|
+
export type LaunchEnvironment = Record<string, string | undefined>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* True inside OpenCode's shared background service (`opencode serve --service`),
|
|
7
|
+
* which hosts plugins in-process for every bare `opencode` session. Its
|
|
8
|
+
* environment belongs to whichever process started it, not to any session.
|
|
9
|
+
*/
|
|
10
|
+
export function runsInSharedService(argv: readonly string[]): boolean {
|
|
11
|
+
return argv.includes("serve") && argv.includes("--service");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The environment the plugin may treat as a Mate launch environment: `env`
|
|
16
|
+
* itself for a managed launch, `{}` otherwise so every reader falls back to
|
|
17
|
+
* the Projection Root.
|
|
18
|
+
*
|
|
19
|
+
* Inherited `MATE_*` variables prove nothing — every shell under a Mate session
|
|
20
|
+
* carries them. The guidance payload does: only `mate opencode` sets it, and
|
|
21
|
+
* the plugin masks it in every shell it configures.
|
|
22
|
+
*/
|
|
23
|
+
export function pluginLaunchEnvironment(
|
|
24
|
+
env: LaunchEnvironment,
|
|
25
|
+
argv: readonly string[],
|
|
26
|
+
): LaunchEnvironment {
|
|
27
|
+
const managed = Boolean(env[MATE_ENV.guidanceJson]?.trim()) && !runsInSharedService(argv);
|
|
28
|
+
return managed ? env : {};
|
|
29
|
+
}
|
|
@@ -12,14 +12,15 @@ import { readWrapStateSync } from "../../src/runtime/wrap-state";
|
|
|
12
12
|
|
|
13
13
|
import { registerCompanionAccess } from "./add-dir";
|
|
14
14
|
import { missingRuntimeDirectoryMessage, registerCompanion, resolveMateBin } from "./companion";
|
|
15
|
+
import { pluginLaunchEnvironment, type LaunchEnvironment } from "./launch-environment";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Only a location in a repository wrapped for OpenCode prepares, and only
|
|
18
19
|
* without a launch environment: `mate opencode` has already prepared the
|
|
19
20
|
* session it starts.
|
|
20
21
|
*/
|
|
21
|
-
async function registerPrepare(api: Context, companionPath: string) {
|
|
22
|
-
if (hasLaunchEnvironment(
|
|
22
|
+
async function registerPrepare(api: Context, companionPath: string, env: LaunchEnvironment) {
|
|
23
|
+
if (hasLaunchEnvironment(env)) return undefined;
|
|
23
24
|
const repoRoot = repositoryWrappedForOpenCode(api.location.directory);
|
|
24
25
|
if (!repoRoot) return undefined;
|
|
25
26
|
const mateBin = resolveMateBin();
|
|
@@ -39,19 +40,22 @@ async function registerPrepare(api: Context, companionPath: string) {
|
|
|
39
40
|
/**
|
|
40
41
|
* Mate OpenCode server plugin (OpenCode 2.x). Loaded through the package's
|
|
41
42
|
* `./server` export; stays inert when no companion resolves from the launch
|
|
42
|
-
* environment or a Projection Root above the session directory.
|
|
43
|
+
* environment or a Projection Root above the session directory. The launch
|
|
44
|
+
* environment is decided once here: outside a managed launch every reader
|
|
45
|
+
* gets `{}`, so an environment inherited by the shared service never counts.
|
|
43
46
|
*/
|
|
44
47
|
export const MateOpenCodePlugin = Plugin.define({
|
|
45
48
|
id: "mate-opencode-plugin",
|
|
46
49
|
async setup(api) {
|
|
47
|
-
const
|
|
50
|
+
const env = pluginLaunchEnvironment(process.env, process.argv);
|
|
51
|
+
const context = readContext(env, api.location.directory);
|
|
48
52
|
if (!context.companionPath) return;
|
|
49
53
|
|
|
50
54
|
await registerCompanionAccess(api, context.companionPath);
|
|
51
|
-
await registerCompanionHooks(api, context);
|
|
52
|
-
await registerCompanion(api, context);
|
|
55
|
+
await registerCompanionHooks(api, context, env);
|
|
56
|
+
await registerCompanion(api, context, env);
|
|
53
57
|
/** Last: the guardrails above are live while the local phase holds `setup`. */
|
|
54
|
-
const prepareCleanup = await registerPrepare(api, context.companionPath);
|
|
58
|
+
const prepareCleanup = await registerPrepare(api, context.companionPath, env);
|
|
55
59
|
return async () => {
|
|
56
60
|
await prepareCleanup?.();
|
|
57
61
|
};
|
package/package.json
CHANGED
|
@@ -73,9 +73,12 @@ type ProjectionResult =
|
|
|
73
73
|
| { ok: true; repository: string; areas: string[] }
|
|
74
74
|
| { ok: false; reason: string };
|
|
75
75
|
|
|
76
|
+
/** A leading `---` block; group 1 is its YAML, the match ends after the closing line. */
|
|
77
|
+
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
78
|
+
|
|
76
79
|
function parseDeltaScopes(source: string): ScopePair[] {
|
|
77
80
|
const text = source.startsWith(BOM) ? source.slice(BOM.length) : source;
|
|
78
|
-
const match =
|
|
81
|
+
const match = FRONTMATTER_PATTERN.exec(text);
|
|
79
82
|
if (!match) return [];
|
|
80
83
|
|
|
81
84
|
try {
|
|
@@ -95,6 +98,26 @@ function parseDeltaScopes(source: string): ScopePair[] {
|
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
const DELTA_TAGS = new Set(["openspec/change", "openspec/delta"]);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A header an agent copied from a delta: it declares `type: delta-spec`, carries a
|
|
105
|
+
* `change` key, or a delta tag. A nested `scopes` key alone does not count, since
|
|
106
|
+
* legacy canonical specs carry one.
|
|
107
|
+
*/
|
|
108
|
+
function isDeltaShapedFrontmatter(yamlSource: string): boolean {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = parse(yamlSource) as unknown;
|
|
111
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
112
|
+
const header = parsed as Record<string, unknown>;
|
|
113
|
+
if (header.type === "delta-spec" || "change" in header) return true;
|
|
114
|
+
const tags = Array.isArray(header.tags) ? header.tags : [header.tags];
|
|
115
|
+
return tags.some((tag) => typeof tag === "string" && DELTA_TAGS.has(tag));
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
98
121
|
function projectDeltaScopes(scopes: ScopePair[]): ProjectionResult {
|
|
99
122
|
if (scopes.length === 0) return { ok: false, reason: "delta declares no scopes entries" };
|
|
100
123
|
const repositories = [...new Set(scopes.map((entry) => entry.repository))];
|
|
@@ -133,7 +156,10 @@ async function archivedChangeUsesMateSchema(
|
|
|
133
156
|
}
|
|
134
157
|
}
|
|
135
158
|
|
|
136
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* The block through its closing line. Scope keys are omitted when the delta had
|
|
161
|
+
* none — a partial block beats no frontmatter.
|
|
162
|
+
*/
|
|
137
163
|
function renderCanonicalFrontmatter(
|
|
138
164
|
capability: string,
|
|
139
165
|
scope: { repository: string; areas: string[] } | null,
|
|
@@ -146,14 +172,14 @@ function renderCanonicalFrontmatter(
|
|
|
146
172
|
"tags: [openspec/spec]",
|
|
147
173
|
"---",
|
|
148
174
|
"",
|
|
149
|
-
"",
|
|
150
175
|
].join("\n");
|
|
151
176
|
}
|
|
152
177
|
|
|
153
178
|
/**
|
|
154
179
|
* Repairs `openspec archive` output: prepends canonical frontmatter to main specs the
|
|
155
|
-
* archive run created bare
|
|
156
|
-
*
|
|
180
|
+
* archive run created bare, and replaces a delta-shaped header an agent copied onto one.
|
|
181
|
+
* Publish does not archive, so this runs the first time publish resolves that archive,
|
|
182
|
+
* however the archive was made.
|
|
157
183
|
*
|
|
158
184
|
* `openspec archive` rebuilds a brand-new main spec from a skeleton with no frontmatter slot,
|
|
159
185
|
* so scope metadata dies exactly once, at spec birth; existing specs keep theirs because only
|
|
@@ -197,8 +223,14 @@ async function reconcileMainSpecFrontmatter(
|
|
|
197
223
|
const canonicalPath = path.join(companionPath, canonicalRelative);
|
|
198
224
|
|
|
199
225
|
try {
|
|
200
|
-
const canonical = await fs.readFile(canonicalPath, "utf8");
|
|
201
|
-
|
|
226
|
+
const canonical = (await fs.readFile(canonicalPath, "utf8")).replace(BOM, "");
|
|
227
|
+
/** The body that follows the written block: the whole file, or what trails a delta-shaped header. */
|
|
228
|
+
let body = `\n${canonical}`;
|
|
229
|
+
if (canonical.startsWith("---")) {
|
|
230
|
+
const header = FRONTMATTER_PATTERN.exec(canonical);
|
|
231
|
+
if (!header || !isDeltaShapedFrontmatter(header[1])) continue;
|
|
232
|
+
body = canonical.slice(header[0].length);
|
|
233
|
+
}
|
|
202
234
|
|
|
203
235
|
const deltaSource = await fs.readFile(path.join(directory, entry.name), "utf8");
|
|
204
236
|
const scopes = parseDeltaScopes(deltaSource);
|
|
@@ -215,7 +247,7 @@ async function reconcileMainSpecFrontmatter(
|
|
|
215
247
|
capability,
|
|
216
248
|
projected.ok ? { repository: projected.repository, areas: projected.areas } : null,
|
|
217
249
|
);
|
|
218
|
-
await fs.writeFile(canonicalPath, head +
|
|
250
|
+
await fs.writeFile(canonicalPath, head + body, "utf8");
|
|
219
251
|
reconciled.push(canonicalRelative);
|
|
220
252
|
} catch (error) {
|
|
221
253
|
process.stderr.write(
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
1
|
import {
|
|
5
2
|
hasGraphifyCapability,
|
|
6
3
|
hasTokensaveCapability,
|
|
@@ -10,7 +7,7 @@ import type { CapabilityConfig } from "../../../lib/orchestrator/types";
|
|
|
10
7
|
import {
|
|
11
8
|
ensureTokensaveBranchingPosture,
|
|
12
9
|
ensureTokensaveStoreExcluded,
|
|
13
|
-
|
|
10
|
+
isTokensaveIndexed,
|
|
14
11
|
} from "../../../tools/setup/capabilities/tokensave";
|
|
15
12
|
import { ensureUnambiguousCompanion } from "../shared/companion-selection";
|
|
16
13
|
import { runGraphifyCapCommand } from "./graphify";
|
|
@@ -43,13 +40,7 @@ async function defaultIsTokensaveInitialized(): Promise<boolean> {
|
|
|
43
40
|
const repo =
|
|
44
41
|
repository ?? (await workingRepoStore.load()).repos.find((entry) => entry.id === repositoryId);
|
|
45
42
|
if (!repo) return false;
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const stat = await fs.stat(path.join(repo.path, TOKENSAVE_STORE_DIR));
|
|
49
|
-
return stat.isDirectory();
|
|
50
|
-
} catch {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
43
|
+
return isTokensaveIndexed(repo.path);
|
|
53
44
|
}
|
|
54
45
|
|
|
55
46
|
// The reused cap commands (runGraphifyCapCommand / runTokensaveCapCommand) signal
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
|
|
4
7
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
8
|
import { gitEnvironment } from "../../runtime/companion-git";
|
|
6
9
|
import { getActiveDistribution } from "../../distribution";
|
|
10
|
+
import { hasOpenspecCapability } from "../../lib/orchestrator/capabilities";
|
|
7
11
|
import { checkEngineRequirement } from "../../lib/orchestrator/engine-guard";
|
|
8
12
|
import type { GlobalConfigStore } from "../../lib/orchestrator/global-config-store";
|
|
9
13
|
import { pathIsDirectory } from "../../lib/fs-utils";
|
|
@@ -73,6 +77,8 @@ export interface DoctorReport {
|
|
|
73
77
|
/** IDs of the capabilities the verified plugins provide. */
|
|
74
78
|
pluginCapabilities?: string[];
|
|
75
79
|
engineRequirement?: { range: string; ok: boolean; detail: string };
|
|
80
|
+
/** Present when the companion's `openspec/config.yaml` has `rules` keys OpenSpec ignores. */
|
|
81
|
+
openspecRuleWarnings?: Array<{ key: string; message: string }>;
|
|
76
82
|
hub?: { members: DoctorHubMember[] };
|
|
77
83
|
resolutionFailures: Array<{ companionPath: string; message: string }>;
|
|
78
84
|
}
|
|
@@ -239,6 +245,70 @@ function collectAudiences(
|
|
|
239
245
|
if (!resolution.ok) report.audienceError = describeUnknownAudience(resolution);
|
|
240
246
|
}
|
|
241
247
|
|
|
248
|
+
/** Artifact IDs of OpenSpec's built-in `spec-driven` schema. */
|
|
249
|
+
const BUILT_IN_OPENSPEC_ARTIFACTS = ["proposal", "specs", "design", "tasks"];
|
|
250
|
+
|
|
251
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
252
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
253
|
+
? (value as Record<string, unknown>)
|
|
254
|
+
: undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Built-in IDs plus every `artifacts[].id` from a `schema.yaml` under the companion's `openspec/schemas/`. */
|
|
258
|
+
async function knownOpenSpecArtifactIds(companionPath: string): Promise<Set<string>> {
|
|
259
|
+
const schemasDir = path.join(companionPath, "openspec", "schemas");
|
|
260
|
+
let schemaNames: string[] = [];
|
|
261
|
+
try {
|
|
262
|
+
schemaNames = await fs.readdir(schemasDir);
|
|
263
|
+
} catch {
|
|
264
|
+
/* no companion schemas */
|
|
265
|
+
}
|
|
266
|
+
const schemaIds = await Promise.all(
|
|
267
|
+
schemaNames.map(async (name) => {
|
|
268
|
+
try {
|
|
269
|
+
const schema = asRecord(
|
|
270
|
+
parseYaml(await fs.readFile(path.join(schemasDir, name, "schema.yaml"), "utf8")),
|
|
271
|
+
);
|
|
272
|
+
const artifacts: unknown[] = Array.isArray(schema?.artifacts) ? schema.artifacts : [];
|
|
273
|
+
return artifacts.flatMap((artifact) => {
|
|
274
|
+
const id = asRecord(artifact)?.id;
|
|
275
|
+
return typeof id === "string" ? [id] : [];
|
|
276
|
+
});
|
|
277
|
+
} catch {
|
|
278
|
+
return []; // not a readable schema
|
|
279
|
+
}
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
return new Set([...BUILT_IN_OPENSPEC_ARTIFACTS, ...schemaIds.flat()]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* OpenSpec ignores a `rules` key that names no artifact, warning only on stderr
|
|
287
|
+
* of the command that reads it. Read-only; a missing or unparseable config is silent.
|
|
288
|
+
*/
|
|
289
|
+
async function collectOpenSpecRuleWarnings(
|
|
290
|
+
companionPath: string,
|
|
291
|
+
): Promise<NonNullable<DoctorReport["openspecRuleWarnings"]>> {
|
|
292
|
+
let rules: Record<string, unknown> | undefined;
|
|
293
|
+
try {
|
|
294
|
+
const config = parseYaml(
|
|
295
|
+
await fs.readFile(path.join(companionPath, "openspec", "config.yaml"), "utf8"),
|
|
296
|
+
);
|
|
297
|
+
rules = asRecord(asRecord(config)?.rules);
|
|
298
|
+
} catch {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
if (!rules) return [];
|
|
302
|
+
|
|
303
|
+
const known = await knownOpenSpecArtifactIds(companionPath);
|
|
304
|
+
return Object.keys(rules)
|
|
305
|
+
.filter((key) => !known.has(key))
|
|
306
|
+
.map((key) => ({
|
|
307
|
+
key,
|
|
308
|
+
message: `rules.${key} matches no OpenSpec artifact and is ignored; put workflow guidance in operations.apply.guidance or operations.archive.guidance`,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
242
312
|
export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<DoctorReport> {
|
|
243
313
|
const cwd = path.resolve(deps.cwd ?? process.cwd());
|
|
244
314
|
const pathValue = deps.pathValue ?? process.env.PATH ?? "";
|
|
@@ -277,6 +347,15 @@ export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<Doctor
|
|
|
277
347
|
collectAudiences(report, root.config, env);
|
|
278
348
|
}
|
|
279
349
|
|
|
350
|
+
if (
|
|
351
|
+
root.rootPath &&
|
|
352
|
+
(kind === "working" || kind === "companion") &&
|
|
353
|
+
hasOpenspecCapability(root.config?.capabilities)
|
|
354
|
+
) {
|
|
355
|
+
const warnings = await collectOpenSpecRuleWarnings(root.rootPath);
|
|
356
|
+
if (warnings.length > 0) report.openspecRuleWarnings = warnings;
|
|
357
|
+
}
|
|
358
|
+
|
|
280
359
|
if (root.rootPath && root.config) {
|
|
281
360
|
const inspect =
|
|
282
361
|
deps.inspectPlugins ?? ((companionPath) => inspectDeclaredPlugins(companionPath, { env }));
|
|
@@ -398,6 +477,15 @@ function renderHumanReport(report: DoctorReport): void {
|
|
|
398
477
|
if (report.engineRequirement) {
|
|
399
478
|
printSection("Version Requirement", report.engineRequirement.detail);
|
|
400
479
|
}
|
|
480
|
+
if (report.openspecRuleWarnings) {
|
|
481
|
+
printSection(
|
|
482
|
+
"OpenSpec Config",
|
|
483
|
+
renderList(
|
|
484
|
+
report.openspecRuleWarnings.map((warning) => warning.message),
|
|
485
|
+
"",
|
|
486
|
+
),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
401
489
|
|
|
402
490
|
printSection(
|
|
403
491
|
"Resolution Failures",
|
|
@@ -88,7 +88,8 @@ export abstract class LaunchAdapter<C extends AdapterContext = AdapterContext> {
|
|
|
88
88
|
|
|
89
89
|
async validateLaunch(_context: C): Promise<void> {}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
/** `args` are the operator's arguments, before `buildArgs` rewrites them. */
|
|
92
|
+
extendEnvironment(_context: C, _args: string[] = []): NodeJS.ProcessEnv {
|
|
92
93
|
return {};
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -130,7 +131,7 @@ export abstract class LaunchAdapter<C extends AdapterContext = AdapterContext> {
|
|
|
130
131
|
async prepareLaunch(context: C, args: string[]): Promise<PreparedLaunch> {
|
|
131
132
|
const builtArgs = this.buildArgs(context, args);
|
|
132
133
|
const baseEnv = this.environment(context);
|
|
133
|
-
const extendedEnv = this.extendEnvironment(context);
|
|
134
|
+
const extendedEnv = this.extendEnvironment(context, args);
|
|
134
135
|
const env = { ...baseEnv, ...extendedEnv };
|
|
135
136
|
|
|
136
137
|
const processPath = process.env.PATH ?? "";
|
|
@@ -36,6 +36,26 @@ function parseOpenCodeMajor(output: string | undefined): number | undefined {
|
|
|
36
36
|
return match ? Number(match[1]) : undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
interface OpenCodeInvocation {
|
|
40
|
+
command: string | undefined;
|
|
41
|
+
rest: string[];
|
|
42
|
+
/**
|
|
43
|
+
* The launch runs its own private server, where the plugin loads with this
|
|
44
|
+
* launch's environment: the TUI or `run`, not pointed at a remote server.
|
|
45
|
+
* Every other invocation may start or reach the shared service, which must
|
|
46
|
+
* never receive a guidance payload.
|
|
47
|
+
*/
|
|
48
|
+
hostsPrivateServer: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readInvocation(launchWorkingDirectory: string, args: string[]): OpenCodeInvocation {
|
|
52
|
+
const isTui = args.length === 0 || args[0]?.startsWith("-");
|
|
53
|
+
const [command, ...rest] = isTui ? [launchWorkingDirectory, ...args] : args;
|
|
54
|
+
const hostsServer = isTui || command === "run";
|
|
55
|
+
const choosesRemoteServer = rest.some((arg) => arg === "--server" || arg.startsWith("--server="));
|
|
56
|
+
return { command, rest, hostsPrivateServer: Boolean(hostsServer) && !choosesRemoteServer };
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
export class OpenCodeAdapter extends LaunchAdapter {
|
|
40
60
|
readonly toolName = "opencode";
|
|
41
61
|
readonly interactive = true;
|
|
@@ -67,15 +87,13 @@ export class OpenCodeAdapter extends LaunchAdapter {
|
|
|
67
87
|
}
|
|
68
88
|
|
|
69
89
|
buildArgs(context: AdapterContext, args: string[]): string[] {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!command) return args;
|
|
74
|
-
const hostsServer = isTui || command === "run";
|
|
75
|
-
const choosesServer = rest.some(
|
|
76
|
-
(arg) => arg === "--standalone" || arg === "--server" || arg.startsWith("--server="),
|
|
90
|
+
const { command, rest, hostsPrivateServer } = readInvocation(
|
|
91
|
+
context.launchWorkingDirectory,
|
|
92
|
+
args,
|
|
77
93
|
);
|
|
78
|
-
|
|
94
|
+
if (!command) return args;
|
|
95
|
+
const addsStandalone = hostsPrivateServer && !rest.includes(STANDALONE_FLAG);
|
|
96
|
+
return addsStandalone ? [command, STANDALONE_FLAG, ...rest] : [command, ...rest];
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
/**
|
|
@@ -84,11 +102,16 @@ export class OpenCodeAdapter extends LaunchAdapter {
|
|
|
84
102
|
* overlay carried now lives in the wrapped `.opencode/opencode.json`. An
|
|
85
103
|
* inherited `OPENCODE_CONFIG_DIR` or `OPENCODE_CONFIG_CONTENT` passes through.
|
|
86
104
|
*/
|
|
87
|
-
extendEnvironment(context: AdapterContext): NodeJS.ProcessEnv {
|
|
105
|
+
extendEnvironment(context: AdapterContext, args: string[] = []): NodeJS.ProcessEnv {
|
|
106
|
+
const { hostsPrivateServer } = readInvocation(context.launchWorkingDirectory, args);
|
|
88
107
|
return {
|
|
89
108
|
// Built fresh per launch from the live capability config; always set so
|
|
90
109
|
// a stale value inherited from an outer Mate session can never leak in.
|
|
91
|
-
|
|
110
|
+
// Masked unless the launch hosts its own server: a payload that reached
|
|
111
|
+
// the shared service would apply this session's guidance to every other.
|
|
112
|
+
[MATE_ENV.guidanceJson]: hostsPrivateServer
|
|
113
|
+
? JSON.stringify(this.buildGuidance(context))
|
|
114
|
+
: "",
|
|
92
115
|
};
|
|
93
116
|
}
|
|
94
117
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
+
import { normalizeBaseAudience } from "../../tools/setup/dynamic-plugins/audiences";
|
|
4
|
+
|
|
3
5
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
4
6
|
import {
|
|
5
7
|
getDefaultSetupSelections,
|
|
@@ -296,8 +298,11 @@ export class ConfigStore extends YamlFileStore<FrameworkConfig> {
|
|
|
296
298
|
super(path.resolve(configPath));
|
|
297
299
|
}
|
|
298
300
|
|
|
301
|
+
/** Either file shape loads as the base set at the root; the next save writes the root shape. */
|
|
299
302
|
override async load(): Promise<FrameworkConfig> {
|
|
300
|
-
const merged = mergeWithDefaults(
|
|
303
|
+
const merged = mergeWithDefaults(
|
|
304
|
+
migrateProfilesToAllowedAgents(normalizeBaseAudience(await super.load())),
|
|
305
|
+
);
|
|
301
306
|
validateHubConfig(merged);
|
|
302
307
|
validatePluginDeclarations(merged);
|
|
303
308
|
validateAudiences(merged);
|