@stofone/dsh-agent-roster 0.9.2 → 0.10.1

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.
Files changed (167) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +80 -5
  3. package/README.zh.md +73 -6
  4. package/bin/cli.mjs +228 -2
  5. package/docs/roster-v2.md +220 -0
  6. package/examples/dist/core-review-1.0.0.json +160 -0
  7. package/examples/dist/research-0.1.0.json +95 -0
  8. package/examples/roles/core-review-correctness/AGENT.md +20 -0
  9. package/examples/roles/core-review-lead/AGENT.md +23 -0
  10. package/examples/roles/core-review-lead-captain/AGENT.md +13 -0
  11. package/examples/roles/core-review-maintainer/AGENT.md +20 -0
  12. package/examples/roles/research-scout/AGENT.md +16 -0
  13. package/examples/roles/research-verifier/AGENT.md +16 -0
  14. package/examples/rosters/core-review/roster.yaml +39 -0
  15. package/examples/rosters/research/roster.yaml +20 -0
  16. package/examples-orchestration/dist/core-review-1.0.0.json +185 -0
  17. package/examples-orchestration/dist/core-review-1.0.0.tasks.json +76 -0
  18. package/examples-orchestration/roles/fixer/AGENT.md +18 -0
  19. package/examples-orchestration/roles/lead/AGENT.md +18 -0
  20. package/examples-orchestration/roles/reviewer/AGENT.md +23 -0
  21. package/examples-orchestration/roles/reviewer/parts/checklist.md +6 -0
  22. package/examples-orchestration/rosters/core-review/overrides/fixer.md +1 -0
  23. package/examples-orchestration/rosters/core-review/roster.yaml +43 -0
  24. package/index.js +8 -1
  25. package/lib/compile2.js +522 -0
  26. package/lib/format-v2.js +196 -0
  27. package/lib/frontmatter.js +113 -0
  28. package/lib/migrate.js +326 -0
  29. package/lib/plan.js +167 -0
  30. package/lib/registry.js +65 -11
  31. package/lib/role.js +498 -0
  32. package/lib/roster2.js +591 -0
  33. package/lib/run.js +188 -0
  34. package/lib/write.js +14 -8
  35. package/orgs/agent-roster-collective/dist/api-design-1.0.0.json +130 -0
  36. package/orgs/agent-roster-collective/dist/content-ops-1.0.0.json +130 -0
  37. package/orgs/agent-roster-collective/dist/contract-review-1.0.0.json +130 -0
  38. package/orgs/agent-roster-collective/dist/data-quality-1.0.0.json +130 -0
  39. package/orgs/agent-roster-collective/dist/db-migration-1.0.0.json +130 -0
  40. package/orgs/agent-roster-collective/dist/docs-writer-1.0.0.json +130 -0
  41. package/orgs/agent-roster-collective/dist/finance-recon-1.0.0.json +130 -0
  42. package/orgs/agent-roster-collective/dist/frontend-ui-1.0.0.json +130 -0
  43. package/orgs/agent-roster-collective/dist/i18n-l10n-1.0.0.json +130 -0
  44. package/orgs/agent-roster-collective/dist/incident-response-1.0.0.json +130 -0
  45. package/orgs/agent-roster-collective/dist/literature-review-1.0.0.json +130 -0
  46. package/orgs/agent-roster-collective/dist/market-research-1.0.0.json +130 -0
  47. package/orgs/agent-roster-collective/dist/ml-eval-1.0.0.json +130 -0
  48. package/orgs/agent-roster-collective/dist/performance-1.0.0.json +130 -0
  49. package/orgs/agent-roster-collective/dist/product-discovery-1.0.0.json +130 -0
  50. package/orgs/agent-roster-collective/dist/qa-regression-1.0.0.json +130 -0
  51. package/orgs/agent-roster-collective/dist/recruiting-1.0.0.json +130 -0
  52. package/orgs/agent-roster-collective/dist/release-manager-1.0.0.json +130 -0
  53. package/orgs/agent-roster-collective/dist/security-audit-1.0.0.json +132 -0
  54. package/orgs/agent-roster-collective/dist/sre-oncall-1.0.0.json +130 -0
  55. package/orgs/agent-roster-collective/dist/support-triage-1.0.0.json +130 -0
  56. package/orgs/agent-roster-collective/roles/api-design-failure-mode/AGENT.md +19 -0
  57. package/orgs/agent-roster-collective/roles/api-design-lead/AGENT.md +19 -0
  58. package/orgs/agent-roster-collective/roles/api-design-schema/AGENT.md +20 -0
  59. package/orgs/agent-roster-collective/roles/content-ops-distributor/AGENT.md +19 -0
  60. package/orgs/agent-roster-collective/roles/content-ops-editor/AGENT.md +19 -0
  61. package/orgs/agent-roster-collective/roles/content-ops-lead/AGENT.md +19 -0
  62. package/orgs/agent-roster-collective/roles/contract-review-clause-reader/AGENT.md +19 -0
  63. package/orgs/agent-roster-collective/roles/contract-review-lead/AGENT.md +19 -0
  64. package/orgs/agent-roster-collective/roles/contract-review-risk-note/AGENT.md +19 -0
  65. package/orgs/agent-roster-collective/roles/data-quality-lead/AGENT.md +19 -0
  66. package/orgs/agent-roster-collective/roles/data-quality-profiler/AGENT.md +19 -0
  67. package/orgs/agent-roster-collective/roles/data-quality-rule-author/AGENT.md +19 -0
  68. package/orgs/agent-roster-collective/roles/db-migration-lead/AGENT.md +19 -0
  69. package/orgs/agent-roster-collective/roles/db-migration-migrator/AGENT.md +19 -0
  70. package/orgs/agent-roster-collective/roles/db-migration-verifier/AGENT.md +19 -0
  71. package/orgs/agent-roster-collective/roles/docs-writer-example-runner/AGENT.md +19 -0
  72. package/orgs/agent-roster-collective/roles/docs-writer-lead/AGENT.md +19 -0
  73. package/orgs/agent-roster-collective/roles/docs-writer-writer/AGENT.md +19 -0
  74. package/orgs/agent-roster-collective/roles/finance-recon-exception/AGENT.md +19 -0
  75. package/orgs/agent-roster-collective/roles/finance-recon-lead/AGENT.md +19 -0
  76. package/orgs/agent-roster-collective/roles/finance-recon-matcher/AGENT.md +19 -0
  77. package/orgs/agent-roster-collective/roles/frontend-ui-a11y-reviewer/AGENT.md +19 -0
  78. package/orgs/agent-roster-collective/roles/frontend-ui-implementer/AGENT.md +19 -0
  79. package/orgs/agent-roster-collective/roles/frontend-ui-lead/AGENT.md +19 -0
  80. package/orgs/agent-roster-collective/roles/i18n-l10n-lead/AGENT.md +19 -0
  81. package/orgs/agent-roster-collective/roles/i18n-l10n-reviewer/AGENT.md +19 -0
  82. package/orgs/agent-roster-collective/roles/i18n-l10n-translator/AGENT.md +19 -0
  83. package/orgs/agent-roster-collective/roles/incident-response-commander/AGENT.md +19 -0
  84. package/orgs/agent-roster-collective/roles/incident-response-scribe/AGENT.md +19 -0
  85. package/orgs/agent-roster-collective/roles/incident-response-triage/AGENT.md +19 -0
  86. package/orgs/agent-roster-collective/roles/literature-review-critic/AGENT.md +19 -0
  87. package/orgs/agent-roster-collective/roles/literature-review-extractor/AGENT.md +19 -0
  88. package/orgs/agent-roster-collective/roles/literature-review-lead/AGENT.md +19 -0
  89. package/orgs/agent-roster-collective/roles/market-research-lead/AGENT.md +19 -0
  90. package/orgs/agent-roster-collective/roles/market-research-researcher/AGENT.md +19 -0
  91. package/orgs/agent-roster-collective/roles/market-research-synthesizer/AGENT.md +19 -0
  92. package/orgs/agent-roster-collective/roles/ml-eval-dataset/AGENT.md +19 -0
  93. package/orgs/agent-roster-collective/roles/ml-eval-lead/AGENT.md +19 -0
  94. package/orgs/agent-roster-collective/roles/ml-eval-metric/AGENT.md +19 -0
  95. package/orgs/agent-roster-collective/roles/performance-lead/AGENT.md +19 -0
  96. package/orgs/agent-roster-collective/roles/performance-load-tester/AGENT.md +19 -0
  97. package/orgs/agent-roster-collective/roles/performance-profiler/AGENT.md +19 -0
  98. package/orgs/agent-roster-collective/roles/product-discovery-interviewer/AGENT.md +19 -0
  99. package/orgs/agent-roster-collective/roles/product-discovery-lead/AGENT.md +19 -0
  100. package/orgs/agent-roster-collective/roles/product-discovery-skeptic/AGENT.md +19 -0
  101. package/orgs/agent-roster-collective/roles/qa-regression-case-writer/AGENT.md +19 -0
  102. package/orgs/agent-roster-collective/roles/qa-regression-flake-hunter/AGENT.md +19 -0
  103. package/orgs/agent-roster-collective/roles/qa-regression-lead/AGENT.md +19 -0
  104. package/orgs/agent-roster-collective/roles/recruiting-lead/AGENT.md +19 -0
  105. package/orgs/agent-roster-collective/roles/recruiting-scheduler/AGENT.md +19 -0
  106. package/orgs/agent-roster-collective/roles/recruiting-screener/AGENT.md +19 -0
  107. package/orgs/agent-roster-collective/roles/release-manager-changelog/AGENT.md +19 -0
  108. package/orgs/agent-roster-collective/roles/release-manager-lead/AGENT.md +19 -0
  109. package/orgs/agent-roster-collective/roles/release-manager-rollback/AGENT.md +19 -0
  110. package/orgs/agent-roster-collective/roles/security-audit-code-auditor/AGENT.md +19 -0
  111. package/orgs/agent-roster-collective/roles/security-audit-lead/AGENT.md +19 -0
  112. package/orgs/agent-roster-collective/roles/security-audit-threat-modeler/AGENT.md +19 -0
  113. package/orgs/agent-roster-collective/roles/sre-oncall-capacity/AGENT.md +19 -0
  114. package/orgs/agent-roster-collective/roles/sre-oncall-lead/AGENT.md +19 -0
  115. package/orgs/agent-roster-collective/roles/sre-oncall-runbook/AGENT.md +19 -0
  116. package/orgs/agent-roster-collective/roles/support-triage-classifier/AGENT.md +19 -0
  117. package/orgs/agent-roster-collective/roles/support-triage-lead/AGENT.md +19 -0
  118. package/orgs/agent-roster-collective/roles/support-triage-responder/AGENT.md +19 -0
  119. package/orgs/agent-roster-collective/rosters/api-design/roster.yaml +26 -0
  120. package/orgs/agent-roster-collective/rosters/content-ops/roster.yaml +25 -0
  121. package/orgs/agent-roster-collective/rosters/contract-review/roster.yaml +25 -0
  122. package/orgs/agent-roster-collective/rosters/data-quality/roster.yaml +26 -0
  123. package/orgs/agent-roster-collective/rosters/db-migration/roster.yaml +26 -0
  124. package/orgs/agent-roster-collective/rosters/docs-writer/roster.yaml +25 -0
  125. package/orgs/agent-roster-collective/rosters/finance-recon/roster.yaml +25 -0
  126. package/orgs/agent-roster-collective/rosters/frontend-ui/roster.yaml +26 -0
  127. package/orgs/agent-roster-collective/rosters/i18n-l10n/roster.yaml +26 -0
  128. package/orgs/agent-roster-collective/rosters/incident-response/roster.yaml +26 -0
  129. package/orgs/agent-roster-collective/rosters/literature-review/roster.yaml +25 -0
  130. package/orgs/agent-roster-collective/rosters/market-research/roster.yaml +25 -0
  131. package/orgs/agent-roster-collective/rosters/ml-eval/roster.yaml +25 -0
  132. package/orgs/agent-roster-collective/rosters/performance/roster.yaml +25 -0
  133. package/orgs/agent-roster-collective/rosters/product-discovery/roster.yaml +25 -0
  134. package/orgs/agent-roster-collective/rosters/qa-regression/roster.yaml +26 -0
  135. package/orgs/agent-roster-collective/rosters/recruiting/roster.yaml +25 -0
  136. package/orgs/agent-roster-collective/rosters/release-manager/roster.yaml +25 -0
  137. package/orgs/agent-roster-collective/rosters/security-audit/roster.yaml +30 -0
  138. package/orgs/agent-roster-collective/rosters/sre-oncall/roster.yaml +26 -0
  139. package/orgs/agent-roster-collective/rosters/support-triage/roster.yaml +25 -0
  140. package/package.json +3 -1
  141. package/schema/compiled.v1.schema.json +391 -0
  142. package/schema/role.v1.schema.json +240 -0
  143. package/schema/roster.v2.schema.json +437 -0
  144. package/examples/core-review/roster.yaml +0 -82
  145. package/examples/research/roster.yaml +0 -42
  146. package/orgs/agent-roster-collective/api-design/roster.yaml +0 -67
  147. package/orgs/agent-roster-collective/content-ops/roster.yaml +0 -55
  148. package/orgs/agent-roster-collective/contract-review/roster.yaml +0 -58
  149. package/orgs/agent-roster-collective/data-quality/roster.yaml +0 -64
  150. package/orgs/agent-roster-collective/db-migration/roster.yaml +0 -64
  151. package/orgs/agent-roster-collective/docs-writer/roster.yaml +0 -59
  152. package/orgs/agent-roster-collective/finance-recon/roster.yaml +0 -63
  153. package/orgs/agent-roster-collective/frontend-ui/roster.yaml +0 -60
  154. package/orgs/agent-roster-collective/i18n-l10n/roster.yaml +0 -60
  155. package/orgs/agent-roster-collective/incident-response/roster.yaml +0 -65
  156. package/orgs/agent-roster-collective/literature-review/roster.yaml +0 -61
  157. package/orgs/agent-roster-collective/market-research/roster.yaml +0 -60
  158. package/orgs/agent-roster-collective/ml-eval/roster.yaml +0 -63
  159. package/orgs/agent-roster-collective/performance/roster.yaml +0 -63
  160. package/orgs/agent-roster-collective/product-discovery/roster.yaml +0 -55
  161. package/orgs/agent-roster-collective/qa-regression/roster.yaml +0 -64
  162. package/orgs/agent-roster-collective/recruiting/roster.yaml +0 -58
  163. package/orgs/agent-roster-collective/release-manager/roster.yaml +0 -59
  164. package/orgs/agent-roster-collective/security-audit/roster.yaml +0 -65
  165. package/orgs/agent-roster-collective/sre-oncall/roster.yaml +0 -60
  166. package/orgs/agent-roster-collective/support-triage/roster.yaml +0 -58
  167. /package/orgs/agent-roster-collective/{security-audit → rosters/security-audit}/capabilities.yaml +0 -0
package/lib/role.js ADDED
@@ -0,0 +1,498 @@
1
+ /**
2
+ * 角色(role)层:一份 `AGENT.md` = frontmatter(参数)+ 正文(人设),可选 `parts/` 片段。
3
+ *
4
+ * 为什么单独一层:人设是散文,路由是参数,两者变更频率与作者都不同。
5
+ * 把它们塞进同一个 YAML 块标量,diff 会混在一起、缩进错一格内容就静默变形。
6
+ *
7
+ * 身份来自文档(frontmatter 的 `name`),目录名只是人的便利——但要满足一条硬约束:
8
+ * **同一座库里 role name 唯一**。我们用一条「重名即报错」代替 v1 的「路径必须等于 id」。
9
+ *
10
+ * @module dsh-agent-roster/role
11
+ */
12
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
13
+ import nodePath from "node:path";
14
+
15
+ import { assemblePrompt, charLength, splitFrontmatter } from "./frontmatter.js";
16
+ import {
17
+ DEFAULT_ON_MISSING,
18
+ LIMITS2,
19
+ ON_MISSING,
20
+ PROMPT_FIELDS,
21
+ REQUIRES_FIELDS,
22
+ ROLE_FIELDS,
23
+ ROLE_FILE_NAME,
24
+ ROLE_KIND,
25
+ ROLE_PARTS_DIR,
26
+ ROLE_REQUIRED,
27
+ ROUTE_FIELDS,
28
+ TOOL_SCOPE_FIELDS,
29
+ TOOLS_FIELDS,
30
+ PATTERNS2,
31
+ } from "./format-v2.js";
32
+ import { PATTERNS, REASONING_EFFORTS } from "./format.js";
33
+ import { diagnostic, parseDocumentText } from "./parse.js";
34
+
35
+ const SINGLE_LINE = new Set(["role", "title", "description", "license", "notes"]);
36
+
37
+ /**
38
+ * 校验角色 frontmatter(纯函数、零 IO)。
39
+ *
40
+ * @param {unknown} value 已解析的 frontmatter 对象
41
+ * @param {{ file?: string, knownToolPolicies?: string[] | null, bodyChars?: number }} [options]
42
+ * @returns {{ ok: boolean, role: object | null, diagnostics: object[] }}
43
+ */
44
+ export function validateRole(value, options = {}) {
45
+ const file = String(options.file ?? "");
46
+ const diagnostics = [];
47
+ const report = (severity, code, path, reason) => diagnostics.push(diagnostic(severity, code, path, reason, file));
48
+
49
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
50
+ report("error", "document-not-mapping", "", "frontmatter 顶层必须是映射(对象)");
51
+ return { ok: false, role: null, diagnostics };
52
+ }
53
+
54
+ for (const key of Object.keys(value)) {
55
+ if (!ROLE_FIELDS.includes(key)) {
56
+ report("error", "unknown-field", key, `未知字段 ${key};允许的字段见 schema/role.v1.schema.json`);
57
+ }
58
+ }
59
+ for (const key of ROLE_REQUIRED) {
60
+ if (value[key] === undefined) report("error", "missing-field", key, `缺少必填字段 ${key}`);
61
+ }
62
+
63
+ if (value.kind !== ROLE_KIND) {
64
+ report("error", "invalid-kind", "kind", `kind 必须是 "${ROLE_KIND}",实际是 ${describe(value.kind)}`);
65
+ }
66
+
67
+ const name = singleLine(value.name, "name", LIMITS2.roleNameChars, report);
68
+ if (name !== null && !PATTERNS2.id.test(name)) {
69
+ report("error", "invalid-role-name", "name", `角色名必须是 kebab-case 的 2~64 字符:${name}`);
70
+ }
71
+
72
+ const version = singleLine(value.version, "version", 64, report);
73
+ if (version !== null && !PATTERNS2.semver.test(version)) {
74
+ report("error", "invalid-semver", "version", `version 必须是精确 SemVer:${version}`);
75
+ }
76
+
77
+ const roleLabel = singleLine(value.role, "role", LIMITS2.roleChars, report);
78
+ const title = singleLine(value.title, "title", LIMITS2.titleChars, report);
79
+ const description = singleLine(value.description, "description", LIMITS2.descriptionChars, report);
80
+ const license = singleLine(value.license, "license", LIMITS2.titleChars, report);
81
+ const notes = singleLine(value.notes, "notes", LIMITS2.notesChars, report);
82
+ const maintainers = stringList(value.maintainers, "maintainers", LIMITS2.maintainers, PATTERNS2.id, report);
83
+ const tags = stringList(value.tags, "tags", LIMITS2.tagsPerRole, PATTERNS2.tag, report);
84
+
85
+ const route = validateRoute(value.route, "route", report);
86
+ const tools = validateTools(value.tools, "tools", options.knownToolPolicies ?? null, report);
87
+ const prompt = validatePrompt(value.prompt, report);
88
+ const skills = stringList(value.skills, "skills", LIMITS2.skillsPerRole, PATTERNS2.skill, report);
89
+ const requires = validateRequires(value.requires, "requires", report);
90
+
91
+ let examples = null;
92
+ if (value.examples !== undefined) {
93
+ if (typeof value.examples !== "string" || value.examples === "") {
94
+ report("error", "invalid-field", "examples", "examples 必须是相对路径字符串");
95
+ } else if (!isSafeRelative(value.examples)) {
96
+ report("error", "unsafe-path", "examples", `examples 不允许绝对路径或 ..:${value.examples}`);
97
+ } else {
98
+ examples = value.examples;
99
+ }
100
+ }
101
+
102
+ let onMissing = DEFAULT_ON_MISSING;
103
+ if (value.onMissing !== undefined) {
104
+ if (!ON_MISSING.includes(value.onMissing)) {
105
+ report("error", "invalid-on-missing", "onMissing", `onMissing 只能是 ${ON_MISSING.join(" / ")}`);
106
+ } else {
107
+ onMissing = value.onMissing;
108
+ }
109
+ }
110
+
111
+ // 拼装后的上限要在这里就拦住:等到运行时才截断,等于让人设静默少一段。
112
+ const bodyChars = Number.isFinite(options.bodyChars) ? Number(options.bodyChars) : null;
113
+ if (bodyChars !== null && bodyChars > LIMITS2.bodyChars) {
114
+ report("error", "prompt-too-long", "body", `正文 ${bodyChars} 字符,超过上限 ${LIMITS2.bodyChars}`);
115
+ }
116
+ if (prompt !== null && prompt.budget !== null && bodyChars !== null && bodyChars > prompt.budget) {
117
+ report(
118
+ "error",
119
+ "prompt-over-budget",
120
+ "prompt.budget",
121
+ `正文 ${bodyChars} 字符超过本角色声明的预算 ${prompt.budget}`,
122
+ );
123
+ }
124
+
125
+ if (diagnostics.some((item) => item.severity === "error")) {
126
+ return { ok: false, role: null, diagnostics };
127
+ }
128
+
129
+ return {
130
+ ok: true,
131
+ role: {
132
+ kind: ROLE_KIND,
133
+ name,
134
+ version,
135
+ role: roleLabel,
136
+ title,
137
+ description,
138
+ license,
139
+ notes,
140
+ maintainers,
141
+ tags,
142
+ route: route ?? {},
143
+ tools: tools ?? {},
144
+ prompt: prompt ?? { parts: [], budget: null },
145
+ skills,
146
+ examples,
147
+ requires,
148
+ onMissing,
149
+ },
150
+ diagnostics,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * 读一份角色目录:`AGENT.md` + `parts/*`,拼出最终 executionPrompt。
156
+ *
157
+ * 路径安全是硬要求:片段只允许角色目录内的相对路径,`..`、绝对路径、指向目录外的符号链接
158
+ * 一律拒绝;缺文件是 **error**,绝不静默当成空片段(空人设比报错更危险)。
159
+ *
160
+ * @param {string} dirPath 角色目录
161
+ * @param {{ knownToolPolicies?: string[] | null }} [options]
162
+ * @returns {{
163
+ * ok: boolean, role: object | null, body: string, prompt: object | null,
164
+ * diagnostics: object[], file: string, dir: string, promptSha: string | null,
165
+ * }}
166
+ */
167
+ export function loadRoleAt(dirPath, options = {}) {
168
+ const dir = nodePath.resolve(String(dirPath));
169
+ const file = nodePath.join(dir, ROLE_FILE_NAME);
170
+ const diagnostics = [];
171
+
172
+ if (!existsSync(file)) {
173
+ diagnostics.push(
174
+ diagnostic("error", "missing-role-file", ROLE_FILE_NAME, `角色目录里没有 ${ROLE_FILE_NAME}`, nodePath.basename(dir)),
175
+ );
176
+ return { ok: false, role: null, body: "", prompt: null, diagnostics, file: null, dir, promptSha: null };
177
+ }
178
+
179
+ let text;
180
+ try {
181
+ text = readFileSync(file, "utf8");
182
+ } catch (error) {
183
+ diagnostics.push(diagnostic("error", "unreadable-role-file", ROLE_FILE_NAME, String(error?.message ?? error), file));
184
+ return { ok: false, role: null, body: "", prompt: null, diagnostics, file, dir, promptSha: null };
185
+ }
186
+
187
+ const split = splitFrontmatter(text, { file });
188
+ diagnostics.push(...split.diagnostics);
189
+ if (!split.ok) return { ok: false, role: null, body: "", prompt: null, diagnostics, file, dir, promptSha: null };
190
+
191
+ const parsed = parseDocumentText(split.frontmatter, { file, format: "yaml" });
192
+ diagnostics.push(...parsed.diagnostics);
193
+ if (!parsed.ok) return { ok: false, role: null, body: split.body, prompt: null, diagnostics, file, dir, promptSha: null };
194
+
195
+ const validated = validateRole(parsed.value, {
196
+ file,
197
+ knownToolPolicies: options.knownToolPolicies ?? null,
198
+ bodyChars: charLength(split.body),
199
+ });
200
+ diagnostics.push(...validated.diagnostics);
201
+ if (!validated.ok) {
202
+ return { ok: false, role: null, body: split.body, prompt: null, diagnostics, file, dir, promptSha: null };
203
+ }
204
+
205
+ const role = validated.role;
206
+ const partTexts = [];
207
+ for (const relative of role.prompt.parts) {
208
+ const resolved = safeResolve(dir, relative);
209
+ if (resolved === null) {
210
+ diagnostics.push(diagnostic("error", "unsafe-path", "prompt.parts", `片段路径不允许越出角色目录:${relative}`, file));
211
+ continue;
212
+ }
213
+ if (!existsSync(resolved)) {
214
+ diagnostics.push(diagnostic("error", "missing-part", "prompt.parts", `片段文件不存在:${relative}`, file));
215
+ continue;
216
+ }
217
+ let partText;
218
+ try {
219
+ partText = readFileSync(resolved, "utf8");
220
+ } catch (error) {
221
+ diagnostics.push(diagnostic("error", "unreadable-part", "prompt.parts", `读不到片段 ${relative}:${error?.message ?? error}`, file));
222
+ continue;
223
+ }
224
+ const chars = charLength(partText);
225
+ if (chars > LIMITS2.partChars) {
226
+ diagnostics.push(diagnostic("error", "part-too-long", "prompt.parts", `片段 ${relative} 有 ${chars} 字符,超过 ${LIMITS2.partChars}`, file));
227
+ }
228
+ partTexts.push({ path: relative, text: partText, chars });
229
+ }
230
+
231
+ if (role.prompt.parts.length > LIMITS2.parts) {
232
+ diagnostics.push(diagnostic("error", "too-many-parts", "prompt.parts", `片段最多 ${LIMITS2.parts} 个`, file));
233
+ }
234
+
235
+ if (diagnostics.some((item) => item.severity === "error")) {
236
+ return { ok: false, role, body: split.body, prompt: null, diagnostics, file, dir, promptSha: null };
237
+ }
238
+
239
+ const assembled = assemblePrompt(split.body, partTexts.map((part) => part.text));
240
+ const chars = charLength(assembled.text);
241
+ if (chars > LIMITS2.promptChars) {
242
+ diagnostics.push(
243
+ diagnostic("error", "prompt-too-long", "prompt", `拼装后 ${chars} 字符,超过上限 ${LIMITS2.promptChars}`, file),
244
+ );
245
+ return { ok: false, role, body: split.body, prompt: null, diagnostics, file, dir, promptSha: null };
246
+ }
247
+ if (role.prompt.budget !== null && chars > role.prompt.budget) {
248
+ diagnostics.push(
249
+ diagnostic("error", "prompt-over-budget", "prompt.budget", `拼装后 ${chars} 字符超过预算 ${role.prompt.budget}`, file),
250
+ );
251
+ return { ok: false, role, body: split.body, prompt: null, diagnostics, file, dir, promptSha: null };
252
+ }
253
+
254
+ return {
255
+ ok: true,
256
+ role,
257
+ body: split.body,
258
+ prompt: {
259
+ text: assembled.text,
260
+ chars,
261
+ parts: [{ path: "body", chars: charLength(split.body) }, ...partTexts.map((part) => ({ path: part.path, chars: part.chars }))],
262
+ },
263
+ diagnostics,
264
+ file,
265
+ dir,
266
+ promptSha: null,
267
+ };
268
+ }
269
+
270
+ /** 从 frontmatter 的 `prompt.parts` 里读出片段路径列表(未校验版本,仅给扫描器算依赖用)。 */
271
+ export function rolePartPaths(role) {
272
+ return Array.isArray(role?.prompt?.parts) ? role.prompt.parts.slice() : [];
273
+ }
274
+
275
+ /** 角色目录里 `parts/` 的实际文件名(用于「片段存在但没被引用」提示)。 */
276
+ export function listPartFiles(dirPath) {
277
+ const dir = nodePath.join(nodePath.resolve(String(dirPath)), ROLE_PARTS_DIR);
278
+ try {
279
+ if (!statSync(dir).isDirectory()) return [];
280
+ return readdirSync(dir)
281
+ .filter((name) => !name.startsWith("."))
282
+ .sort();
283
+ } catch {
284
+ return [];
285
+ }
286
+ }
287
+
288
+ function validateRoute(value, path, report) {
289
+ if (value === undefined) return null;
290
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
291
+ report("error", "invalid-field", path, "route 必须是映射");
292
+ return null;
293
+ }
294
+ for (const key of Object.keys(value)) {
295
+ if (!ROUTE_FIELDS.includes(key)) report("error", "unknown-field", `${path}.${key}`, `未知字段 ${path}.${key}`);
296
+ }
297
+ const route = {};
298
+ if (value.provider !== undefined) {
299
+ if (typeof value.provider !== "string" || !PATTERNS2.provider.test(value.provider)) {
300
+ report("error", "invalid-provider", `${path}.provider`, `provider 形态不合法:${describe(value.provider)}`);
301
+ } else route.provider = value.provider;
302
+ }
303
+ if (value.model !== undefined) {
304
+ if (typeof value.model !== "string" || !PATTERNS2.model.test(value.model)) {
305
+ report("error", "invalid-model", `${path}.model`, `model 形态不合法:${describe(value.model)}`);
306
+ } else route.model = value.model;
307
+ }
308
+ if (value.reasoningEffort !== undefined) {
309
+ if (!REASONING_EFFORTS.includes(value.reasoningEffort)) {
310
+ report("error", "invalid-effort", `${path}.reasoningEffort`, `reasoningEffort 只能是 ${REASONING_EFFORTS.join(" / ")}`);
311
+ } else route.reasoningEffort = value.reasoningEffort;
312
+ }
313
+ if (route.provider !== undefined && route.model === undefined) {
314
+ report("error", "incomplete-route", path, "声明了 provider 就必须声明 model(两者成对出现)");
315
+ }
316
+ if (route.model !== undefined && route.provider === undefined) {
317
+ report("error", "incomplete-route", path, "声明了 model 就必须声明 provider(两者成对出现)");
318
+ }
319
+ return route;
320
+ }
321
+
322
+ function validateTools(value, path, knownToolPolicies, report) {
323
+ if (value === undefined) return null;
324
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
325
+ report("error", "invalid-field", path, "tools 必须是映射");
326
+ return null;
327
+ }
328
+ for (const key of Object.keys(value)) {
329
+ if (!TOOLS_FIELDS.includes(key)) report("error", "unknown-field", `${path}.${key}`, `未知字段 ${path}.${key}`);
330
+ }
331
+ const tools = {};
332
+ if (value.policy !== undefined) {
333
+ if (typeof value.policy !== "string" || !PATTERNS2.id.test(value.policy)) {
334
+ report("error", "invalid-tool-policy", `${path}.policy`, `policy 必须是 kebab-case 的 2~64 字符:${describe(value.policy)}`);
335
+ } else {
336
+ tools.policy = value.policy;
337
+ if (knownToolPolicies !== null && knownToolPolicies.length > 0 && !knownToolPolicies.includes(value.policy)) {
338
+ report(
339
+ "error",
340
+ "unknown-tool-policy",
341
+ `${path}.policy`,
342
+ `部署没有声明工具策略 ${value.policy};已知:${knownToolPolicies.join(", ")}`,
343
+ );
344
+ }
345
+ }
346
+ }
347
+ if (value.scope !== undefined) {
348
+ if (value.scope === null || typeof value.scope !== "object" || Array.isArray(value.scope)) {
349
+ report("error", "invalid-field", `${path}.scope`, "scope 必须是映射");
350
+ } else {
351
+ for (const key of Object.keys(value.scope)) {
352
+ if (!TOOL_SCOPE_FIELDS.includes(key)) {
353
+ report("error", "unknown-field", `${path}.scope.${key}`, `未知字段 ${path}.scope.${key}`);
354
+ }
355
+ }
356
+ tools.scope = {};
357
+ for (const key of TOOL_SCOPE_FIELDS) {
358
+ if (value.scope[key] === undefined) continue;
359
+ const list = value.scope[key];
360
+ if (!Array.isArray(list) || list.length > 64 || list.some((item) => typeof item !== "string" || !PATTERNS.toolPattern.test(item))) {
361
+ report("error", "invalid-tool-scope", `${path}.scope.${key}`, `${key} 必须是工具名数组(最多 64 项)`);
362
+ continue;
363
+ }
364
+ tools.scope[key] = list.slice();
365
+ }
366
+ }
367
+ }
368
+ return tools;
369
+ }
370
+
371
+ function validatePrompt(value, report) {
372
+ if (value === undefined) return null;
373
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
374
+ report("error", "invalid-field", "prompt", "prompt 必须是映射");
375
+ return null;
376
+ }
377
+ for (const key of Object.keys(value)) {
378
+ if (!PROMPT_FIELDS.includes(key)) report("error", "unknown-field", `prompt.${key}`, `未知字段 prompt.${key}`);
379
+ }
380
+ const parts = [];
381
+ if (value.parts !== undefined) {
382
+ if (!Array.isArray(value.parts) || value.parts.length > LIMITS2.parts) {
383
+ report("error", "invalid-parts", "prompt.parts", `parts 必须是有序数组(最多 ${LIMITS2.parts} 项)`);
384
+ } else {
385
+ value.parts.forEach((item, index) => {
386
+ if (typeof item !== "string" || !isSafeRelative(item)) {
387
+ report("error", "unsafe-path", `prompt.parts[${index}]`, `片段必须是不含 .. 的相对路径:${describe(item)}`);
388
+ } else {
389
+ parts.push(item);
390
+ }
391
+ });
392
+ if (new Set(parts).size !== parts.length) report("error", "duplicate-part", "prompt.parts", "同一个片段被引用了两次");
393
+ }
394
+ }
395
+ let budget = null;
396
+ if (value.budget !== undefined) {
397
+ if (!Number.isInteger(value.budget) || value.budget <= 0) {
398
+ report("error", "invalid-budget", "prompt.budget", "budget 必须是正整数(字符数)");
399
+ } else {
400
+ budget = value.budget;
401
+ }
402
+ }
403
+ return { parts, budget };
404
+ }
405
+
406
+ function validateRequires(value, path, report) {
407
+ if (value === undefined) return { skills: [], toolPolicies: [] };
408
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
409
+ report("error", "invalid-field", path, "requires 必须是映射");
410
+ return { skills: [], toolPolicies: [] };
411
+ }
412
+ for (const key of Object.keys(value)) {
413
+ if (!REQUIRES_FIELDS.includes(key)) {
414
+ report("error", "unknown-field", `${path}.${key}`, `未知要求 ${path}.${key}:只有有证据源的要求才能被声明`);
415
+ }
416
+ }
417
+ return {
418
+ skills: stringList(value.skills, `${path}.skills`, LIMITS2.skillsPerRole, PATTERNS2.skill, report),
419
+ toolPolicies: stringList(value.toolPolicies, `${path}.toolPolicies`, 16, PATTERNS2.id, report),
420
+ };
421
+ }
422
+
423
+ function singleLine(value, path, maxChars, report) {
424
+ if (value === undefined) return null;
425
+ if (typeof value !== "string" || value === "") {
426
+ report("error", "invalid-field", path, `${path} 必须是非空字符串`);
427
+ return null;
428
+ }
429
+ if (value.includes("\n")) {
430
+ report("error", "multiline-field", path, `${path} 必须是单行;长文本请写进正文或 parts/`);
431
+ return null;
432
+ }
433
+ const chars = charLength(value);
434
+ if (SINGLE_LINE.has(path) && chars > maxChars) {
435
+ report("error", "field-too-long", path, `${path} 有 ${chars} 字符,超过 ${maxChars}`);
436
+ return null;
437
+ }
438
+ return value;
439
+ }
440
+
441
+ function stringList(value, path, max, pattern, report) {
442
+ if (value === undefined) return [];
443
+ if (!Array.isArray(value)) {
444
+ report("error", "invalid-field", path, `${path} 必须是数组`);
445
+ return [];
446
+ }
447
+ if (value.length > max) report("error", "too-many-items", path, `${path} 最多 ${max} 项`);
448
+ const out = [];
449
+ value.forEach((item, index) => {
450
+ if (typeof item !== "string" || !pattern.test(item)) {
451
+ report("error", "invalid-item", `${path}[${index}]`, `${path}[${index}] 形态不合法:${describe(item)}`);
452
+ return;
453
+ }
454
+ out.push(item);
455
+ });
456
+ if (new Set(out).size !== out.length) report("error", "duplicate-item", path, `${path} 里有重复项`);
457
+ return out;
458
+ }
459
+
460
+ /** 相对路径安全:非空、非绝对、不含 `..` 段、不含反斜杠与协议头。 */
461
+ export function isSafeRelative(value) {
462
+ if (typeof value !== "string" || value === "") return false;
463
+ if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\")) return false;
464
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
465
+ // `./x` 是人的习惯写法,等价于 `x`;但它不能掩盖真正的越界(`..` 一律拒绝)。
466
+ const normalized = value.replace(/^(\.\/)+/, "");
467
+ if (normalized === "") return false;
468
+ return normalized.split("/").every((segment) => segment !== ".." && segment !== "" && segment !== ".");
469
+ }
470
+
471
+ /** 把相对路径解析到基准目录内;越界返回 null(含符号链接逃逸)。 */
472
+ export function safeResolve(baseDir, relative) {
473
+ if (!isSafeRelative(relative)) return null;
474
+ const base = nodePath.resolve(String(baseDir));
475
+ const resolved = nodePath.resolve(base, relative);
476
+ const prefix = base.endsWith(nodePath.sep) ? base : base + nodePath.sep;
477
+ if (resolved !== base && !resolved.startsWith(prefix)) return null;
478
+ try {
479
+ if (existsSync(resolved)) {
480
+ const realBase = realpathSync(base);
481
+ const realResolved = realpathSync(resolved);
482
+ const realPrefix = realBase.endsWith(nodePath.sep) ? realBase : realBase + nodePath.sep;
483
+ if (realResolved !== realBase && !realResolved.startsWith(realPrefix)) return null;
484
+ }
485
+ } catch {
486
+ return null;
487
+ }
488
+ return resolved;
489
+ }
490
+
491
+ function describe(value) {
492
+ if (value === undefined) return "缺失";
493
+ if (value === null) return "null";
494
+ if (typeof value === "string") return JSON.stringify(value);
495
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
496
+ if (Array.isArray(value)) return `数组(${value.length})`;
497
+ return "对象";
498
+ }