master-skill 0.10.0 → 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 (85) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/ETHICS.md +23 -17
  5. package/README.md +18 -11
  6. package/README_EN.md +22 -13
  7. package/SKILL.md +5 -5
  8. package/bin/cli.mjs +309 -77
  9. package/gemini-extension.json +1 -1
  10. package/hooks/run-hook.cmd +18 -5
  11. package/hooks/tests/test_run_hook.sh +114 -0
  12. package/hooks/tests/test_run_hook_cmd.sh +94 -0
  13. package/masters/.gitkeep +0 -0
  14. package/package.json +8 -2
  15. package/prebuilt/compare/SKILL.md +5 -5
  16. package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
  17. package/prebuilt/master-ajahn-chah/meta.json +8 -0
  18. package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
  19. package/prebuilt/master-atisha/SKILL.md +13 -11
  20. package/prebuilt/master-atisha/meta.json +8 -0
  21. package/prebuilt/master-atisha/references/voice.md +1 -1
  22. package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
  23. package/prebuilt/master-buddhaghosa/meta.json +8 -0
  24. package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
  25. package/prebuilt/master-fazang/SKILL.md +3 -3
  26. package/prebuilt/master-fazang/meta.json +8 -0
  27. package/prebuilt/master-huineng/SKILL.md +3 -3
  28. package/prebuilt/master-huineng/meta.json +8 -0
  29. package/prebuilt/master-kumarajiva/SKILL.md +3 -3
  30. package/prebuilt/master-kumarajiva/meta.json +8 -0
  31. package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
  32. package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
  33. package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
  34. package/prebuilt/master-milarepa/SKILL.md +13 -11
  35. package/prebuilt/master-milarepa/meta.json +8 -0
  36. package/prebuilt/master-milarepa/references/voice.md +1 -1
  37. package/prebuilt/master-nagarjuna/SKILL.md +3 -3
  38. package/prebuilt/master-nagarjuna/meta.json +8 -0
  39. package/prebuilt/master-ouyi/SKILL.md +3 -3
  40. package/prebuilt/master-ouyi/meta.json +8 -0
  41. package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
  42. package/prebuilt/master-tsongkhapa/meta.json +8 -0
  43. package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
  44. package/prebuilt/master-xuanzang/SKILL.md +3 -3
  45. package/prebuilt/master-xuanzang/meta.json +8 -0
  46. package/prebuilt/master-xuyun/SKILL.md +3 -3
  47. package/prebuilt/master-xuyun/meta.json +8 -0
  48. package/prebuilt/master-yinguang/SKILL.md +3 -3
  49. package/prebuilt/master-yinguang/meta.json +8 -0
  50. package/prebuilt/master-zhiyi/SKILL.md +3 -3
  51. package/prebuilt/master-zhiyi/meta.json +8 -0
  52. package/prompts/correction_handler.md +104 -0
  53. package/prompts/doctrine_reviewer.md +61 -0
  54. package/prompts/intake.md +62 -0
  55. package/prompts/merger.md +62 -0
  56. package/prompts/rag_instructions.md +54 -0
  57. package/prompts/sutra_analyzer.md +83 -0
  58. package/prompts/teaching_builder.md +41 -0
  59. package/prompts/voice_analyzer.md +92 -0
  60. package/prompts/voice_builder.md +48 -0
  61. package/prompts/voice_reviewer.md +66 -0
  62. package/references/README.md +12 -0
  63. package/references/ethics-runtime.md +112 -0
  64. package/references/fojin-api.md +223 -0
  65. package/references/source-conventions.md +129 -0
  66. package/references/teaching-modes.md +84 -0
  67. package/references/traditions.md +72 -0
  68. package/references/workflow-details.md +361 -0
  69. package/requirements.txt +6 -0
  70. package/scripts/select-fidelity-smoke.py +78 -0
  71. package/scripts/test-fidelity.py +19 -2
  72. package/scripts/tests/test_select_fidelity_smoke.py +142 -0
  73. package/scripts/tests/test_validate_citation_contract.py +408 -0
  74. package/scripts/tests/test_validate_workflow.py +265 -0
  75. package/scripts/validate-citation-contract.py +193 -0
  76. package/scripts/verify_citations.py +8 -1
  77. package/skill-catalog.json +147 -0
  78. package/tools/cross_reference.py +365 -0
  79. package/tools/fojin_bridge.py +146 -0
  80. package/tools/master_builder.py +341 -0
  81. package/tools/rag_query.py +336 -0
  82. package/tools/skill_writer.py +230 -0
  83. package/tools/sutra_collector.py +237 -0
  84. package/tools/verify_sources.py +512 -0
  85. package/tools/version_manager.py +88 -0
@@ -0,0 +1,361 @@
1
+ # create-master 主流程细节
2
+
3
+ > **何时读这个**:进入主流程 Step 1-5 任一步遇到细节问题(错误兜底、用户交互文案、版本号策略)时;处理"追加材料 / 纠正 / 管理命令"涉及版本与冲突时;运行时执行优先级冲突解释场景。
4
+ > 主流程骨架在根 SKILL.md,本文档是按需展开的"细则手册"。
5
+
6
+ ## Step 1:信息录入细则
7
+
8
+ ### 快捷入口(用户直接给名)
9
+
10
+ 用户直接输入 `/create-master 弘一大师` → 跳过交互问答,自动填充默认值:
11
+ - 关注方面 = 全部
12
+ - 语言 = 根据传承自动推荐(汉传/中文,藏传/中文+藏文术语注音,南传/中文+巴利术语注音)
13
+
14
+ 展示确认摘要:
15
+
16
+ ```
17
+ 即将创建:弘一大师
18
+ 传承:汉传(律宗)
19
+ 关注方面:全部
20
+ 语言:中文
21
+ 确认创建?(Y/n)
22
+ ```
23
+
24
+ 用户确认后直接进入 Step 2。
25
+
26
+ ### 语言自动检测
27
+
28
+ 根据用户**第一条消息**的语言决定后续全部交互语言:
29
+ - 中文消息 → 中文回复
30
+ - English message → English replies
31
+ - 其他语言同理
32
+
33
+ 中途切换语言 → 跟随切换,但保持术语原文 + 注音。
34
+
35
+ ### FoJin KG 匹配
36
+
37
+ - **匹配成功** → 自动填充传承、时代、宗派等元数据,展示给用户确认
38
+ - **匹配失败** → 提示:
39
+
40
+ > "未在 FoJin 知识图谱中找到「{name}」。请确认名称是否正确,或提供以下信息以手动创建:宗派(如禅宗/净土/天台/华严/唯识等)、时代、师承。"
41
+
42
+ - 用户提供补充信息后,以**手动模式**继续(仍走 Step 2,但跳过 KG 实体采集,仅做经文检索)
43
+
44
+ ### 名称校验规则
45
+
46
+ - 名称必须为**历史真实人物**,不接受虚构角色(小说人物 / 游戏角色 / 神话人物 / 在世法师)
47
+ - 检测到非历史 / 虚构人物 → 回复:
48
+
49
+ > "本工具仅支持历史上真实存在的高僧大德,无法为虚构人物创建教学角色。"
50
+
51
+ - 名称不可为空、不可为纯数字 / 特殊字符
52
+ - 多写法名(如"鸠摩罗什"/"鸠摩罗什婆")→ 优先 FoJin KG 中的**标准名称**
53
+ - 已存在(预置或已生成) → 提示:
54
+
55
+ > "「{name}」已存在,可直接使用 /master-{slug} 调用。如需重新生成,请先执行 /delete-master {slug}。"
56
+
57
+ - 在世法师 / 圆寂未足版权期 → 走 `references/ethics-runtime.md` §版权分级 Tier B/C 流程
58
+
59
+ ## Step 2:数据采集细则
60
+
61
+ ### 采集内容
62
+
63
+ ```bash
64
+ python3 ${CLAUDE_SKILL_DIR}/tools/sutra_collector.py --name "<法师名>" --tradition "<传承>" --output collected_data.json
65
+ ```
66
+
67
+ 包括:
68
+ - 知识图谱实体与师承关系
69
+ - 相关经典列表与内容摘录
70
+ - 传承相关术语(汉传 → CBETA 经名 / 藏传 → 藏文典名 + Toh 编号 / 南传 → SC 经名 + PTS 编号)
71
+
72
+ ### API 故障处理
73
+
74
+ FoJin API 返回错误或不可达 → 向用户说明:
75
+
76
+ > "FoJin API 暂时不可用(错误信息:{error})。您可以:①稍后重试;②进入手动输入模式,提供经文文本。"
77
+
78
+ 手动输入模式下,用户可粘贴经文原文或提供 CBETA 经号,系统基于用户提供的材料继续生成。**但所有用户提供的经号仍须经 verify_sources.py 验证。**
79
+
80
+ ### 超时与重试
81
+
82
+ - 每次 API 调用超时 = 30 秒
83
+ - 超时自动重试 1 次
84
+ - 仍失败 → 触发上述故障处理
85
+
86
+ ### 最低数据阈值
87
+
88
+ 采集到经文 < 3 条 → 警告:
89
+
90
+ > "仅找到 {n} 条相关经文,生成的角色内容可能不够丰富。建议:①追加关键词重新搜索;②手动补充经文材料;③继续生成(内容可能有限)。"
91
+
92
+ ### 引用验证
93
+
94
+ 采集完成后:
95
+
96
+ ```bash
97
+ python3 ${CLAUDE_SKILL_DIR}/tools/verify_sources.py --check-links collected_data.json
98
+ ```
99
+
100
+ 该命令离线检查来源家族、ID 格式、声明归属与自动派生的 citation contract;它不请求外部站点。
101
+ 格式或归属无效的来源须在 Step 3 前排除。外部可达性如有需要,应另作人工或可选在线核验。
102
+ 详细引用规则 → `references/source-conventions.md`。
103
+
104
+ ### 采集结果确认
105
+
106
+ ```
107
+ 数据采集完成:
108
+ 知识图谱实体:{n} 个
109
+ 相关经典:{m} 部
110
+ 内容摘录:{k} 段
111
+ 无效链接:{j} 个(已排除)
112
+ 继续分析?(Y/n)
113
+ ```
114
+
115
+ ## Step 3:分析与生成细则
116
+
117
+ ### 运行时检索规则
118
+
119
+ 加载 `${CLAUDE_SKILL_DIR}/prompts/rag_instructions.md`,将检索指引嵌入生成的每个法师 SKILL.md 运行规则中——确保法师回答时调用 FoJin 实时检索而非仅依赖 LLM 自身知识。
120
+
121
+ ### 两阶段分析
122
+
123
+ 1. **教义分析** — 加载 `prompts/sutra_analyzer.md` + 采集数据,分析教义结构:核心教义维度、关键经典、修行次第
124
+ 2. **风格分析** — 加载 `prompts/voice_analyzer.md` + 采集数据,分析说法风格:语言特征、说法模式、常用譬喻
125
+
126
+ ### 宗派标签自动检测(风格规则路由)
127
+
128
+ 按 FoJin KG 宗派信息自动选用 voice_analyzer 中对应宗派的风格规则:
129
+
130
+ | 宗派 | 风格特征 |
131
+ |------|---------|
132
+ | 禅宗 | 机锋、公案风格 |
133
+ | 净土宗 | 劝信、念佛开示风格 |
134
+ | 天台宗 | 判教、止观论述风格 |
135
+ | 华严宗 | 圆融、法界观论述风格 |
136
+ | 唯识 / 法相 | 因明论证、术语精确风格 |
137
+ | 三论 / 中观 | 八不破立、二谛说法 |
138
+ | 律宗 | 戒法持犯、止持作持论述 |
139
+ | 藏传格鲁 | 道次第论证、应成中观三士道 |
140
+ | 藏传噶举 | 大手印诗偈 / 道歌风格(米拉日巴 mGur) |
141
+ | 南传上座部 | 七清净十六观智论述、巴利原文穿插 |
142
+
143
+ ### 质量门控
144
+
145
+ 分析器输出中任一维度标记 `"insufficient_data": true` → 向用户提示:
146
+
147
+ > "以下维度的数据不足,生成质量可能受影响:{dimensions}。"
148
+ > "建议追加相关经文材料后重新分析,或选择继续生成(不足部分将标注警告)。"
149
+
150
+ 用户继续 → 生成文件中对不足维度添加 `<!-- DATA_LIMITED -->` 注释。
151
+
152
+ ### 生成阶段
153
+
154
+ - **教义生成**:`prompts/teaching_builder.md` → `teaching.md`
155
+ - **风格生成**:`prompts/voice_builder.md` → `voice.md`(4 层结构)
156
+
157
+ ### voice.md 4 层结构
158
+
159
+ | Layer | 含义 | 示例 |
160
+ |-------|------|------|
161
+ | **0** | 硬规则,不可违反的底线 | "不自称已证悟"、"不预言未来"、"不冒充佛"、"不收弟子" |
162
+ | **1** | 核心风格,该法师最显著的说法特征 | 慧能:直指见性 / 印光:劝信念佛 / 玄奘:因明严谨 |
163
+ | **2** | 辅助风格,次要但常见的表达模式 | 引用习惯、常用譬喻、句式偏好 |
164
+ | **3** | 情境风格,特定场景下的应对方式 | 面对学者 / 面对初学 / 面对疑惑 / 面对争执 |
165
+
166
+ ## Step 3.5:二阶段审查细则
167
+
168
+ ### 顺序不可颠倒
169
+
170
+ 教义准确性审查 → 风格一致性审查。**不可颠倒**,因为教义错误修复可能影响风格层级。
171
+
172
+ ### 教义准确性审查
173
+
174
+ 生成器先从 `sources[].type` 派生 citation contract,并在内存中保留同一个 sources/contract 对象。
175
+ `prompts/doctrine_reviewer.md` 接收这个**生成器内存上下文**及 teaching.md:
176
+ - 经证覆盖率(目标 ≥ 90%)
177
+ - 各来源家族 ID 的声明归属准确性
178
+ - 宗派边界越界(如让慧能讲三士道)
179
+ - 输出:PASS / PASS WITH WARNINGS / FAIL
180
+
181
+ 最终 spec 与 `meta.json` 必须复用同一个 contract;写入器会重新按 `sources[].type` 派生并拒绝漂移。
182
+
183
+ FAIL → 自动修复严重问题后重审,**最多 2 轮**。仍 FAIL → 报告问题请求人工介入。
184
+
185
+ ### 风格一致性审查
186
+
187
+ `prompts/voice_reviewer.md` 对 voice.md:
188
+ - Layer 0 硬规则完整性
189
+ - 风格与宗派特征匹配度
190
+ - 层次结构清晰度
191
+ - 输出:PASS / PASS WITH WARNINGS / FAIL
192
+
193
+ FAIL → 自动修复后重审。
194
+
195
+ ### 结果展示
196
+
197
+ ```
198
+ ══ 审查结果 ══
199
+ 教义准确性:PASS (经证覆盖率 95%, 0 严重问题)
200
+ 风格一致性:PASS WITH WARNINGS (Layer 0 完整, 1 警告)
201
+ 警告:Layer 2 缺少"面对学者"的情境风格
202
+ ══════════════
203
+ ```
204
+
205
+ 两项均 PASS 或 PASS WITH WARNINGS 后,进入 Step 4。
206
+
207
+ ## Step 4:预览与确认细则
208
+
209
+ ### 结构化预览格式
210
+
211
+ ```
212
+ ══ 教义预览(teaching.md)══
213
+ 核心教义:{1-3 条核心教义概要}
214
+ 关键经典:{主要引用经典列表}
215
+ 修行次第:{修行路径概要}
216
+
217
+ ══ 风格预览(voice.md)══
218
+ 风格特征:{2-3 条风格特点}
219
+ 语言模式:{典型表达方式}
220
+ 示例句:
221
+ 1. "{模拟该法师风格的示例句1}"
222
+ 2. "{模拟该法师风格的示例句2}"
223
+ ══════════════════════════
224
+ ```
225
+
226
+ ### 用户修改请求识别
227
+
228
+ | 用户说 | 处理 |
229
+ |--------|------|
230
+ | "修改教义部分" | 重新展示 teaching.md 详情,接受用户逐条调整 |
231
+ | "调整风格更严厉一些" / "语气更温和" | 调整 voice.md 风格参数后重新预览 |
232
+ | "添加更多关于{主题}的内容" | 针对性补充特定教义维度 |
233
+ | "重新生成" | 以调整后的参数重新执行 Step 3,重新展示预览 |
234
+
235
+ ## Step 5:写入文件细则
236
+
237
+ ```bash
238
+ python3 ${CLAUDE_SKILL_DIR}/tools/master_builder.py --spec generated-master.json --output masters/
239
+ ```
240
+
241
+ `generated-master.json` 是审查通过后的生成规格,必含 `name`、`tradition`、`school`、`era`、
242
+ `languages`、`teaching_content`、`voice_content`、`sources`,并可携带审查所用的同一
243
+ `citation_contract`。若携带的 contract 与来源家族自动派生结果不同,构建立即失败。
244
+
245
+ ### 生成后终验
246
+
247
+ ```bash
248
+ python3 ${CLAUDE_SKILL_DIR}/tools/verify_sources.py --final-check masters/master-{slug}/
249
+ ```
250
+
251
+ `--final-check` 离线验证 persona 目录包含 `SKILL.md`、`teaching.md`、`voice.md`、`meta.json`,
252
+ 并验证 `meta.json` 的来源家族、ID 格式、声明归属与 citation contract。它不会解析
253
+ `teaching.md` 的自由文本引文,也不检查外部链接 HTTP 状态;外部可达性是独立的人工或可选在线步骤。
254
+
255
+ ### 生成目录结构
256
+
257
+ ```
258
+ masters/master-{slug}/
259
+ ├── SKILL.md # /master-{slug} 触发(完整角色定义)
260
+ ├── teaching.md # 教义体系(可单独使用)
261
+ ├── voice.md # 说法风格(可单独使用)
262
+ └── meta.json # 元数据(版本、生成时间、数据来源)
263
+ ```
264
+
265
+ ### 角色注册(按运行环境)
266
+
267
+ **Claude Code 用户**
268
+ 1. 生成的 SKILL.md 已放置在 `masters/master-{slug}/`
269
+ 2. 确保 `masters/` 在 Claude Code skill 搜索路径中(检查 `.claude/settings.json` 的 `skillDirs` 配置)
270
+ 3. 完成后自动可通过 `/master-{slug}` 触发
271
+
272
+ **OpenClaw 用户**
273
+ 1. 将 `masters/master-{slug}/` 复制到 OpenClaw 的 skills 目录
274
+ 2. 在 OpenClaw 配置中注册新 skill
275
+ 3. 参考 OpenClaw 文档完成注册流程
276
+
277
+ ### 完成提示
278
+
279
+ ```
280
+ 已生成「{master_name}」教学角色
281
+ 目录:masters/master-{slug}/
282
+ 调用命令:/master-{slug}
283
+ 包含文件:SKILL.md, teaching.md, voice.md, meta.json
284
+ 数据来源:{n} 条经文,{m} 个知识图谱实体
285
+ ```
286
+
287
+ ## 追加材料、纠正、管理命令细则
288
+
289
+ ### 追加材料(进化模式)
290
+
291
+ **触发短语**:
292
+ - "给印光大师追加《文钞三编》的材料"
293
+ - "追加《经名》的材料"
294
+ - "补充关于{主题}的内容"
295
+ - "用这段语录更新慧能大师的说法风格"
296
+
297
+ 加载 `prompts/merger.md` 增量合并。
298
+
299
+ **合并冲突处理**:
300
+ - merger.md 策略:「新数据优先、保留原有结构」
301
+ - 教义矛盾(如不同经典对同一概念阐述差异)→ 保留双方并加注释说明差异
302
+ - 风格冲突 → 新材料风格特征与现有特征**合并**,不覆盖
303
+
304
+ **版本自动递增**:
305
+ - 每次追加 → meta.json `version` 自动 minor 递增(1.0.0 → 1.1.0)
306
+ - 旧版本自动归档到 `masters/master-{slug}/versions/`
307
+ - `/master-rollback` 可回退到任意历史版本
308
+
309
+ ### 纠正模式
310
+
311
+ 用户对 AI 表现提出纠正:
312
+ - "他不会这样说话"
313
+ - "他应该更严厉一些"
314
+ - "他遇到这种问题会先引用《法华经》"
315
+
316
+ 加载 `prompts/correction_handler.md`。
317
+
318
+ **纠正处理流程**:
319
+ 1. 识别纠正类型:教义纠正 → `teaching.md`;风格纠正 → `voice.md`
320
+ 2. 以 `## Correction` 块格式追加到对应文件**末尾**,含时间戳 + 原始反馈
321
+ 3. 纠正记录的优先级**高于**分析生成的内容(详见下文执行优先级)
322
+ 4. 每次纠正 → meta.json 版本号 patch 递增(1.1.0 → 1.1.1)
323
+
324
+ ### 管理命令
325
+
326
+ | 命令 | 行为 |
327
+ |------|------|
328
+ | `/list-masters` | 列出所有已生成的法师,显示传承、时代、版本号;预置标 `[预置]`,自定义标 `[自定义]` |
329
+ | `/master-rollback <slug> <version>` | 回滚到指定版本;当前版本自动归档到 `.versions/`;指定版本不存在 → 列出可用版本供选择 |
330
+ | `/delete-master <slug>` | 删除法师目录;执行前二次确认:"确定要删除「{master_name}」吗?此操作不可恢复。输入 'yes' 确认。";**预置不可删除** |
331
+
332
+ ## 执行优先级冲突细则
333
+
334
+ 法师角色运行时优先级(高→低):
335
+
336
+ 1. voice.md Layer 0 硬规则
337
+ 2. Correction 记录
338
+ 3. voice.md Layer 1-3
339
+ 4. teaching.md 教义内容
340
+ 5. FoJin RAG 实时检索结果
341
+ 6. LLM 自身知识
342
+
343
+ ### 典型冲突场景示例
344
+
345
+ | 场景 | 处理 |
346
+ |------|------|
347
+ | voice.md Layer 0 规定"不自称已证悟",teaching.md 有该法师证悟记载 | 回答时**不以第一人称宣称证悟**(Layer 0 覆盖 teaching) |
348
+ | 用户纠正"他从不直接回答是非题",voice.md Layer 1 可能有直接回答模式 | 纠正记录覆盖 Layer 1(Correction 高于 Layer 1-3) |
349
+ | FoJin RAG 检索经文与 teaching.md 记载有细节差异 | 以 teaching.md 为准(RAG 作为补充参考) |
350
+ | LLM 自有知识与 teaching.md 冲突 | 以 teaching.md 为准(LLM 知识最低优先级) |
351
+
352
+ ## FoJin API 深度使用
353
+
354
+ `rag_query.py` 不够用的场景(KG 深度遍历、跨词典分组对比等)→ 参考 `references/fojin-api.md`(REST API 完整参考)。
355
+
356
+ ## 衔接其他 references
357
+
358
+ - 引用规则(CBETA / BDRC / SC / Toh / PTS)→ `references/source-conventions.md`
359
+ - HARD-GATE 完整规则、AI 透明度、版权分级、边界场景 → `references/ethics-runtime.md`
360
+ - 三大传统总论、宗派定位、跨传统对比议题 → `references/traditions.md`
361
+ - compare / debate / curriculum 选择 → `references/teaching-modes.md`
@@ -0,0 +1,6 @@
1
+ # requests 2.33+ requires Python 3.10. Keep the documented Python 3.9 floor
2
+ # on the latest compatible 2.32 patch while newer runtimes use the current line.
3
+ requests>=2.32.5,<2.33; python_version < "3.10"
4
+ requests>=2.34.2; python_version >= "3.10"
5
+ pypinyin>=0.55.0
6
+ pyyaml>=6.0.3
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """Select one fidelity smoke target from persona metadata."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ class SelectionError(ValueError):
13
+ """Raised when a smoke target cannot be selected safely."""
14
+
15
+
16
+ def discover_roster(prebuilt: Path) -> list[str]:
17
+ """Return sorted persona directories with a non-empty sources list."""
18
+ roster: list[str] = []
19
+ for meta_path in sorted(prebuilt.glob("master-*/meta.json")):
20
+ try:
21
+ metadata = json.loads(meta_path.read_text(encoding="utf-8"))
22
+ except (OSError, json.JSONDecodeError) as exc:
23
+ raise SelectionError(f"Invalid metadata {meta_path}: {exc}") from exc
24
+ sources = metadata.get("sources")
25
+ if isinstance(sources, list) and sources:
26
+ roster.append(meta_path.parent.name)
27
+ return roster
28
+
29
+
30
+ def parse_day_of_year(raw_day: str) -> int:
31
+ """Parse a zero-padded day of year explicitly as base 10."""
32
+ try:
33
+ day = int(raw_day, 10)
34
+ except ValueError as exc:
35
+ raise SelectionError(f"Invalid day-of-year: {raw_day!r}") from exc
36
+ if not 1 <= day <= 366:
37
+ raise SelectionError(f"Invalid day-of-year: {raw_day!r}")
38
+ return day
39
+
40
+
41
+ def select_target(
42
+ roster: list[str], day_of_year: int, changed: list[str]
43
+ ) -> str:
44
+ """Prefer the first discovered changed persona; otherwise rotate by day."""
45
+ if not roster:
46
+ raise SelectionError("No persona smoke targets discovered from metadata sources")
47
+ for candidate in changed:
48
+ if candidate in roster:
49
+ return candidate
50
+ return roster[day_of_year % len(roster)]
51
+
52
+
53
+ def main() -> int:
54
+ parser = argparse.ArgumentParser(description=__doc__)
55
+ parser.add_argument("--prebuilt", type=Path, default=Path("prebuilt"))
56
+ parser.add_argument("--day-of-year", required=True)
57
+ parser.add_argument(
58
+ "--changed",
59
+ action="append",
60
+ default=[],
61
+ help="changed prebuilt directory; repeat to preserve diff order",
62
+ )
63
+ args = parser.parse_args()
64
+
65
+ try:
66
+ roster = discover_roster(args.prebuilt)
67
+ day_of_year = parse_day_of_year(args.day_of_year)
68
+ target = select_target(roster, day_of_year, args.changed)
69
+ except SelectionError as exc:
70
+ print(f"error: {exc}", file=sys.stderr)
71
+ return 1
72
+
73
+ print(target)
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ raise SystemExit(main())
@@ -263,7 +263,22 @@ def run_tests(
263
263
  }
264
264
 
265
265
 
266
- def main():
266
+ def results_failed(results: list[dict], dry_run: bool) -> bool:
267
+ """Return whether collected fidelity results require a failing exit status."""
268
+ if dry_run:
269
+ return any("error" in suite for suite in results)
270
+ return any(
271
+ "error" in suite
272
+ or suite.get("failed", 0) > 0
273
+ or any(
274
+ case.get("status") in {"FAIL", "api_error"}
275
+ for case in suite.get("results", [])
276
+ )
277
+ for suite in results
278
+ )
279
+
280
+
281
+ def main() -> int:
267
282
  parser = argparse.ArgumentParser(description="Master-skill fidelity test runner")
268
283
  parser.add_argument("--master", type=str, help="Test a specific master")
269
284
  parser.add_argument("--all", action="store_true", help="Test all masters with fidelity.jsonl")
@@ -322,6 +337,8 @@ def main():
322
337
  else:
323
338
  print(f" {r['master']}: {r.get('passed', 0)}/{r['total']} ({r.get('pass_rate', 'N/A')})")
324
339
 
340
+ return 1 if results_failed(all_results, args.dry_run) else 0
341
+
325
342
 
326
343
  if __name__ == "__main__":
327
- main()
344
+ raise SystemExit(main())
@@ -0,0 +1,142 @@
1
+ """Behavior tests for deterministic fidelity smoke-target selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[2]
14
+ SELECTOR = ROOT / "scripts" / "select-fidelity-smoke.py"
15
+
16
+
17
+ def _write_meta(prebuilt: Path, slug: str, sources: object) -> None:
18
+ master_dir = prebuilt / slug
19
+ master_dir.mkdir(parents=True)
20
+ (master_dir / "meta.json").write_text(
21
+ json.dumps({"sources": sources}),
22
+ encoding="utf-8",
23
+ )
24
+
25
+
26
+ def _run_selector(
27
+ prebuilt: Path,
28
+ day_of_year: str,
29
+ changed: str | list[str] | None = None,
30
+ ) -> subprocess.CompletedProcess[str]:
31
+ command = [
32
+ sys.executable,
33
+ str(SELECTOR),
34
+ "--prebuilt",
35
+ str(prebuilt),
36
+ "--day-of-year",
37
+ day_of_year,
38
+ ]
39
+ if changed is not None:
40
+ changed_values = [changed] if isinstance(changed, str) else changed
41
+ for candidate in changed_values:
42
+ command.extend(["--changed", candidate])
43
+ return subprocess.run(command, text=True, capture_output=True, check=False)
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ ("day_of_year", "expected"),
48
+ [("008", "master-gamma"), ("099", "master-alpha"), ("100", "master-beta")],
49
+ )
50
+ def test_rotation_parses_zero_padded_days_as_decimal(
51
+ tmp_path: Path,
52
+ day_of_year: str,
53
+ expected: str,
54
+ ):
55
+ prebuilt = tmp_path / "prebuilt"
56
+ _write_meta(prebuilt, "master-gamma", [{"id": "g"}])
57
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
58
+ _write_meta(prebuilt, "master-beta", [{"id": "b"}])
59
+
60
+ result = _run_selector(prebuilt, day_of_year)
61
+
62
+ assert result.returncode == 0, result.stderr
63
+ assert result.stdout == f"{expected}\n"
64
+ assert result.stderr == ""
65
+
66
+
67
+ def test_changed_persona_wins_only_when_in_discovered_roster(tmp_path: Path):
68
+ prebuilt = tmp_path / "prebuilt"
69
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
70
+ _write_meta(prebuilt, "master-beta", [{"id": "b"}])
71
+
72
+ selected = _run_selector(prebuilt, "008", "master-beta")
73
+ rotated = _run_selector(prebuilt, "008", "master-not-discovered")
74
+
75
+ assert selected.returncode == 0, selected.stderr
76
+ assert selected.stdout == "master-beta\n"
77
+ assert rotated.returncode == 0, rotated.stderr
78
+ assert rotated.stdout == "master-alpha\n"
79
+
80
+
81
+ def test_meta_skill_before_persona_does_not_hide_changed_persona(tmp_path: Path):
82
+ prebuilt = tmp_path / "prebuilt"
83
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
84
+ _write_meta(prebuilt, "master-beta", [{"id": "b"}])
85
+
86
+ result = _run_selector(
87
+ prebuilt,
88
+ "008",
89
+ ["compare", "master-beta", "master-alpha"],
90
+ )
91
+
92
+ assert result.returncode == 0, result.stderr
93
+ assert result.stdout == "master-beta\n"
94
+
95
+
96
+ def test_discovery_ignores_meta_skills_and_empty_sources(tmp_path: Path):
97
+ prebuilt = tmp_path / "prebuilt"
98
+ _write_meta(prebuilt, "compare", [{"id": "meta"}])
99
+ _write_meta(prebuilt, "master-empty", [])
100
+ _write_meta(prebuilt, "master-valid", [{"id": "source"}])
101
+
102
+ result = _run_selector(prebuilt, "100")
103
+
104
+ assert result.returncode == 0, result.stderr
105
+ assert result.stdout == "master-valid\n"
106
+
107
+
108
+ def test_empty_roster_fails_closed(tmp_path: Path):
109
+ prebuilt = tmp_path / "prebuilt"
110
+ _write_meta(prebuilt, "master-empty", [])
111
+
112
+ result = _run_selector(prebuilt, "008")
113
+
114
+ assert result.returncode != 0
115
+ assert result.stdout == ""
116
+ assert "No persona smoke targets" in result.stderr
117
+
118
+
119
+ def test_invalid_json_after_valid_metadata_fails_without_partial_stdout(tmp_path: Path):
120
+ prebuilt = tmp_path / "prebuilt"
121
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
122
+ invalid_dir = prebuilt / "master-zeta"
123
+ invalid_dir.mkdir(parents=True)
124
+ (invalid_dir / "meta.json").write_text("{not-json", encoding="utf-8")
125
+
126
+ result = _run_selector(prebuilt, "008")
127
+
128
+ assert result.returncode != 0
129
+ assert result.stdout == ""
130
+ assert "master-zeta/meta.json" in result.stderr
131
+
132
+
133
+ @pytest.mark.parametrize("day_of_year", ["0", "367", "not-a-day"])
134
+ def test_invalid_day_fails_closed(tmp_path: Path, day_of_year: str):
135
+ prebuilt = tmp_path / "prebuilt"
136
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
137
+
138
+ result = _run_selector(prebuilt, day_of_year)
139
+
140
+ assert result.returncode != 0
141
+ assert result.stdout == ""
142
+ assert "day-of-year" in result.stderr