@f5-sales-demo/xcsh 19.75.0 → 19.76.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 +8 -8
- package/src/discovery/helpers.ts +61 -0
- package/src/discovery/plugin-summaries.test.ts +83 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/prompts/system/system-prompt.md +4 -1
- package/src/system-prompt.plugin-pointer.test.ts +36 -18
- package/src/system-prompt.ts +17 -12
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.76.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
58
58
|
"@mozilla/readability": "^0.6",
|
|
59
|
-
"@f5-sales-demo/xcsh-stats": "19.
|
|
60
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
61
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
62
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
63
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
64
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
65
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
59
|
+
"@f5-sales-demo/xcsh-stats": "19.76.0",
|
|
60
|
+
"@f5-sales-demo/pi-agent-core": "19.76.0",
|
|
61
|
+
"@f5-sales-demo/pi-ai": "19.76.0",
|
|
62
|
+
"@f5-sales-demo/pi-natives": "19.76.0",
|
|
63
|
+
"@f5-sales-demo/pi-resource-management": "19.76.0",
|
|
64
|
+
"@f5-sales-demo/pi-tui": "19.76.0",
|
|
65
|
+
"@f5-sales-demo/pi-utils": "19.76.0",
|
|
66
66
|
"@sinclair/typebox": "^0.34",
|
|
67
67
|
"@xterm/headless": "^6.0",
|
|
68
68
|
"ajv": "^8.20",
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -856,6 +856,67 @@ export async function listXcshPluginRoots(
|
|
|
856
856
|
return result;
|
|
857
857
|
}
|
|
858
858
|
|
|
859
|
+
export interface XcshPluginSummary {
|
|
860
|
+
/** Registry id (root.plugin) — the key the `xcsh://plugin/<id>` resolver matches on. */
|
|
861
|
+
id: string;
|
|
862
|
+
/** Display name from the plugin's own manifest (falls back to the registry id). */
|
|
863
|
+
name: string;
|
|
864
|
+
description: string;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Sanitize a manifest/package description for single-line rendering in the system prompt.
|
|
869
|
+
* Collapses whitespace runs to single spaces, trims, and caps length (appending "…" if truncated).
|
|
870
|
+
* Defense-in-depth: the value is rendered with `noEscape:true`, so a newline would break the
|
|
871
|
+
* single-line bullet and an oversized description would bloat the prompt.
|
|
872
|
+
*/
|
|
873
|
+
function sanitizePluginDescription(description: unknown): string {
|
|
874
|
+
if (typeof description !== "string") return "";
|
|
875
|
+
const collapsed = description.replace(/\s+/g, " ").trim();
|
|
876
|
+
return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Read a plugin's human name+description from its own manifest. Fail-safe: never throws. */
|
|
880
|
+
export async function readPluginSummary(root: { plugin: string; path: string }): Promise<XcshPluginSummary> {
|
|
881
|
+
// Precedence mirrors MarketplaceManager#resolvePluginVersion.
|
|
882
|
+
const candidates: Array<{ rel: string; pick: (m: any) => { name?: string; description?: string } }> = [
|
|
883
|
+
{ rel: ".xcsh-plugin/plugin.json", pick: m => ({ name: m?.name, description: m?.description }) },
|
|
884
|
+
{
|
|
885
|
+
rel: "package.json",
|
|
886
|
+
pick: m => ({ name: m?.xcsh?.name ?? m?.pi?.name, description: m?.xcsh?.description ?? m?.pi?.description }),
|
|
887
|
+
},
|
|
888
|
+
];
|
|
889
|
+
for (const c of candidates) {
|
|
890
|
+
try {
|
|
891
|
+
const m = await Bun.file(path.join(root.path, c.rel)).json();
|
|
892
|
+
const { name, description } = c.pick(m);
|
|
893
|
+
if (name || description) {
|
|
894
|
+
return { id: root.plugin, name: name ?? root.plugin, description: sanitizePluginDescription(description) };
|
|
895
|
+
}
|
|
896
|
+
} catch {
|
|
897
|
+
// try next candidate
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return { id: root.plugin, name: root.plugin, description: "" };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** All installed plugins as {name, description}, sorted by name, deduped by plugin id. */
|
|
904
|
+
export async function listXcshPluginSummaries(home: string, cwd?: string): Promise<XcshPluginSummary[]> {
|
|
905
|
+
try {
|
|
906
|
+
const { roots } = await listXcshPluginRoots(home, cwd);
|
|
907
|
+
const seen = new Set<string>();
|
|
908
|
+
const out: XcshPluginSummary[] = [];
|
|
909
|
+
for (const r of roots) {
|
|
910
|
+
if (seen.has(r.plugin)) continue;
|
|
911
|
+
seen.add(r.plugin);
|
|
912
|
+
out.push(await readPluginSummary(r));
|
|
913
|
+
}
|
|
914
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
915
|
+
} catch {
|
|
916
|
+
return [];
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
859
920
|
/**
|
|
860
921
|
* Clear the plugin roots cache (useful for testing or when plugins change).
|
|
861
922
|
*/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readPluginSummary } from "./helpers";
|
|
6
|
+
|
|
7
|
+
async function mkRoot(files: Record<string, unknown>): Promise<string> {
|
|
8
|
+
const dir = await mkdtemp(join(tmpdir(), "xps-"));
|
|
9
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
10
|
+
const p = join(dir, rel);
|
|
11
|
+
await mkdir(join(p, ".."), { recursive: true });
|
|
12
|
+
await writeFile(p, JSON.stringify(content));
|
|
13
|
+
}
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("readPluginSummary", () => {
|
|
18
|
+
test("reads name+description from .xcsh-plugin/plugin.json", async () => {
|
|
19
|
+
const path = await mkRoot({
|
|
20
|
+
".xcsh-plugin/plugin.json": { name: "meddpicc", description: "MEDDPICC framework" },
|
|
21
|
+
});
|
|
22
|
+
expect(await readPluginSummary({ plugin: "meddpicc", path })).toEqual({
|
|
23
|
+
id: "meddpicc",
|
|
24
|
+
name: "meddpicc",
|
|
25
|
+
description: "MEDDPICC framework",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
test("falls back to package.json xcsh field", async () => {
|
|
29
|
+
const path = await mkRoot({ "package.json": { xcsh: { name: "foo", description: "Foo tool" } } });
|
|
30
|
+
expect(await readPluginSummary({ plugin: "foo", path })).toEqual({
|
|
31
|
+
id: "foo",
|
|
32
|
+
name: "foo",
|
|
33
|
+
description: "Foo tool",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
test("falls back to package.json pi field", async () => {
|
|
37
|
+
const path = await mkRoot({ "package.json": { pi: { name: "legacy", description: "Legacy tool" } } });
|
|
38
|
+
expect(await readPluginSummary({ plugin: "legacy", path })).toEqual({
|
|
39
|
+
id: "legacy",
|
|
40
|
+
name: "legacy",
|
|
41
|
+
description: "Legacy tool",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
test("no manifest → name from root.plugin, empty description", async () => {
|
|
45
|
+
const path = await mkRoot({});
|
|
46
|
+
expect(await readPluginSummary({ plugin: "bare", path })).toEqual({
|
|
47
|
+
id: "bare",
|
|
48
|
+
name: "bare",
|
|
49
|
+
description: "",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
test("manifest without name → falls back to root.plugin", async () => {
|
|
53
|
+
const path = await mkRoot({ ".xcsh-plugin/plugin.json": { description: "desc only" } });
|
|
54
|
+
expect(await readPluginSummary({ plugin: "baz", path })).toEqual({
|
|
55
|
+
id: "baz",
|
|
56
|
+
name: "baz",
|
|
57
|
+
description: "desc only",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
test("id is the registry id even when the manifest name differs", async () => {
|
|
61
|
+
const path = await mkRoot({
|
|
62
|
+
".xcsh-plugin/plugin.json": { name: "Display Name", description: "mismatched name" },
|
|
63
|
+
});
|
|
64
|
+
expect(await readPluginSummary({ plugin: "registry-id", path })).toEqual({
|
|
65
|
+
id: "registry-id",
|
|
66
|
+
name: "Display Name",
|
|
67
|
+
description: "mismatched name",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
test("multi-line / oversized description is collapsed to one line and capped with …", async () => {
|
|
71
|
+
const longDescription = `line one\nline two\t${"x".repeat(400)}`;
|
|
72
|
+
const path = await mkRoot({
|
|
73
|
+
".xcsh-plugin/plugin.json": { name: "big", description: longDescription },
|
|
74
|
+
});
|
|
75
|
+
const summary = await readPluginSummary({ plugin: "big", path });
|
|
76
|
+
expect(summary.id).toBe("big");
|
|
77
|
+
expect(summary.description).not.toContain("\n");
|
|
78
|
+
expect(summary.description).not.toContain("\t");
|
|
79
|
+
expect(summary.description.startsWith("line one line two ")).toBe(true);
|
|
80
|
+
expect(summary.description.length).toBe(301); // 300 chars + the "…" ellipsis
|
|
81
|
+
expect(summary.description.endsWith("…")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.76.0",
|
|
21
|
+
"commit": "2e4e3b0d461f6dc353419167c54b0da3c7310765",
|
|
22
|
+
"shortCommit": "2e4e3b0",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.76.0",
|
|
25
|
+
"commitDate": "2026-07-22T16:43:35Z",
|
|
26
|
+
"buildDate": "2026-07-22T17:06:30.694Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/2e4e3b0d461f6dc353419167c54b0da3c7310765",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.76.0"
|
|
33
33
|
};
|
|
@@ -307,7 +307,10 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
|
|
|
307
307
|
- `xcsh://console/<resource>/<operation>` — the exact ordered UI steps (selectors) for that operation.
|
|
308
308
|
- `xcsh://extension` — Chrome extension bridge tool API reference: which tool to use (click, typeahead, input, navigation) for each automation task.
|
|
309
309
|
{{#if hasPlugins}}
|
|
310
|
-
Installed plugins expose capabilities, schemas, and executable helpers on demand.
|
|
310
|
+
**Installed plugins** expose domain capabilities, schemas, and executable helpers on demand. When a task falls within a plugin's domain, consult that plugin: read `xcsh://plugin/<name>` for its summary, then **run its engine/helpers to produce any computed, scored, ranked, or "what to do next" result** rather than deriving it yourself or reading it from a data file — a plugin's engine is the source of truth, and values already written into an artifact may be stale or wrong:
|
|
311
|
+
{{#each plugins}}
|
|
312
|
+
- **{{name}}** — {{description}} → `xcsh://plugin/{{id}}`
|
|
313
|
+
{{/each}}
|
|
311
314
|
{{/if}}
|
|
312
315
|
|
|
313
316
|
### Presentation profile
|
|
@@ -3,36 +3,54 @@ import * as path from "node:path";
|
|
|
3
3
|
import { prompt } from "@f5-sales-demo/pi-utils";
|
|
4
4
|
import { registerCodingAgentPromptHelpers } from "./config/prompt-templates";
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// Technique A of the MEDDPICC progressive-hints ladder: a generic, framework-agnostic
|
|
7
|
+
// "Installed Plugins" capability index. When ≥1 plugin is present, the template enumerates
|
|
8
|
+
// each installed plugin by name + own-manifest description + `xcsh://plugin/<name>` pointer,
|
|
9
|
+
// so the agent reliably consults the plugin instead of answering from memory. It is gated on
|
|
10
|
+
// the `hasPlugins` render variable and iterates the `plugins` array that `buildSystemPrompt`
|
|
11
|
+
// computes from discovered plugin summaries.
|
|
10
12
|
//
|
|
11
|
-
// Seam: the Handlebars-compile path (`prompt.render(template, data)`) — the same
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// true-case fixture through it would be non-deterministic.
|
|
13
|
+
// Seam: the Handlebars-compile path (`prompt.render(template, data)`) — the same seam every
|
|
14
|
+
// other conditional-rendering assertion in this package uses. It is deterministic (no
|
|
15
|
+
// dependency on what is installed under the real home dir) and exercises the exact template
|
|
16
|
+
// block this task renders. `buildSystemPrompt` resolves plugins from `os.homedir()` and takes
|
|
17
|
+
// no `home` override, so a true-case fixture through it would be non-deterministic.
|
|
17
18
|
|
|
18
19
|
const systemPromptPath = path.resolve(import.meta.dir, "prompts/system/system-prompt.md");
|
|
19
20
|
|
|
20
|
-
describe("
|
|
21
|
+
describe("Installed Plugins index (technique A)", () => {
|
|
21
22
|
beforeAll(() => {
|
|
22
23
|
registerCodingAgentPromptHelpers();
|
|
23
24
|
});
|
|
24
25
|
|
|
25
|
-
test("
|
|
26
|
+
test("enumerates installed plugins with pointer, names no plugin in the core template", async () => {
|
|
26
27
|
const template = await Bun.file(systemPromptPath).text();
|
|
27
|
-
const rendered = prompt.render(template, {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const rendered = prompt.render(template, {
|
|
29
|
+
hasPlugins: true,
|
|
30
|
+
plugins: [{ id: "meddpicc", name: "meddpicc", description: "MEDDPICC qualification helper" }],
|
|
31
|
+
});
|
|
32
|
+
expect(rendered).toContain("Installed plugins");
|
|
33
|
+
expect(rendered).toContain("xcsh://plugin/meddpicc");
|
|
34
|
+
expect(rendered).toContain("MEDDPICC qualification helper");
|
|
31
35
|
});
|
|
32
36
|
|
|
33
|
-
test("
|
|
37
|
+
test("pointer uses the registry id while the label uses the manifest name", async () => {
|
|
34
38
|
const template = await Bun.file(systemPromptPath).text();
|
|
35
|
-
const rendered = prompt.render(template, {
|
|
39
|
+
const rendered = prompt.render(template, {
|
|
40
|
+
hasPlugins: true,
|
|
41
|
+
plugins: [{ id: "registry-id", name: "Display Name", description: "mismatched name and id" }],
|
|
42
|
+
});
|
|
43
|
+
// POINTER must resolve against the registry id, not the display name.
|
|
44
|
+
expect(rendered).toContain("xcsh://plugin/registry-id");
|
|
45
|
+
expect(rendered).not.toContain("xcsh://plugin/Display Name");
|
|
46
|
+
// DISPLAY label keeps the manifest name.
|
|
47
|
+
expect(rendered).toContain("**Display Name**");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("omits the block entirely when no plugins are present", async () => {
|
|
51
|
+
const template = await Bun.file(systemPromptPath).text();
|
|
52
|
+
const rendered = prompt.render(template, { hasPlugins: false, plugins: [] });
|
|
53
|
+
expect(rendered).not.toContain("Installed plugins");
|
|
36
54
|
expect(rendered).not.toContain("xcsh://plugin");
|
|
37
55
|
});
|
|
38
56
|
});
|
package/src/system-prompt.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { systemPromptCapability } from "./capability/system-prompt";
|
|
|
13
13
|
import type { SkillsSettings } from "./config/settings";
|
|
14
14
|
import { renderDeprecationGuardrails } from "./deprecations";
|
|
15
15
|
import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
|
|
16
|
-
import {
|
|
16
|
+
import { listXcshPluginSummaries, type XcshPluginSummary } from "./discovery/helpers";
|
|
17
17
|
import { isApplicableToContext, loadSkills, type Skill } from "./extensibility/skills";
|
|
18
18
|
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
|
|
19
19
|
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
|
|
@@ -556,12 +556,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
556
556
|
? loadSkills({ ...mergedSkillsSettings, cwd: resolvedCwd }).then(result => result.skills)
|
|
557
557
|
: Promise.resolve([]);
|
|
558
558
|
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
const
|
|
563
|
-
.
|
|
564
|
-
|
|
559
|
+
// Installed-plugins capability index: enumerate installed plugins (name + own-manifest
|
|
560
|
+
// description + xcsh://plugin/<name> pointer) so the agent reliably consults them. Discovery
|
|
561
|
+
// is cached; fails safe to [] (no block) on error.
|
|
562
|
+
const pluginSummariesPromise: Promise<XcshPluginSummary[]> = listXcshPluginSummaries(
|
|
563
|
+
os.homedir(),
|
|
564
|
+
resolvedCwd,
|
|
565
|
+
).catch(() => []);
|
|
565
566
|
|
|
566
567
|
return Promise.all([
|
|
567
568
|
resolvePromptInput(customPrompt, "system prompt"),
|
|
@@ -570,7 +571,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
570
571
|
contextFilesPromise,
|
|
571
572
|
agentsMdSearchPromise,
|
|
572
573
|
skillsPromise,
|
|
573
|
-
|
|
574
|
+
pluginSummariesPromise,
|
|
574
575
|
]).then(
|
|
575
576
|
([
|
|
576
577
|
resolvedCustomPrompt,
|
|
@@ -579,7 +580,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
579
580
|
contextFiles,
|
|
580
581
|
agentsMdSearch,
|
|
581
582
|
skills,
|
|
582
|
-
|
|
583
|
+
pluginSummaries,
|
|
583
584
|
]) => ({
|
|
584
585
|
resolvedCustomPrompt,
|
|
585
586
|
resolvedAppendPrompt,
|
|
@@ -587,7 +588,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
587
588
|
contextFiles,
|
|
588
589
|
agentsMdSearch,
|
|
589
590
|
skills,
|
|
590
|
-
|
|
591
|
+
pluginSummaries,
|
|
591
592
|
}),
|
|
592
593
|
);
|
|
593
594
|
})();
|
|
@@ -612,7 +613,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
612
613
|
files: [],
|
|
613
614
|
};
|
|
614
615
|
let skills: Skill[] = providedSkills ?? [];
|
|
615
|
-
let
|
|
616
|
+
let pluginSummaries: XcshPluginSummary[] = [];
|
|
616
617
|
|
|
617
618
|
if (prepResult.type === "timeout") {
|
|
618
619
|
logger.warn("System prompt preparation timed out; using minimal startup context", {
|
|
@@ -635,9 +636,12 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
635
636
|
contextFiles = dedupeExactContextFiles(prepResult.value.contextFiles);
|
|
636
637
|
agentsMdSearch = prepResult.value.agentsMdSearch;
|
|
637
638
|
skills = prepResult.value.skills;
|
|
638
|
-
|
|
639
|
+
pluginSummaries = prepResult.value.pluginSummaries;
|
|
639
640
|
}
|
|
640
641
|
|
|
642
|
+
const plugins = pluginSummaries;
|
|
643
|
+
const hasPlugins = plugins.length > 0;
|
|
644
|
+
|
|
641
645
|
const date = new Date().toISOString().slice(0, 10);
|
|
642
646
|
const dateTime = date;
|
|
643
647
|
const promptCwd = resolvedCwd.replace(/\\/g, "/");
|
|
@@ -692,6 +696,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
692
696
|
agentsMdSearch,
|
|
693
697
|
skills: contextFilteredSkills,
|
|
694
698
|
rules: rules ?? [],
|
|
699
|
+
plugins,
|
|
695
700
|
hasPlugins,
|
|
696
701
|
alwaysApplyRules: injectedAlwaysApplyRules,
|
|
697
702
|
date,
|