feat(skill): 新增项目规范维护 Skill
This commit is contained in:
@@ -16,7 +16,7 @@ CraftKit 是一组面向 Codex 插件市场的中性 Skill 工具。项目从通
|
||||
| `doc` | 文档转换、整理与写作 | 已迁移 `format-md`、`docx-to-md`、`md-to-docx`、`xlsx-to-md`、`archive` |
|
||||
| `git` | 分支、提交、变更提取与集成 | 已迁移 `commit-msg`、`branch`、`identity`、`export`、`integrate` |
|
||||
| `knowledge` | 项目初始化、交接、复盘与经验 | 已迁移 `handoff`、`init`、`trace`、`distill`、`lessons`、`worklog` |
|
||||
| `skill` | 项目规范及 Skill 创建、迁移与维护 | 已迁移 `guidance` |
|
||||
| `skill` | 项目规范及 Skill 创建、迁移与维护 | 已迁移 `guidance`、`guidance-edit` |
|
||||
|
||||
## 目录结构
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ plan-change
|
||||
2. `dev/style`(已完成,基于项目视觉基线设计样式)
|
||||
3. `dev/form`(已完成,设计表单结构、响应布局和可访问性)
|
||||
4. `skill/guidance`(特殊样本已完成)
|
||||
5. `skill/guidance-edit`
|
||||
5. `skill/guidance-edit`(已完成,建立、检查和维护项目规范索引)
|
||||
|
||||
这里只实现读取和维护“当前项目自身规范”的机制,不随 CraftKit 提供任何来源项目规范。
|
||||
原前端导航能力由上述 Skill 的精确触发描述和 Codex 自动发现替代,不再维护独立路由 Skill。混合任务按 `style` → `component` → `form` → 前端设计或实现的依赖顺序处理。
|
||||
@@ -238,3 +238,4 @@ plan-change
|
||||
- [x] 完成 Git 高风险隔离集成样本:`integrate`。
|
||||
- [x] 完成知识管理批次:`trace`、`distill`、`lessons`、`worklog`。
|
||||
- [x] 完成前端辅助批次:`component`、`style`、`form`,并以精确触发替代独立路由。
|
||||
- [x] 完成项目规范维护能力:`guidance-edit`。
|
||||
|
||||
@@ -390,8 +390,10 @@
|
||||
"source-b:31dec2fa162b06d3": {
|
||||
"sourcePathHash": "31dec2fa162b06d379aa676d426d2de01c3b082061ff15adb7a8c721cd5635af",
|
||||
"sourceSha256": "a146d79ccfec7b1f9275cda8e6cf3ab1190430d0c3bc11886a722ce1d6fabc90",
|
||||
"status": "specified",
|
||||
"target": "plugins/skill/skills/guidance-edit"
|
||||
"status": "migrated",
|
||||
"target": "plugins/skill/skills/guidance-edit",
|
||||
"targetVersion": "0.1.0",
|
||||
"reviewedAt": "2026-08-25"
|
||||
},
|
||||
"source-b:988b200efd0b8be0": {
|
||||
"sourcePathHash": "988b200efd0b8be024060e0785da2f864bf7ee30d7c7b434fde7ab1e4cdcc9d8",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""规范索引维护 Skill 的确定性行为测试。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SKILL = ROOT / "plugins" / "skill" / "skills" / "guidance-edit"
|
||||
SCRIPT = SKILL / "scripts" / "check_index.py"
|
||||
|
||||
|
||||
class GuidanceEditTest(unittest.TestCase):
|
||||
"""验证索引检查器和读写职责边界。"""
|
||||
|
||||
def run_check(self, standards: Path) -> subprocess.CompletedProcess[str]:
|
||||
"""以 JSON 模式执行检查器。"""
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), str(standards), "--json"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def test_clean_index_is_reachable(self) -> None:
|
||||
"""根索引能够逐级访问所有规范时应检查通过。"""
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
standards = Path(temp)
|
||||
(standards / "frontend").mkdir()
|
||||
(standards / "index.md").write_text(
|
||||
"[前端](frontend/index.md)\n", encoding="utf-8"
|
||||
)
|
||||
(standards / "frontend" / "index.md").write_text(
|
||||
"[组件](components.md)\n", encoding="utf-8"
|
||||
)
|
||||
(standards / "frontend" / "components.md").write_text(
|
||||
"# 组件\n", encoding="utf-8"
|
||||
)
|
||||
result = self.run_check(standards)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
report = json.loads(result.stdout)
|
||||
self.assertEqual([], report["unindexed"])
|
||||
self.assertEqual([], report["broken"])
|
||||
|
||||
def test_reports_unindexed_broken_and_cycle(self) -> None:
|
||||
"""未索引文件、失效链接和循环引用应同时报告。"""
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
standards = Path(temp)
|
||||
(standards / "index.md").write_text(
|
||||
"[主题](topic.md)\n[缺失](missing.md)\n", encoding="utf-8"
|
||||
)
|
||||
(standards / "topic.md").write_text(
|
||||
"[返回](index.md)\n", encoding="utf-8"
|
||||
)
|
||||
(standards / "orphan.md").write_text("# 未索引\n", encoding="utf-8")
|
||||
result = self.run_check(standards)
|
||||
self.assertEqual(1, result.returncode)
|
||||
report = json.loads(result.stdout)
|
||||
self.assertEqual(["orphan.md"], report["unindexed"])
|
||||
self.assertEqual("missing.md", report["broken"][0]["target"])
|
||||
self.assertTrue(report["cycles"])
|
||||
|
||||
def test_missing_entry_is_input_error(self) -> None:
|
||||
"""入口不存在时应返回输入错误,而不是覆盖问题。"""
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
result = self.run_check(Path(temp))
|
||||
self.assertEqual(2, result.returncode)
|
||||
self.assertIn("索引入口不存在", result.stderr)
|
||||
|
||||
def test_skill_separates_read_and_write_modes(self) -> None:
|
||||
"""检查默认只读,所有写入均需预览与确认。"""
|
||||
content = (SKILL / "SKILL.md").read_text(encoding="utf-8")
|
||||
self.assertIn("检查默认只读", content)
|
||||
self.assertIn("经用户确认后写入", content)
|
||||
self.assertIn("不强制创建固定层级", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "skill",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "项目规范检索及 Codex Skill 创建、迁移与维护工具。",
|
||||
"author": {
|
||||
"name": "CraftKit"
|
||||
@@ -9,7 +9,7 @@
|
||||
"interface": {
|
||||
"displayName": "Skill",
|
||||
"shortDescription": "项目规范与 Skill 维护工具",
|
||||
"longDescription": "提供项目规范渐进检索,以及 Codex Skill 的创建、迁移、检查和来源同步工作流。",
|
||||
"longDescription": "提供项目规范渐进检索与索引维护,以及 Codex Skill 的创建、迁移、检查和来源同步工作流。",
|
||||
"developerName": "CraftKit",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Read", "Write"],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: guidance-edit
|
||||
description: 建立、检查或维护当前项目 .craftkit/standards/ 的规范索引,并安全接入用户确认的新规范。适用于修复索引、检查规范可达性和新增项目规则;只读规范查询、项目初始化或从代码自动推断强制规则不应触发。
|
||||
---
|
||||
|
||||
# 项目规范维护
|
||||
|
||||
维护当前项目自己的增量规范,使 `guidance` 能从 `.craftkit/standards/index.md` 渐进检索到规则正文。不要附带、复制或补全任何外部私有规范库。
|
||||
|
||||
## 选择模式
|
||||
|
||||
- 建立或重组索引:读取 [初始化索引](references/init.md)。
|
||||
- 检查失效链接、未索引文件或循环引用:读取 [覆盖检查](references/check.md)。
|
||||
- 接入用户提供或项目已有的新规范:读取 [接入规范](references/add.md)。
|
||||
|
||||
## 共同边界
|
||||
|
||||
1. 先完整读取现有索引、目标文件及适用的 `AGENTS.md`、`.craftkit/project.json`。
|
||||
2. 检查默认只读;建立索引、修复引用和接入规范都必须先展示目标路径、差异与冲突,经用户确认后写入。
|
||||
3. 尊重项目已有目录和主题,不强制创建固定层级;只在内容规模和检索路径确有需要时增加中间索引。
|
||||
4. 项目规则只记录项目特有增量。通用建议继续由 `guidance` 的公共基线提供,不能冒充项目强制要求。
|
||||
5. 代码中的单一样本、未验证惯例和参考项目做法不得直接提升为规范。
|
||||
6. 不写入凭据、个人绝对路径、私有仓库认证地址或未经授权的第三方正文。
|
||||
|
||||
完成写入后重新运行覆盖检查,并用 `guidance` 验证一个真实查询能命中新增或修复的规则。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "规范维护"
|
||||
short_description: "建立、检查和维护项目规范索引"
|
||||
default_prompt: "使用 $guidance-edit 检查并维护当前项目的规范索引。"
|
||||
@@ -0,0 +1,17 @@
|
||||
# 接入新规范
|
||||
|
||||
## 输入判断
|
||||
|
||||
- 用户提供文件时,先确认其授权范围、是否保留原文以及目标项目是否允许共享。
|
||||
- 项目中已有但未索引的文件,优先原地接入,不重复复制。
|
||||
- 用户只描述规则时,先整理为拟写摘要并确认,不能把建议直接写成强制规则。
|
||||
|
||||
## 接入方案
|
||||
|
||||
1. 判断主题、适用目录、技术版本、规则级别和与既有规范的关系。
|
||||
2. 选择已有主题目录;没有合适位置时提出简短中性名称,不预设固定领域分类。
|
||||
3. 展示目标路径、正文来源、索引更新、冲突处理和是否需要更新 `.craftkit/project.json`。
|
||||
4. 用户确认后写入或保守合并,并在最近的有效索引中添加 Markdown 相对链接。
|
||||
5. 运行覆盖检查,再用 `guidance` 执行一次真实检索。
|
||||
|
||||
不得复制未获授权的参考项目规则、依赖库文档或公司知识库。公共资料只用于独立重建规则,并应记录来源、适用版本和必要的许可证信息。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 规范覆盖检查
|
||||
|
||||
默认执行只读检查:
|
||||
|
||||
```powershell
|
||||
python scripts/check_index.py .craftkit/standards
|
||||
```
|
||||
|
||||
可通过 `--entry` 指定相对于规范根的其他入口,通过 `--json` 输出机器可读结果。
|
||||
|
||||
检查结果包括:
|
||||
|
||||
- 从入口沿 Markdown 相对链接可达的规范文件。
|
||||
- 规范根下未被入口索引到的 Markdown 文件。
|
||||
- 可达文件中的失效 Markdown 链接。
|
||||
- 索引图中的循环引用。
|
||||
|
||||
退出码:`0` 表示检查通过,`1` 表示发现覆盖问题,`2` 表示路径或入口无效。外部 URL、页内锚点和非 Markdown 资源不参与覆盖判定。
|
||||
|
||||
检查报告不自动修改文件。需要修复时先展示建议链接位置、相对路径和影响范围,用户确认后再编辑,并重新检查。
|
||||
@@ -0,0 +1,17 @@
|
||||
# 初始化或重组索引
|
||||
|
||||
## 分析
|
||||
|
||||
1. 确认 `.craftkit/standards/` 和入口 `index.md` 是否存在。
|
||||
2. 枚举规范 Markdown 文件,读取标题、链接和少量必要正文,识别现有主题与目录职责。
|
||||
3. 区分正式规范、索引、说明、草稿和历史资料;身份不明确时询问用户。
|
||||
4. 报告现有入口、失效引用、未索引文件、重复主题和潜在冲突。
|
||||
|
||||
## 设计
|
||||
|
||||
- 小型规范库可由根索引直接链接正文。
|
||||
- 内容较多或存在稳定主题目录时,可为该主题建立局部 `index.md`。
|
||||
- 保留项目已有且有效的目录结构,不为了统一外观移动正文。
|
||||
- 索引只承担导航、适用范围和优先级,不复制正文规则。
|
||||
|
||||
写入前展示新增与修改文件、每项链接目标以及不处理的文件。用户确认后保守修改索引;移动、重命名或删除正文需要单独授权。
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查项目规范 Markdown 文件能否从指定索引入口访问。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
LINK_RE = re.compile(r"(?<!!)\[[^\]]*\]\(([^)]+)\)")
|
||||
|
||||
|
||||
def relative_name(path: Path, root: Path) -> str:
|
||||
"""返回使用正斜杠的规范根相对路径。"""
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def markdown_targets(path: Path, root: Path) -> tuple[list[Path], list[str]]:
|
||||
"""提取指向规范根内 Markdown 文件的相对链接。"""
|
||||
targets: list[Path] = []
|
||||
escaped: list[str] = []
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for raw in LINK_RE.findall(content):
|
||||
value = raw.strip().split(maxsplit=1)[0].strip("<>")
|
||||
if not value or value.startswith(("#", "http://", "https://", "mailto:")):
|
||||
continue
|
||||
value = unquote(value.split("#", 1)[0])
|
||||
if not value.lower().endswith(".md"):
|
||||
continue
|
||||
target = (path.parent / value).resolve()
|
||||
try:
|
||||
target.relative_to(root)
|
||||
except ValueError:
|
||||
escaped.append(raw)
|
||||
continue
|
||||
targets.append(target)
|
||||
return targets, escaped
|
||||
|
||||
|
||||
def inspect(root: Path, entry_name: str) -> dict[str, object]:
|
||||
"""遍历索引图并返回可序列化检查结果。"""
|
||||
root = root.resolve()
|
||||
entry = (root / entry_name).resolve()
|
||||
if not root.is_dir():
|
||||
raise ValueError(f"规范目录不存在:{root}")
|
||||
try:
|
||||
entry.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise ValueError("入口必须位于规范目录内") from exc
|
||||
if not entry.is_file():
|
||||
raise ValueError(f"索引入口不存在:{entry_name}")
|
||||
|
||||
all_files = {path.resolve() for path in root.rglob("*.md") if path.is_file()}
|
||||
graph: dict[Path, list[Path]] = {}
|
||||
broken: list[dict[str, str]] = []
|
||||
escaped: list[dict[str, str]] = []
|
||||
reachable: set[Path] = set()
|
||||
pending = [entry]
|
||||
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current in reachable:
|
||||
continue
|
||||
reachable.add(current)
|
||||
targets, outside = markdown_targets(current, root)
|
||||
graph[current] = []
|
||||
for raw in outside:
|
||||
escaped.append({"source": relative_name(current, root), "target": raw})
|
||||
for target in targets:
|
||||
if not target.is_file():
|
||||
broken.append(
|
||||
{
|
||||
"source": relative_name(current, root),
|
||||
"target": relative_name(target, root),
|
||||
}
|
||||
)
|
||||
continue
|
||||
graph[current].append(target)
|
||||
pending.append(target)
|
||||
|
||||
cycles: set[tuple[str, ...]] = set()
|
||||
active: list[Path] = []
|
||||
visited: set[Path] = set()
|
||||
|
||||
def visit(node: Path) -> None:
|
||||
"""深度优先查找索引图中的回边。"""
|
||||
if node in active:
|
||||
start = active.index(node)
|
||||
cycle = active[start:] + [node]
|
||||
cycles.add(tuple(relative_name(item, root) for item in cycle))
|
||||
return
|
||||
if node in visited:
|
||||
return
|
||||
active.append(node)
|
||||
for target in graph.get(node, []):
|
||||
visit(target)
|
||||
active.pop()
|
||||
visited.add(node)
|
||||
|
||||
visit(entry)
|
||||
return {
|
||||
"root": str(root),
|
||||
"entry": relative_name(entry, root),
|
||||
"reachable": sorted(relative_name(path, root) for path in reachable),
|
||||
"unindexed": sorted(relative_name(path, root) for path in all_files - reachable),
|
||||
"broken": sorted(broken, key=lambda item: (item["source"], item["target"])),
|
||||
"outsideRoot": sorted(escaped, key=lambda item: (item["source"], item["target"])),
|
||||
"cycles": [list(cycle) for cycle in sorted(cycles)],
|
||||
}
|
||||
|
||||
|
||||
def has_findings(report: dict[str, object]) -> bool:
|
||||
"""判断报告是否包含需要处理的问题。"""
|
||||
return any(report[key] for key in ("unindexed", "broken", "outsideRoot", "cycles"))
|
||||
|
||||
|
||||
def print_text(report: dict[str, object]) -> None:
|
||||
"""输出便于人工阅读的简洁报告。"""
|
||||
print(f"入口:{report['entry']}")
|
||||
print(f"可达文件:{len(report['reachable'])}")
|
||||
for label, key in (
|
||||
("未索引文件", "unindexed"),
|
||||
("失效链接", "broken"),
|
||||
("越界链接", "outsideRoot"),
|
||||
("循环引用", "cycles"),
|
||||
):
|
||||
values = report[key]
|
||||
print(f"{label}:{len(values)}")
|
||||
for value in values:
|
||||
print(f" - {value}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""解析命令行参数并返回稳定退出码。"""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("root", type=Path, help="规范根目录")
|
||||
parser.add_argument("--entry", default="index.md", help="规范根内的索引入口")
|
||||
parser.add_argument("--json", action="store_true", help="输出 JSON 报告")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
report = inspect(args.root, args.entry)
|
||||
except (OSError, UnicodeError, ValueError) as exc:
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
return 2
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print_text(report)
|
||||
return 1 if has_findings(report) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user