skill-family-engineering-kit 0.2.0 → 0.3.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/CHANGELOG.md +43 -0
- package/CHANGELOG.zh-CN.md +43 -0
- package/NOTICE +10 -0
- package/README.md +160 -55
- package/README.zh-CN.md +210 -0
- package/candidate/index.mjs +4 -0
- package/candidate/profile-bundle.mjs +1150 -0
- package/candidate/projection-bundle-cli.mjs +64 -0
- package/candidate/skill-naming-cli.mjs +59 -0
- package/candidate/skill-naming-policy.json +46 -0
- package/candidate/skill-naming.mjs +280 -0
- package/docs/404.html +1434 -408
- package/docs/agents/architecture-routing/index.html +1944 -0
- package/docs/agents/capability-catalog.en.json +1564 -0
- package/docs/agents/capability-catalog.json +935 -0
- package/docs/agents/capability-catalog.schema.json +179 -0
- package/docs/agents/capability-catalog.zh-CN.json +1564 -0
- package/docs/agents/index.html +1914 -0
- package/docs/architecture/index.html +1706 -497
- package/docs/assets/javascripts/lunr/tinyseg.js +2 -2
- package/docs/assets/javascripts/lunr/wordcut.js +37 -37
- package/docs/en/agents/architecture-routing/index.html +1944 -0
- package/docs/en/agents/index.html +1925 -0
- package/docs/en/architecture/index.html +2187 -0
- package/docs/en/examples-and-fixtures/index.html +1888 -0
- package/docs/en/help/index.html +2035 -0
- package/docs/en/index.html +1895 -0
- package/docs/en/licensing/index.html +1917 -0
- package/docs/en/migration/index.html +2335 -0
- package/docs/en/quickstart/index.html +1919 -0
- package/docs/en/recipes/adapter-text-closure/index.html +2000 -0
- package/docs/en/recipes/adopt-existing-repository/index.html +1987 -0
- package/docs/en/recipes/deterministic-human-report/index.html +1998 -0
- package/docs/en/recipes/domain-schema-validation/index.html +2009 -0
- package/docs/en/recipes/durable-local-state/index.html +2009 -0
- package/docs/en/recipes/host-profile-integration/index.html +2001 -0
- package/docs/en/recipes/index.html +1877 -0
- package/docs/en/recipes/safe-filesystem-and-atomic-write/index.html +1994 -0
- package/docs/en/reference/api/index.html +1941 -0
- package/docs/en/reference/compatibility/index.html +2036 -0
- package/docs/en/reference/failure-and-side-effect-matrix/index.html +2216 -0
- package/docs/examples-and-fixtures/index.html +1871 -0
- package/docs/git-lifecycle/index.html +1508 -482
- package/docs/help/index.html +1655 -554
- package/docs/index.html +1479 -450
- package/docs/integration/audit/failure-evidence/index.html +1488 -462
- package/docs/integration/audit/independence/index.html +1513 -487
- package/docs/integration/audit/index.html +1514 -488
- package/docs/integration/audit/mutation-taxonomy/index.html +1548 -522
- package/docs/integration/audit/version-compatibility/index.html +1489 -463
- package/docs/licensing/index.html +1917 -0
- package/docs/migration/index.html +1679 -586
- package/docs/public/status/index.html +1542 -516
- package/docs/quickstart/index.html +1589 -539
- package/docs/recipes/adapter-text-closure/index.html +2000 -0
- package/docs/recipes/adopt-existing-repository/index.html +1987 -0
- package/docs/recipes/deterministic-human-report/index.html +1998 -0
- package/docs/recipes/domain-schema-validation/index.html +2009 -0
- package/docs/recipes/durable-local-state/index.html +2009 -0
- package/docs/recipes/host-profile-integration/index.html +2001 -0
- package/docs/recipes/index.html +1877 -0
- package/docs/recipes/safe-filesystem-and-atomic-write/index.html +1994 -0
- package/docs/reference/api/contracts/index.html +2558 -0
- package/docs/reference/api/engineering-kit/index.html +2577 -0
- package/docs/reference/api/harness/index.html +2634 -0
- package/docs/reference/api/index.html +1881 -0
- package/docs/reference/compatibility/index.html +2036 -0
- package/docs/reference/failure-and-side-effect-matrix/index.html +2216 -0
- package/docs/search/search_index.json +1 -1
- package/docs/setup/index.html +1494 -468
- package/docs/sitemap.xml +152 -0
- package/package.json +16 -5
- package/release-notes/0.2.1.yaml +23 -0
- package/release-notes/0.3.0.yaml +25 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildQuickstartProfileProjection } from "./profile-bundle.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build-time CLI for the Quickstart Profile v2 offline bundle. It only emits
|
|
7
|
+
* the projection manifest on stdout; diagnostics go to stderr and every
|
|
8
|
+
* argument or build failure exits 2. Writing stays with runProjection.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const USAGE = [
|
|
12
|
+
"usage: projection-bundle-cli.mjs",
|
|
13
|
+
" [--target-prefix <relative-path>]",
|
|
14
|
+
" --consumer-schema-root <absolute-build-time-root>",
|
|
15
|
+
" --consumer-schema <relative-path> (repeatable)",
|
|
16
|
+
" --source-repository <identity>",
|
|
17
|
+
" --source-base-commit <identity>",
|
|
18
|
+
].join("\n");
|
|
19
|
+
|
|
20
|
+
function fail(message) {
|
|
21
|
+
process.stderr.write(`${message}\n${USAGE}\n`);
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const options = { consumerSchemaPaths: [] };
|
|
27
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
28
|
+
const flag = args[index];
|
|
29
|
+
const next = args[index + 1];
|
|
30
|
+
switch (flag) {
|
|
31
|
+
case "--target-prefix":
|
|
32
|
+
case "--consumer-schema-root":
|
|
33
|
+
case "--consumer-schema":
|
|
34
|
+
case "--source-repository":
|
|
35
|
+
case "--source-base-commit":
|
|
36
|
+
if (next === undefined) fail(`missing value for ${flag}`);
|
|
37
|
+
index += 1;
|
|
38
|
+
if (flag === "--consumer-schema") options.consumerSchemaPaths.push(next);
|
|
39
|
+
else if (flag === "--target-prefix") options.targetPrefix = next;
|
|
40
|
+
else if (flag === "--consumer-schema-root") options.consumerSchemaRoot = next;
|
|
41
|
+
else if (flag === "--source-repository") options.sourceRepository = next;
|
|
42
|
+
else options.sourceBaseCommit = next;
|
|
43
|
+
break;
|
|
44
|
+
default:
|
|
45
|
+
fail(`unknown argument: ${flag}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!options.consumerSchemaRoot) fail("--consumer-schema-root is required");
|
|
49
|
+
if (!path.isAbsolute(options.consumerSchemaRoot)) {
|
|
50
|
+
fail("--consumer-schema-root must be an absolute directory");
|
|
51
|
+
}
|
|
52
|
+
if (options.consumerSchemaPaths.length === 0) {
|
|
53
|
+
fail("at least one --consumer-schema is required");
|
|
54
|
+
}
|
|
55
|
+
if (!options.sourceRepository) fail("--source-repository is required");
|
|
56
|
+
if (!options.sourceBaseCommit) fail("--source-base-commit is required");
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const { manifest } = await buildQuickstartProfileProjection(options);
|
|
60
|
+
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
61
|
+
} catch (cause) {
|
|
62
|
+
process.stderr.write(`${cause?.message ?? String(cause)}\n`);
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { checkPluginSkillNaming } from "./skill-naming.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build-time CLI for the candidate plugin skill naming check. It only emits
|
|
6
|
+
* the report on stdout; diagnostics go to stderr. Exit codes: 0 = every
|
|
7
|
+
* skill passes, 1 = at least one rule FAIL, 2 = usage or mechanism error.
|
|
8
|
+
* The CLI never writes anywhere.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const USAGE = [
|
|
12
|
+
"usage: skill-naming-cli.mjs",
|
|
13
|
+
" --skills-root <directory> (required; each immediate child with SKILL.md is one published skill)",
|
|
14
|
+
" --plugin-slug <slug> (required; the published plugin slug, required as description signal)",
|
|
15
|
+
" --name-prefix <prefix> (optional approved name prefix; defaults to the plugin slug)",
|
|
16
|
+
" --domain-signal <word> (repeatable; extra domain words accepted as description signals)",
|
|
17
|
+
" --policy <path> (optional; defaults to the bundled candidate policy)",
|
|
18
|
+
].join("\n");
|
|
19
|
+
|
|
20
|
+
function fail(message) {
|
|
21
|
+
process.stderr.write(`${message}\n${USAGE}\n`);
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const options = { domainSignals: [] };
|
|
27
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
28
|
+
const flag = args[index];
|
|
29
|
+
const next = args[index + 1];
|
|
30
|
+
switch (flag) {
|
|
31
|
+
case "--skills-root":
|
|
32
|
+
case "--plugin-slug":
|
|
33
|
+
case "--name-prefix":
|
|
34
|
+
case "--domain-signal":
|
|
35
|
+
case "--policy":
|
|
36
|
+
if (next === undefined) fail(`missing value for ${flag}`);
|
|
37
|
+
index += 1;
|
|
38
|
+
if (flag === "--skills-root") options.skillsRoot = next;
|
|
39
|
+
else if (flag === "--plugin-slug") options.pluginSlug = next;
|
|
40
|
+
else if (flag === "--name-prefix") options.namePrefix = next;
|
|
41
|
+
else if (flag === "--domain-signal") options.domainSignals.push(next);
|
|
42
|
+
else options.policyPath = next;
|
|
43
|
+
break;
|
|
44
|
+
default:
|
|
45
|
+
fail(`unknown argument: ${flag}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!options.skillsRoot) fail("--skills-root is required");
|
|
49
|
+
if (!options.pluginSlug) fail("--plugin-slug is required");
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const report = await checkPluginSkillNaming(options);
|
|
53
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
54
|
+
process.exit(report.ok ? 0 : 1);
|
|
55
|
+
} catch (cause) {
|
|
56
|
+
const kind = cause && cause.details && cause.details.kind ? ` (${cause.details.kind})` : "";
|
|
57
|
+
process.stderr.write(`${cause?.code ?? "SFC2004"}${kind}: ${cause?.message ?? String(cause)}\n`);
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"kind": "skill-family.skill-naming-policy",
|
|
4
|
+
"policyVersion": "1.0.0",
|
|
5
|
+
"status": "candidate",
|
|
6
|
+
"summary": "Plugin skill naming and description policy: every published skill name carries the plugin prefix, every frontmatter description carries a domain signal, and routing entry skills declare plugin-internal routing scope.",
|
|
7
|
+
"pluginSlugPattern": "^[a-z][a-z0-9-]*$",
|
|
8
|
+
"skillName": {
|
|
9
|
+
"ruleId": "SNR-001",
|
|
10
|
+
"summary": "Every published skill name must be <plugin-slug>-<suffix>; bare generic names are forbidden.",
|
|
11
|
+
"suffixPattern": "[a-z0-9][a-z0-9-]*",
|
|
12
|
+
"reservedBareNames": ["help", "setup", "quickstart", "where-am-i"],
|
|
13
|
+
"additionalApprovedPatterns": [],
|
|
14
|
+
"note": "Equivalent namespace forms are only valid when their regular expression is explicitly approved and registered in additionalApprovedPatterns; a reserved bare name stays forbidden even then."
|
|
15
|
+
},
|
|
16
|
+
"description": {
|
|
17
|
+
"ruleId": "SNR-002",
|
|
18
|
+
"summary": "The frontmatter description must contain a domain signal (the plugin slug or a declared domain word) and must not consist of a bare global trigger phrase.",
|
|
19
|
+
"forbiddenGlobalTriggers": [
|
|
20
|
+
"what can this plugin do",
|
|
21
|
+
"what can you do",
|
|
22
|
+
"how to start",
|
|
23
|
+
"how do i start",
|
|
24
|
+
"is my environment ready",
|
|
25
|
+
"你能做什么",
|
|
26
|
+
"怎么开始",
|
|
27
|
+
"我的环境准备好了吗"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"routingEntry": {
|
|
31
|
+
"ruleId": "SNR-003",
|
|
32
|
+
"summary": "A routing entry skill must name its plugin slug in the description (plugin-internal routing scope) and must not imply global arbitration.",
|
|
33
|
+
"suffixes": ["help", "quickstart", "where-am-i"],
|
|
34
|
+
"forbiddenGlobalScopePhrases": [
|
|
35
|
+
"across all plugins",
|
|
36
|
+
"all installed plugins",
|
|
37
|
+
"global routing",
|
|
38
|
+
"global arbitration",
|
|
39
|
+
"routes every plugin",
|
|
40
|
+
"全局路由",
|
|
41
|
+
"全局仲裁",
|
|
42
|
+
"所有插件",
|
|
43
|
+
"跨插件仲裁"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Candidate plugin skill naming checker (unstable).
|
|
7
|
+
*
|
|
8
|
+
* Mechanically evaluates the cross-repository skill naming and description
|
|
9
|
+
* policy (candidate/skill-naming-policy.json) over one plugin skills root:
|
|
10
|
+
* every immediate subdirectory that carries a SKILL.md is one published
|
|
11
|
+
* skill, and its frontmatter `name` / `description` are checked against the
|
|
12
|
+
* three frozen rules:
|
|
13
|
+
*
|
|
14
|
+
* SNR-001 name prefix — name must be `<plugin-slug>-<suffix>`
|
|
15
|
+
* (or match an explicitly approved extra
|
|
16
|
+
* pattern); reserved bare names are always
|
|
17
|
+
* forbidden;
|
|
18
|
+
* SNR-002 description signal — description must contain the plugin slug
|
|
19
|
+
* or a caller-declared domain word, and must
|
|
20
|
+
* not consist of a bare global trigger
|
|
21
|
+
* phrase;
|
|
22
|
+
* SNR-003 routing scope — routing entry skills (policy suffixes)
|
|
23
|
+
* must name the plugin slug in the
|
|
24
|
+
* description and must not imply global
|
|
25
|
+
* arbitration.
|
|
26
|
+
*
|
|
27
|
+
* The module only reads; it never writes, never consults the network, the
|
|
28
|
+
* clock, or Git. Every rule outcome is reported per skill as PASS or FAIL;
|
|
29
|
+
* the report's `ok` is the AND of all rule outcomes.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const SKILL_NAMING_POLICY_PATH = fileURLToPath(
|
|
33
|
+
new URL("./skill-naming-policy.json", import.meta.url),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export const SKILL_NAMING_RULE_IDS = Object.freeze(["SNR-001", "SNR-002", "SNR-003"]);
|
|
37
|
+
|
|
38
|
+
function namingError(kind, message, details) {
|
|
39
|
+
const error = new Error(message);
|
|
40
|
+
error.code = "SFC2004";
|
|
41
|
+
error.details = { ...(details ?? {}), kind };
|
|
42
|
+
return error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function escapeRegExp(value) {
|
|
46
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function unquote(value) {
|
|
50
|
+
const trimmed = value.trim();
|
|
51
|
+
if (
|
|
52
|
+
trimmed.length >= 2 &&
|
|
53
|
+
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
54
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")))
|
|
55
|
+
) {
|
|
56
|
+
return trimmed.slice(1, -1);
|
|
57
|
+
}
|
|
58
|
+
return trimmed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Line-based frontmatter extraction (no YAML dependency): the document must
|
|
63
|
+
* open with a `---` line and close with another `---` line; `name` and
|
|
64
|
+
* `description` are top-level keys, optionally quoted, optionally continued
|
|
65
|
+
* on indented lines (plain multi-line or `|`/`>` block scalars).
|
|
66
|
+
*/
|
|
67
|
+
export function parseSkillFrontmatter(text) {
|
|
68
|
+
if (typeof text !== "string" || !text.startsWith("---\n")) {
|
|
69
|
+
throw namingError("frontmatter-missing", "SKILL.md does not start with a frontmatter fence");
|
|
70
|
+
}
|
|
71
|
+
const lines = text.split("\n");
|
|
72
|
+
const end = lines.indexOf("---", 1);
|
|
73
|
+
if (end === -1) {
|
|
74
|
+
throw namingError("frontmatter-unclosed", "SKILL.md frontmatter fence is not closed");
|
|
75
|
+
}
|
|
76
|
+
const fields = {};
|
|
77
|
+
for (let index = 1; index < end; index += 1) {
|
|
78
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*):[ \t]*(.*)$/.exec(lines[index]);
|
|
79
|
+
if (!match) continue;
|
|
80
|
+
const [, key, rawValue] = match;
|
|
81
|
+
let value = rawValue;
|
|
82
|
+
if (value === "" || value === "|" || value === ">" || value === "|-" || value === ">-") {
|
|
83
|
+
const collected = [];
|
|
84
|
+
for (let follow = index + 1; follow < end; follow += 1) {
|
|
85
|
+
const line = lines[follow];
|
|
86
|
+
if (/^[ \t]+\S/.test(line)) collected.push(line.trim());
|
|
87
|
+
else if (line.trim() === "") collected.push("");
|
|
88
|
+
else break;
|
|
89
|
+
}
|
|
90
|
+
value = collected.join(" ").trim();
|
|
91
|
+
index += collected.length;
|
|
92
|
+
}
|
|
93
|
+
fields[key] = unquote(value);
|
|
94
|
+
}
|
|
95
|
+
return { name: fields.name, description: fields.description };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Loads and shape-validates a naming policy document. */
|
|
99
|
+
export async function loadSkillNamingPolicy(policyPath) {
|
|
100
|
+
const resolved = policyPath ?? SKILL_NAMING_POLICY_PATH;
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(await readFile(resolved, "utf8"));
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
throw namingError(
|
|
106
|
+
cause && cause.code === "ENOENT" ? "policy-missing" : "policy-parse-failed",
|
|
107
|
+
`skill naming policy is not readable JSON: ${resolved}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (!parsed || parsed.kind !== "skill-family.skill-naming-policy" || parsed.schemaVersion !== 1) {
|
|
111
|
+
throw namingError("policy-invalid", "skill naming policy must carry schemaVersion 1 and kind skill-family.skill-naming-policy");
|
|
112
|
+
}
|
|
113
|
+
for (const section of ["skillName", "description", "routingEntry"]) {
|
|
114
|
+
if (!parsed[section] || typeof parsed[section].ruleId !== "string") {
|
|
115
|
+
throw namingError("policy-invalid", `skill naming policy lacks a usable ${section} section`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizePhrase(value) {
|
|
122
|
+
return String(value ?? "")
|
|
123
|
+
.toLocaleLowerCase("en-US")
|
|
124
|
+
.replace(/[\s\p{P}\p{S}]+/gu, " ")
|
|
125
|
+
.trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ruleOutcome(ruleId, ok, message) {
|
|
129
|
+
return { ruleId, status: ok ? "PASS" : "FAIL", ...(ok ? {} : { message }) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function checkNameRule(name, namePrefix, policy) {
|
|
133
|
+
const { suffixPattern, reservedBareNames, additionalApprovedPatterns } = policy.skillName;
|
|
134
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
135
|
+
return ruleOutcome("SNR-001", false, "frontmatter lacks a usable name");
|
|
136
|
+
}
|
|
137
|
+
if (reservedBareNames.includes(name)) {
|
|
138
|
+
return ruleOutcome("SNR-001", false, `reserved bare generic name is forbidden: ${name}`);
|
|
139
|
+
}
|
|
140
|
+
const prefixed = new RegExp(`^${escapeRegExp(namePrefix)}-(?:${suffixPattern})$`).test(name);
|
|
141
|
+
const approved = (Array.isArray(additionalApprovedPatterns) ? additionalApprovedPatterns : []).some(
|
|
142
|
+
(pattern) => new RegExp(pattern).test(name),
|
|
143
|
+
);
|
|
144
|
+
return prefixed || approved
|
|
145
|
+
? ruleOutcome("SNR-001", true)
|
|
146
|
+
: ruleOutcome("SNR-001", false, `name must be ${namePrefix}-<suffix> or an approved namespace form: ${name}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function checkDescriptionRule(description, slug, domainSignals, policy) {
|
|
150
|
+
if (typeof description !== "string" || description.trim().length === 0) {
|
|
151
|
+
return ruleOutcome("SNR-002", false, "frontmatter lacks a usable description");
|
|
152
|
+
}
|
|
153
|
+
const lowered = description.toLocaleLowerCase("en-US");
|
|
154
|
+
const signals = [slug, ...domainSignals].filter((signal) => typeof signal === "string" && signal.length > 0);
|
|
155
|
+
if (!signals.some((signal) => lowered.includes(signal.toLocaleLowerCase("en-US")))) {
|
|
156
|
+
return ruleOutcome(
|
|
157
|
+
"SNR-002",
|
|
158
|
+
false,
|
|
159
|
+
"description carries no domain signal (plugin slug or a declared domain word)",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const normalized = normalizePhrase(description);
|
|
163
|
+
const bareTrigger = (policy.description.forbiddenGlobalTriggers ?? []).find(
|
|
164
|
+
(trigger) => normalizePhrase(trigger) === normalized,
|
|
165
|
+
);
|
|
166
|
+
if (bareTrigger !== undefined) {
|
|
167
|
+
return ruleOutcome("SNR-002", false, `description is a bare global trigger phrase: ${bareTrigger}`);
|
|
168
|
+
}
|
|
169
|
+
return ruleOutcome("SNR-002", true);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function checkRoutingRule(name, description, slug, namePrefix, policy) {
|
|
173
|
+
const suffixes = policy.routingEntry.suffixes ?? [];
|
|
174
|
+
const prefix = `${namePrefix}-`;
|
|
175
|
+
const suffix = typeof name === "string" && name.startsWith(prefix) ? name.slice(prefix.length) : null;
|
|
176
|
+
if (suffix === null || !suffixes.includes(suffix)) {
|
|
177
|
+
return ruleOutcome("SNR-003", true); // not a routing entry skill: vacuous pass
|
|
178
|
+
}
|
|
179
|
+
const lowered = typeof description === "string" ? description.toLocaleLowerCase("en-US") : "";
|
|
180
|
+
if (!lowered.includes(slug.toLocaleLowerCase("en-US"))) {
|
|
181
|
+
return ruleOutcome(
|
|
182
|
+
"SNR-003",
|
|
183
|
+
false,
|
|
184
|
+
"routing entry skill must name the plugin slug to declare plugin-internal routing scope",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const scopePhrase = (policy.routingEntry.forbiddenGlobalScopePhrases ?? []).find((phrase) =>
|
|
188
|
+
lowered.includes(phrase.toLocaleLowerCase("en-US")),
|
|
189
|
+
);
|
|
190
|
+
if (scopePhrase !== undefined) {
|
|
191
|
+
return ruleOutcome("SNR-003", false, `routing entry skill implies global arbitration: ${scopePhrase}`);
|
|
192
|
+
}
|
|
193
|
+
return ruleOutcome("SNR-003", true);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Checks every published skill under one skills root.
|
|
198
|
+
* Options: { skillsRoot, pluginSlug, namePrefix?, domainSignals?, policyPath? }.
|
|
199
|
+
* namePrefix is the approved name prefix used by SNR-001/SNR-003 and defaults
|
|
200
|
+
* to pluginSlug; the reference implementation shows the two can differ (an
|
|
201
|
+
* approved short prefix), while SNR-002/SNR-003 still require the full plugin
|
|
202
|
+
* slug as the description signal.
|
|
203
|
+
* Returns the report document; throws a coded error only for unusable
|
|
204
|
+
* inputs (missing root, invalid slug, unreadable policy).
|
|
205
|
+
*/
|
|
206
|
+
export async function checkPluginSkillNaming({ skillsRoot, pluginSlug, namePrefix, domainSignals = [], policyPath } = {}) {
|
|
207
|
+
const policy = await loadSkillNamingPolicy(policyPath);
|
|
208
|
+
if (typeof pluginSlug !== "string" || !new RegExp(policy.pluginSlugPattern).test(pluginSlug)) {
|
|
209
|
+
throw namingError("invalid-plugin-slug", "pluginSlug must satisfy the policy plugin slug pattern", {
|
|
210
|
+
pattern: policy.pluginSlugPattern,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const effectivePrefix = namePrefix ?? pluginSlug;
|
|
214
|
+
if (typeof effectivePrefix !== "string" || !new RegExp(policy.pluginSlugPattern).test(effectivePrefix)) {
|
|
215
|
+
throw namingError("invalid-name-prefix", "namePrefix must satisfy the policy plugin slug pattern", {
|
|
216
|
+
pattern: policy.pluginSlugPattern,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (typeof skillsRoot !== "string" || skillsRoot.length === 0) {
|
|
220
|
+
throw namingError("invalid-skills-root", "skillsRoot is required");
|
|
221
|
+
}
|
|
222
|
+
const rootAbs = await realpath(path.resolve(skillsRoot)).catch(() => null);
|
|
223
|
+
if (rootAbs === null || !(await stat(rootAbs)).isDirectory()) {
|
|
224
|
+
throw namingError("invalid-skills-root", `skills root is not an existing directory: ${skillsRoot}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const directories = (await readdir(rootAbs, { withFileTypes: true }))
|
|
228
|
+
.filter((entry) => entry.isDirectory())
|
|
229
|
+
.map((entry) => entry.name)
|
|
230
|
+
.sort((left, right) => left.localeCompare(right));
|
|
231
|
+
|
|
232
|
+
const skills = [];
|
|
233
|
+
for (const directory of directories) {
|
|
234
|
+
const relPath = `${directory}/SKILL.md`;
|
|
235
|
+
let text = null;
|
|
236
|
+
try {
|
|
237
|
+
const candidate = path.join(rootAbs, directory, "SKILL.md");
|
|
238
|
+
if ((await stat(candidate)).isFile()) text = await readFile(candidate, "utf8");
|
|
239
|
+
} catch {
|
|
240
|
+
text = null;
|
|
241
|
+
}
|
|
242
|
+
if (text === null) continue; // not a published skill directory
|
|
243
|
+
let frontmatter = { name: undefined, description: undefined };
|
|
244
|
+
let parseFailure = null;
|
|
245
|
+
try {
|
|
246
|
+
frontmatter = parseSkillFrontmatter(text);
|
|
247
|
+
} catch (cause) {
|
|
248
|
+
parseFailure = cause && cause.message ? cause.message : String(cause);
|
|
249
|
+
}
|
|
250
|
+
const rules =
|
|
251
|
+
parseFailure !== null
|
|
252
|
+
? SKILL_NAMING_RULE_IDS.map((ruleId) => ruleOutcome(ruleId, false, parseFailure))
|
|
253
|
+
: [
|
|
254
|
+
checkNameRule(frontmatter.name, effectivePrefix, policy),
|
|
255
|
+
checkDescriptionRule(frontmatter.description, pluginSlug, domainSignals, policy),
|
|
256
|
+
checkRoutingRule(frontmatter.name, frontmatter.description, pluginSlug, effectivePrefix, policy),
|
|
257
|
+
];
|
|
258
|
+
skills.push({
|
|
259
|
+
directory,
|
|
260
|
+
path: relPath,
|
|
261
|
+
name: frontmatter.name ?? null,
|
|
262
|
+
rules,
|
|
263
|
+
ok: rules.every((rule) => rule.status === "PASS"),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
kind: "skill-family.skill-naming-report",
|
|
269
|
+
schemaVersion: 1,
|
|
270
|
+
policyVersion: policy.policyVersion,
|
|
271
|
+
pluginSlug,
|
|
272
|
+
namePrefix: effectivePrefix,
|
|
273
|
+
skillsRoot: rootAbs,
|
|
274
|
+
skillCount: skills.length,
|
|
275
|
+
ok: skills.every((skill) => skill.ok),
|
|
276
|
+
skills,
|
|
277
|
+
policy:
|
|
278
|
+
"skill naming check is diagnosis only: it never writes and never renames; violations must be resolved by the owning plugin",
|
|
279
|
+
};
|
|
280
|
+
}
|