@uniqbit/mate-core 0.17.2-canary.3 → 0.18.0
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/package.json +1 -1
- package/src/cli/commands/companion/update.ts +43 -6
- package/src/cli/commands/install.ts +3 -1
- package/src/cli/commands/plugin/plugin.ts +1 -1
- package/src/cli/commands/plugin/verify.ts +4 -8
- package/src/cli/commands/studio/terminal-launch.ts +1 -51
- package/src/cli/commands/studio/terminal.ts +2 -13
- package/src/lib/install.ts +3 -0
- package/src/lib/orchestrator/types.ts +0 -6
- package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +5 -2
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +5 -2
- package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +5 -2
- package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +5 -2
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -2
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +5 -2
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +5 -2
- package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +5 -2
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +5 -2
- package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +5 -2
- package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +5 -2
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -2
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +5 -2
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +5 -2
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +90 -63
- package/src/tools/setup/capabilities/openspec.ts +55 -27
- package/src/tools/setup/capabilities/react-doctor.ts +8 -16
- package/src/tools/setup/capabilities/tokensave.ts +1 -0
- package/src/tools/setup/dynamic-plugins/frozen.ts +24 -3
- package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
- package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
- package/src/tools/setup/dynamic-plugins/staging.ts +11 -5
- package/src/tools/setup/dynamic-plugins/verify.ts +22 -18
- package/src/tools/setup/engine.ts +6 -1
- package/src/tools/setup/install-contract.ts +2 -0
- package/src/tools/setup/mate.ts +3 -1
- package/src/tools/setup/plugin.ts +2 -0
- package/src/tools/setup/providers/agents-skill-root.ts +79 -0
- package/src/tools/setup/providers/opencode.ts +8 -9
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
type PluginHost,
|
|
15
15
|
} from "./host";
|
|
16
16
|
import { dynamicPluginsWorkspaceRoot, pluginPackageRoot } from "./paths";
|
|
17
|
+
import {
|
|
18
|
+
disallowedPluginMessage,
|
|
19
|
+
isPluginAllowed,
|
|
20
|
+
PluginPolicyError,
|
|
21
|
+
readPluginPolicy,
|
|
22
|
+
} from "./policy";
|
|
17
23
|
|
|
18
24
|
interface PluginManifest {
|
|
19
25
|
mate?: { pluginApiVersion?: unknown };
|
|
@@ -47,6 +53,16 @@ export async function loadDynamicPlugin(
|
|
|
47
53
|
const name = declaration.package;
|
|
48
54
|
const packageRoot = pluginPackageRoot(companionPath, name);
|
|
49
55
|
|
|
56
|
+
/** Fails closed: a malformed policy allows nothing. Checked before any plugin file is touched. */
|
|
57
|
+
try {
|
|
58
|
+
if (!isPluginAllowed(readPluginPolicy(deps.env ?? process.env), name)) {
|
|
59
|
+
return { ok: false, warning: `${disallowedPluginMessage(name)}; not loaded.` };
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!(error instanceof PluginPolicyError)) throw error;
|
|
63
|
+
return { ok: false, warning: `plugin "${name}" not loaded: ${error.message}` };
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
let manifest: PluginManifest;
|
|
51
67
|
try {
|
|
52
68
|
manifest = JSON.parse(
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Environment variable carrying the appliance's declared-plugin package allowlist. */
|
|
2
|
+
export const ALLOWED_PLUGINS_ENV = "MATE_ALLOWED_PLUGINS";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parsed allowlist: exact package names and scope-wide `@scope/*` patterns.
|
|
6
|
+
* An empty list permits no package; the policy's absence permits all.
|
|
7
|
+
*/
|
|
8
|
+
export interface PluginPolicy {
|
|
9
|
+
exact: string[];
|
|
10
|
+
scopes: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class PluginPolicyError extends Error {}
|
|
14
|
+
|
|
15
|
+
const EXACT_PATTERN = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
16
|
+
const SCOPE_PATTERN = /^@[a-z0-9][a-z0-9._~-]*\/\*$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parses a comma-separated allowlist. `undefined` means no policy; an empty
|
|
20
|
+
* or blank string is an explicit empty policy. Malformed entries throw so a
|
|
21
|
+
* typo can never widen or silently narrow the trust decision.
|
|
22
|
+
*/
|
|
23
|
+
export function parseAllowedPlugins(raw: string | undefined): PluginPolicy | null {
|
|
24
|
+
if (raw === undefined) return null;
|
|
25
|
+
const policy: PluginPolicy = { exact: [], scopes: [] };
|
|
26
|
+
if (raw.trim() === "") return policy;
|
|
27
|
+
for (const part of raw.split(",")) {
|
|
28
|
+
const entry = part.trim();
|
|
29
|
+
if (SCOPE_PATTERN.test(entry)) policy.scopes.push(entry.slice(0, entry.indexOf("/") + 1));
|
|
30
|
+
else if (EXACT_PATTERN.test(entry)) policy.exact.push(entry);
|
|
31
|
+
else {
|
|
32
|
+
throw new PluginPolicyError(
|
|
33
|
+
`${ALLOWED_PLUGINS_ENV}: malformed entry ${JSON.stringify(entry)}; expected an exact package name or a scope pattern such as "@acme/*"`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return policy;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reads the effective policy from an environment; throws on a malformed value. */
|
|
41
|
+
export function readPluginPolicy(
|
|
42
|
+
env: Record<string, string | undefined> = process.env,
|
|
43
|
+
): PluginPolicy | null {
|
|
44
|
+
return parseAllowedPlugins(env[ALLOWED_PLUGINS_ENV]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isPluginAllowed(policy: PluginPolicy | null, packageName: string): boolean {
|
|
48
|
+
if (policy === null) return true;
|
|
49
|
+
if (policy.exact.includes(packageName)) return true;
|
|
50
|
+
return policy.scopes.some((scope) => packageName.startsWith(scope));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function disallowedPluginMessage(packageName: string): string {
|
|
54
|
+
return `plugin "${packageName}" is not allowed by ${ALLOWED_PLUGINS_ENV}`;
|
|
55
|
+
}
|
|
@@ -25,16 +25,22 @@ function changedPaths(cwd: string): string[] {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* Checks that what plugins would generate is already committed. The live
|
|
29
|
+
* checkout must have no edits of its own; the projection then runs against a
|
|
30
|
+
* disposable clone of its commit, and any file that differs afterwards is
|
|
31
|
+
* drift. Nothing is written back to the live checkout. Returns the drifting
|
|
32
|
+
* paths (empty when the checkout is complete).
|
|
33
33
|
*/
|
|
34
34
|
export async function verifyTrackedPluginOutputs(
|
|
35
35
|
companionPath: string,
|
|
36
36
|
project: StagedProjection,
|
|
37
37
|
): Promise<string[]> {
|
|
38
|
+
const pending = changedPaths(companionPath);
|
|
39
|
+
if (pending.length > 0) {
|
|
40
|
+
throw new TrackedOutputError(
|
|
41
|
+
`the checkout has uncommitted changes, which frozen setup will not overwrite: ${pending.join(", ")}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
38
44
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "mate-staging-"));
|
|
39
45
|
try {
|
|
40
46
|
const staged = path.join(root, "companion");
|
|
@@ -5,37 +5,38 @@ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
|
|
|
5
5
|
import { readDeclarations, validateDeclaration } from "./declarations";
|
|
6
6
|
import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
|
|
7
7
|
import { pluginPackageRoot } from "./paths";
|
|
8
|
+
import {
|
|
9
|
+
disallowedPluginMessage,
|
|
10
|
+
isPluginAllowed,
|
|
11
|
+
PluginPolicyError,
|
|
12
|
+
readPluginPolicy,
|
|
13
|
+
} from "./policy";
|
|
8
14
|
|
|
9
15
|
export interface PluginVerificationFailure {
|
|
10
16
|
package: string;
|
|
11
17
|
reason: string;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export interface PluginInspection {
|
|
15
|
-
failures: PluginVerificationFailure[];
|
|
16
|
-
/** IDs of the capabilities the loaded plugins provide. */
|
|
17
|
-
capabilities: string[];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
20
|
/**
|
|
21
|
-
* Strict, installation-free check of every declared plugin: installed
|
|
21
|
+
* Strict, installation-free check of every declared plugin: allowed, installed
|
|
22
22
|
* and loadable with the effective environment. Unlike hydration it fails
|
|
23
|
-
* closed on the first-class problems ordinary commands only warn about.
|
|
23
|
+
* closed on the first-class problems ordinary commands only warn about. Only
|
|
24
|
+
* allowlisted packages are imported.
|
|
24
25
|
*/
|
|
25
26
|
export async function verifyDeclaredPlugins(
|
|
26
27
|
companionPath: string,
|
|
27
28
|
deps: DynamicPluginLoadDeps = {},
|
|
28
29
|
): Promise<PluginVerificationFailure[]> {
|
|
29
|
-
return (await inspectDeclaredPlugins(companionPath, deps)).failures;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function inspectDeclaredPlugins(
|
|
33
|
-
companionPath: string,
|
|
34
|
-
deps: DynamicPluginLoadDeps = {},
|
|
35
|
-
): Promise<PluginInspection> {
|
|
36
30
|
const env = deps.env ?? process.env;
|
|
37
31
|
const failures: PluginVerificationFailure[] = [];
|
|
38
|
-
|
|
32
|
+
let policy: ReturnType<typeof readPluginPolicy>;
|
|
33
|
+
try {
|
|
34
|
+
policy = readPluginPolicy(env);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (!(error instanceof PluginPolicyError)) throw error;
|
|
37
|
+
return [{ package: "(allowlist)", reason: error.message }];
|
|
38
|
+
}
|
|
39
|
+
|
|
39
40
|
const declarations: PluginDeclaration[] = [];
|
|
40
41
|
for (const entry of await readDeclarations(companionPath)) {
|
|
41
42
|
const { declaration, error } = validateDeclaration(entry);
|
|
@@ -45,6 +46,10 @@ export async function inspectDeclaredPlugins(
|
|
|
45
46
|
|
|
46
47
|
for (const declaration of declarations) {
|
|
47
48
|
const name = declaration.package;
|
|
49
|
+
if (!isPluginAllowed(policy, name)) {
|
|
50
|
+
failures.push({ package: name, reason: disallowedPluginMessage(name) });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
48
53
|
// oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
|
|
49
54
|
const installed = await fs
|
|
50
55
|
.access(pluginPackageRoot(companionPath, name))
|
|
@@ -60,7 +65,6 @@ export async function inspectDeclaredPlugins(
|
|
|
60
65
|
// oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
|
|
61
66
|
const result = await loadDynamicPlugin(companionPath, declaration, { ...deps, env });
|
|
62
67
|
if (!result.ok) failures.push({ package: name, reason: result.warning });
|
|
63
|
-
else if (result.plugin.kind === "capability") capabilities.push(result.plugin.id);
|
|
64
68
|
}
|
|
65
|
-
return
|
|
69
|
+
return failures;
|
|
66
70
|
}
|
|
@@ -221,7 +221,12 @@ async function collectContributionInputs(
|
|
|
221
221
|
// are torn down. The surfaces never create files for disabled inputs.
|
|
222
222
|
const runtimeActive = plan.activeProviders.includes(runtimeId);
|
|
223
223
|
const inputs = inputsByRuntime.get(runtimeId) ?? [];
|
|
224
|
-
inputs.push({
|
|
224
|
+
inputs.push({
|
|
225
|
+
pluginId: capability.id,
|
|
226
|
+
enabled: enabled && runtimeActive,
|
|
227
|
+
capabilityEnabled: enabled,
|
|
228
|
+
contributions,
|
|
229
|
+
});
|
|
225
230
|
inputsByRuntime.set(runtimeId, inputs);
|
|
226
231
|
}
|
|
227
232
|
}
|
|
@@ -17,4 +17,6 @@ export interface InstallRequirement {
|
|
|
17
17
|
detect(): boolean | Promise<boolean>;
|
|
18
18
|
install(): Promise<void>;
|
|
19
19
|
verify?(): boolean | Promise<boolean>;
|
|
20
|
+
/** Best-effort refresh of an already-satisfied requirement; must never throw. */
|
|
21
|
+
update?(): Promise<void>;
|
|
20
22
|
}
|
package/src/tools/setup/mate.ts
CHANGED
|
@@ -17,6 +17,8 @@ export const MATE_SKILLS = [
|
|
|
17
17
|
"mate-simplify-code",
|
|
18
18
|
] as const;
|
|
19
19
|
const LEGACY_MATE_SKILLS = ["mate-artifact-finish", "mate-openspec-artifact-finish"] as const;
|
|
20
|
+
/** Every Mate-owned skill name a skill root may hold, current or retired. */
|
|
21
|
+
export const MANAGED_MATE_SKILL_NAMES = [...MATE_SKILLS, ...LEGACY_MATE_SKILLS] as const;
|
|
20
22
|
|
|
21
23
|
const MATE_SKILLS_SOURCE = path.join(import.meta.dirname, "../../templates/mate-skills");
|
|
22
24
|
|
|
@@ -147,7 +149,7 @@ export async function applyMateSkills(skillsDir: string, tool: string): Promise<
|
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
export async function teardownMateSkills(skillsDir: string, companionPath: string): Promise<void> {
|
|
150
|
-
for (const skill of
|
|
152
|
+
for (const skill of MANAGED_MATE_SKILL_NAMES) {
|
|
151
153
|
try {
|
|
152
154
|
await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
|
|
153
155
|
} catch {
|
|
@@ -219,6 +219,8 @@ export type RuntimeContributionsByRuntime = Partial<Record<string, RuntimeContri
|
|
|
219
219
|
export interface CapabilityContributionInput {
|
|
220
220
|
pluginId: string;
|
|
221
221
|
enabled: boolean;
|
|
222
|
+
/** Capability enabled regardless of runtime; absent means disabled. */
|
|
223
|
+
capabilityEnabled?: boolean;
|
|
222
224
|
contributions: RuntimeContributions;
|
|
223
225
|
}
|
|
224
226
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// oxlint-disable no-await-in-loop
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { SkillTreeContribution } from "../plugin";
|
|
6
|
+
import { mergeDir, pruneEmptyAncestors } from "../utils";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Agent Runtimes that read the vendor-neutral companion `.agents/skills` root.
|
|
10
|
+
* Claude is never a member: it reads only `.claude/skills`.
|
|
11
|
+
*/
|
|
12
|
+
export const SHARED_SKILL_ROOT_RUNTIMES = ["opencode"] as const;
|
|
13
|
+
|
|
14
|
+
export function isSharedSkillRootActive(activeProviders: readonly string[]): boolean {
|
|
15
|
+
return SHARED_SKILL_ROOT_RUNTIMES.some((runtime) => activeProviders.includes(runtime));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSharedSkillsDir(companionPath: string): string {
|
|
19
|
+
return path.join(companionPath, ".agents", "skills");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getLegacyOpenCodeSkillsDir(companionPath: string): string {
|
|
23
|
+
return path.join(companionPath, ".opencode", "skills");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Mate owns skill-root entries only by name; the directory itself is never listed or wiped. */
|
|
27
|
+
async function removeSkillNames(
|
|
28
|
+
skillsDir: string,
|
|
29
|
+
names: readonly string[],
|
|
30
|
+
companionPath: string,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
await fs.rm(path.join(skillsDir, name), { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
await pruneEmptyAncestors(skillsDir, companionPath);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function removeSharedSkills(
|
|
39
|
+
companionPath: string,
|
|
40
|
+
names: readonly string[],
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
await removeSkillNames(getSharedSkillsDir(companionPath), names, companionPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function removeLegacyOpenCodeSkills(
|
|
46
|
+
companionPath: string,
|
|
47
|
+
names: readonly string[],
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
await removeSkillNames(getLegacyOpenCodeSkillsDir(companionPath), names, companionPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SharedSkillTreeState {
|
|
53
|
+
/** Capability enabled and the reconciling runtime active. */
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
/** Capability enabled regardless of runtime; separates disable from runtime deselect. */
|
|
56
|
+
capabilityEnabled: boolean;
|
|
57
|
+
activeProviders: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reconcile one declared skill tree into the shared root. A deselected runtime
|
|
62
|
+
* keeps the tree while another reading runtime is still active.
|
|
63
|
+
*/
|
|
64
|
+
export async function reconcileSharedSkillTree(
|
|
65
|
+
companionPath: string,
|
|
66
|
+
skillTree: SkillTreeContribution,
|
|
67
|
+
state: SharedSkillTreeState,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
await removeLegacyOpenCodeSkills(companionPath, [skillTree.name]);
|
|
70
|
+
if (state.enabled) {
|
|
71
|
+
await mergeDir(
|
|
72
|
+
skillTree.sourceDir,
|
|
73
|
+
path.join(getSharedSkillsDir(companionPath), skillTree.name),
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (state.capabilityEnabled && isSharedSkillRootActive(state.activeProviders)) return;
|
|
78
|
+
await removeSharedSkills(companionPath, [skillTree.name]);
|
|
79
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../../../lib/opencode-plugin-package";
|
|
12
12
|
import { refreshFromTemplate, stripGuidanceBlock } from "../plugins/guidance";
|
|
13
13
|
import { stripSectionFromFile, type RemoveHeadingSectionOptions } from "./agent-file-sections";
|
|
14
|
+
import { reconcileSharedSkillTree } from "./agents-skill-root";
|
|
14
15
|
import { patchSkillTreeMarkdownFiles } from "./skill-tree";
|
|
15
16
|
import {
|
|
16
17
|
instructionBlockKey,
|
|
@@ -21,7 +22,7 @@ import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "
|
|
|
21
22
|
import { surfaceRoot } from "../surface-target";
|
|
22
23
|
import { resolvePreinstalledPluginReference } from "../../../lib/preinstalled-plugins";
|
|
23
24
|
import { getCurrentVersion } from "../../../lib/update-checker";
|
|
24
|
-
import {
|
|
25
|
+
import { pruneEmptyAncestors } from "../utils";
|
|
25
26
|
import {
|
|
26
27
|
getOpenCodePluginReferences,
|
|
27
28
|
isRecord,
|
|
@@ -465,7 +466,7 @@ export async function removeOpenCodeForeignPluginReferences(
|
|
|
465
466
|
// Runtime Surface reconciliation: apply/remove declared Capability
|
|
466
467
|
// contributions (spec: runtime-surface). Managed identity: named MCP servers,
|
|
467
468
|
// `isManagedReference` for plugin entries, managed guidance blocks, and named
|
|
468
|
-
// skill trees.
|
|
469
|
+
// skill trees (written to the shared `.agents/skills` root).
|
|
469
470
|
// ---------------------------------------------------------------------------
|
|
470
471
|
|
|
471
472
|
const OPENCODE_CONTRIBUTION_CONFIG_FILES = ["opencode.json"];
|
|
@@ -530,13 +531,11 @@ export async function reconcileOpenCodeContributions(
|
|
|
530
531
|
}
|
|
531
532
|
|
|
532
533
|
for (const skillTree of input.contributions.skillTrees ?? []) {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
await pruneEmptyAncestors(path.join(root, ".opencode", "skills"), root);
|
|
539
|
-
}
|
|
534
|
+
await reconcileSharedSkillTree(root, skillTree, {
|
|
535
|
+
enabled: input.enabled,
|
|
536
|
+
capabilityEnabled: input.capabilityEnabled ?? false,
|
|
537
|
+
activeProviders: ctx.activeProviders,
|
|
538
|
+
});
|
|
540
539
|
}
|
|
541
540
|
}
|
|
542
541
|
}
|