From 381308e04a2d09b7acc64047089bb1fe45a8f020 Mon Sep 17 00:00:00 2001 From: "zhiye.sun" Date: Thu, 3 Sep 2026 17:11:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(profile):=20=E5=BB=BA=E7=AB=8B=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E8=83=BD=E5=8A=9B=E5=A5=91=E7=BA=A6=E4=B8=8E=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/profile/.codex-plugin/plugin.json | 18 +++ plugins/profile/skills/resolve/SKILL.md | 27 ++++ .../profile/skills/resolve/agents/openai.yaml | 5 + .../skills/resolve/references/contract.md | 23 +++ .../skills/resolve/references/discovery.md | 10 ++ .../skills/resolve/references/fallback.md | 12 ++ .../resolve/references/manifest.schema.json | 30 ++++ .../skills/resolve/references/resolution.md | 20 +++ .../resolve/scripts/validate_profile.py | 141 ++++++++++++++++++ 9 files changed, 286 insertions(+) create mode 100644 plugins/profile/.codex-plugin/plugin.json create mode 100644 plugins/profile/skills/resolve/SKILL.md create mode 100644 plugins/profile/skills/resolve/agents/openai.yaml create mode 100644 plugins/profile/skills/resolve/references/contract.md create mode 100644 plugins/profile/skills/resolve/references/discovery.md create mode 100644 plugins/profile/skills/resolve/references/fallback.md create mode 100644 plugins/profile/skills/resolve/references/manifest.schema.json create mode 100644 plugins/profile/skills/resolve/references/resolution.md create mode 100644 plugins/profile/skills/resolve/scripts/validate_profile.py diff --git a/plugins/profile/.codex-plugin/plugin.json b/plugins/profile/.codex-plugin/plugin.json new file mode 100644 index 0000000..53854f3 --- /dev/null +++ b/plugins/profile/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "profile", + "version": "0.1.0", + "description": "技术能力 Profile 契约、匹配规则与静态校验工具。", + "author": { + "name": "CraftKit" + }, + "skills": "./skills/", + "interface": { + "displayName": "Profile", + "shortDescription": "技术能力契约、匹配和静态校验", + "longDescription": "提供多语言技术能力清单契约、版本匹配规则、缺失回退和确定性静态校验。", + "developerName": "CraftKit", + "category": "Productivity", + "capabilities": ["Read"], + "defaultPrompt": ["帮我解析当前项目需要的技术 Profile,并说明能力缺口。"] + } +} diff --git a/plugins/profile/skills/resolve/SKILL.md b/plugins/profile/skills/resolve/SKILL.md new file mode 100644 index 0000000..b1b6017 --- /dev/null +++ b/plugins/profile/skills/resolve/SKILL.md @@ -0,0 +1,27 @@ +--- +name: resolve +description: 根据目标模块、项目技术事实和当前会话已发现的技术提供方解析 Profile 能力,返回匹配结果、资料入口和缺口。适用于多语言项目的能力选择与诊断;项目初始化、业务设计或直接实现不应触发。 +--- + +# Profile 解析 + +根据项目证据选择技术能力,不扫描用户插件缓存,也不写入项目画像。 + +## 工作流 + +1. 读取目标路径、所需能力和 `.craftkit/project.json`;项目画像缺失时只使用构建文件、锁文件和源码中的可验证事实。 +2. 按模块根最长匹配规则确定目标模块;同长度命中多个模块时返回 `ambiguous`。 +3. 从当前会话可用 Skill 判断提供方是否已被发现,不推断未暴露插件的安装状态。 +4. 已发现提供方时,由对应 Profile Skill 读取自身清单并返回候选;本 Skill 不拼接其他插件物理路径。 +5. 按[解析规则](references/resolution.md)选择候选,并按[回退规则](references/fallback.md)处理缺失、未知和冲突。 +6. 返回标准能力上下文,区分事实、选择、逻辑资料标识、项目覆盖入口和缺口。 + +详细字段和能力枚举见[契约](references/contract.md),提供方发现边界见[发现协议](references/discovery.md)。 + +## 边界 + +- Profile 提供的命令只是选择建议,不构成执行授权。 +- 项目规范的合并和覆盖由 `guidance` 负责。 +- 稳定项目事实只有在用户要求初始化或更新画像时才由 `knowledge:init` 写入。 +- 逻辑资料标识只用于诊断;消费者不能将其转换成用户目录绝对路径。 + diff --git a/plugins/profile/skills/resolve/agents/openai.yaml b/plugins/profile/skills/resolve/agents/openai.yaml new file mode 100644 index 0000000..0c34b17 --- /dev/null +++ b/plugins/profile/skills/resolve/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Profile Resolver(profile:resolve)" + short_description: "按项目事实匹配技术能力并报告缺口" + default_prompt: "使用 $resolve 解析当前目标模块需要的技术 Profile。" + diff --git a/plugins/profile/skills/resolve/references/contract.md b/plugins/profile/skills/resolve/references/contract.md new file mode 100644 index 0000000..d2479a3 --- /dev/null +++ b/plugins/profile/skills/resolve/references/contract.md @@ -0,0 +1,23 @@ +# Profile 契约 + +每个提供方以 `references/manifest.json` 声明能力。清单只包含路由元数据,规则正文保存在同一 Skill 的 Markdown 文件中。 + +必需字段包括 `schemaVersion`、`provider` 和 `profiles`。Profile 必须声明稳定 ID、版本体系、版本范围、优先级、探测证据、能力资料入口和回退入口。 + +首期能力标识: + +- `project-detection` +- `knowledge-routing` +- `backend-design` +- `backend-implementation` +- `backend-testing` +- `language-review` +- `framework-review` +- `command-resolution` + +缺少能力表示提供方不提供该能力,不能使用空文件占位。 + +首期版本范围只支持 `*`、精确数字版本,以及用逗号连接的 `>=`、`>`、`<=`、`<`、`==` 条件。不支持并集、排除、预发布标签和生态专属通配表达式。 + +诊断结果使用 `:/` 逻辑标识。该值不能被消费者拼接为物理路径。 + diff --git a/plugins/profile/skills/resolve/references/discovery.md b/plugins/profile/skills/resolve/references/discovery.md new file mode 100644 index 0000000..1e064e2 --- /dev/null +++ b/plugins/profile/skills/resolve/references/discovery.md @@ -0,0 +1,10 @@ +# 提供方发现协议 + +1. 当前会话的 Skill 清单是提供方是否可用的唯一运行时依据。 +2. 消费者请求已发现的 `:profile`,由提供方读取自己的清单和资料。 +3. 没有发现对应 Skill 时返回 `missing`,不扫描用户插件缓存。 +4. Profile 插件不维护运行时注册表,不读取其他插件目录。 +5. 新增或更新插件后的真实发现行为必须在安装后的新会话验证。 + +仓库静态校验只能证明声明有效,不能证明插件已经安装或当前会话已经加载。 + diff --git a/plugins/profile/skills/resolve/references/fallback.md b/plugins/profile/skills/resolve/references/fallback.md new file mode 100644 index 0000000..cf68715 --- /dev/null +++ b/plugins/profile/skills/resolve/references/fallback.md @@ -0,0 +1,12 @@ +# Profile 回退规则 + +| 状态 | 条件 | 消费者行为 | +| --- | --- | --- | +| `available` | 提供方已发现、版本匹配且资料有效 | 加载专项资料 | +| `generic` | 只有版本中性资料 | 使用通用资料并说明适用范围 | +| `missing` | 提供方或能力未发现 | 继续核心通用流程并报告缺口 | +| `incompatible` | 项目版本不在支持范围 | 禁止加载冲突资料 | +| `ambiguous` | 模块或候选无法唯一确定 | 展示候选并请求最小必要信息 | +| `invalid` | 清单或引用未通过校验 | 隔离该提供方并报告错误 | + +回退结果必须说明核心工作流仍可完成的范围,不能把缺少增强能力描述为项目缺陷。 diff --git a/plugins/profile/skills/resolve/references/manifest.schema.json b/plugins/profile/skills/resolve/references/manifest.schema.json new file mode 100644 index 0000000..91ca881 --- /dev/null +++ b/plugins/profile/skills/resolve/references/manifest.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://craftkit.local/schemas/profile-manifest-v1.json", + "title": "CraftKit Profile Manifest", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "provider", "profiles"], + "properties": { + "schemaVersion": { "const": 1 }, + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "displayName"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "kind": { "enum": ["language", "framework", "toolchain"] }, + "displayName": { "type": "string", "minLength": 1 } + } + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "priority", "detect", "capabilities", "fallback"] + } + } + } +} diff --git a/plugins/profile/skills/resolve/references/resolution.md b/plugins/profile/skills/resolve/references/resolution.md new file mode 100644 index 0000000..44177c7 --- /dev/null +++ b/plugins/profile/skills/resolve/references/resolution.md @@ -0,0 +1,20 @@ +# Profile 解析规则 + +## 模块选择 + +- 将目标路径和模块根转换为项目相对规范路径。 +- 选择路径前缀最长的模块根。 +- 同一目标命中多个同长度模块时返回 `ambiguous`。 +- 跨模块任务分别解析,不合并为仓库级单一 Profile。 + +## 候选选择 + +1. 项目已确认 `preferredProfiles` 时,先检查对应提供方是否被发现。 +2. 没有偏好时按语言、框架、版本和构建证据筛选。 +3. 优先版本范围更窄、探测证据更具体的候选。 +4. 仍有多个候选时按较高 `priority` 选择。 +5. 排序后仍不能唯一确定时返回 `ambiguous`。 +6. 没有版本匹配时,只允许加载 `*` 的版本中性 Profile。 + +项目事实和清单冲突时保留项目事实,返回 `incompatible`,不能建议自动升级项目依赖。 + diff --git a/plugins/profile/skills/resolve/scripts/validate_profile.py b/plugins/profile/skills/resolve/scripts/validate_profile.py new file mode 100644 index 0000000..c1d77e5 --- /dev/null +++ b/plugins/profile/skills/resolve/scripts/validate_profile.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""校验 CraftKit 技术 Profile 清单的结构、标识、版本范围和资料引用。""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +CAPABILITIES = { + "project-detection", + "knowledge-routing", + "backend-design", + "backend-implementation", + "backend-testing", + "language-review", + "framework-review", + "command-resolution", +} +KINDS = {"language", "framework", "toolchain"} +SCHEMES = {"semver", "pep440", "java-feature", "generic"} +ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +PROFILE_PATTERN = re.compile(r"^[a-z0-9-]+/[a-z0-9-]+$") +VERSION_PATTERN = re.compile(r"^(?:\*|(?:>=|>|<=|<|==)?\d+(?:\.\d+){0,2}(?:,(?:>=|>|<=|<|==)\d+(?:\.\d+){0,2})*)$") + + +def _require_mapping(value: Any, label: str, errors: list[str]) -> dict[str, Any]: + """把对象字段收窄为字典,并把类型错误加入统一错误集合。""" + if not isinstance(value, dict): + errors.append(f"{label} 必须是对象") + return {} + return value + + +def _validate_reference(skill_root: Path, value: Any, label: str, errors: list[str]) -> None: + """保证资料引用为 Skill 内相对文件,阻止绝对路径和目录逃逸。""" + if not isinstance(value, str) or not value: + errors.append(f"{label} 必须是非空相对路径") + return + relative = Path(value) + if relative.is_absolute() or ".." in relative.parts: + errors.append(f"{label} 不能使用绝对路径或目录逃逸: {value}") + return + target = (skill_root / relative).resolve() + try: + target.relative_to(skill_root.resolve()) + except ValueError: + errors.append(f"{label} 超出 Skill 目录: {value}") + return + if not target.is_file(): + errors.append(f"{label} 引用文件不存在: {value}") + elif target.stat().st_size == 0: + errors.append(f"{label} 引用文件为空: {value}") + + +def validate_manifest(path: Path) -> list[str]: + """返回全部可确定的契约错误,便于一次修复多个问题。""" + errors: list[str] = [] + try: + data = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + return [f"无法读取 JSON: {exc}"] + + root = _require_mapping(data, "根节点", errors) + if root.get("schemaVersion") != 1: + errors.append("schemaVersion 必须为 1") + + provider = _require_mapping(root.get("provider"), "provider", errors) + provider_id = provider.get("id") + if not isinstance(provider_id, str) or not ID_PATTERN.fullmatch(provider_id): + errors.append("provider.id 必须是小写短横线标识") + if provider.get("kind") not in KINDS: + errors.append("provider.kind 不受支持") + if not isinstance(provider.get("displayName"), str) or not provider.get("displayName"): + errors.append("provider.displayName 必须是非空字符串") + + profiles = root.get("profiles") + if not isinstance(profiles, list) or not profiles: + errors.append("profiles 必须是非空数组") + return errors + + seen: set[str] = set() + skill_root = path.parent.parent + for index, raw_profile in enumerate(profiles): + label = f"profiles[{index}]" + profile = _require_mapping(raw_profile, label, errors) + profile_id = profile.get("id") + if not isinstance(profile_id, str) or not PROFILE_PATTERN.fullmatch(profile_id): + errors.append(f"{label}.id 格式无效") + elif profile_id in seen: + errors.append(f"{label}.id 重复: {profile_id}") + else: + seen.add(profile_id) + if isinstance(provider_id, str) and isinstance(profile_id, str) and not profile_id.startswith(provider_id + "/"): + errors.append(f"{label}.id 必须使用 provider.id 作为前缀") + + version = _require_mapping(profile.get("version"), f"{label}.version", errors) + if version.get("scheme") not in SCHEMES: + errors.append(f"{label}.version.scheme 不受支持") + version_range = version.get("range") + if not isinstance(version_range, str) or not VERSION_PATTERN.fullmatch(version_range): + errors.append(f"{label}.version.range 格式无效") + if not isinstance(profile.get("priority"), int) or isinstance(profile.get("priority"), bool): + errors.append(f"{label}.priority 必须是整数") + if not isinstance(profile.get("detect"), dict): + errors.append(f"{label}.detect 必须是对象") + + capabilities = _require_mapping(profile.get("capabilities"), f"{label}.capabilities", errors) + if not capabilities: + errors.append(f"{label}.capabilities 不能为空") + for capability, reference in capabilities.items(): + if capability not in CAPABILITIES: + errors.append(f"{label}.capabilities 包含未知能力: {capability}") + _validate_reference(skill_root, reference, f"{label}.capabilities.{capability}", errors) + _validate_reference(skill_root, profile.get("fallback"), f"{label}.fallback", errors) + return errors + + +def main() -> int: + """解析命令行参数并以退出码表达校验结果。""" + parser = argparse.ArgumentParser(description="校验 CraftKit Profile manifest.json") + parser.add_argument("manifests", nargs="+", type=Path, help="一个或多个 manifest.json 路径") + args = parser.parse_args() + failed = False + for manifest in args.manifests: + errors = validate_manifest(manifest.resolve()) + if errors: + failed = True + print(f"FAIL {manifest}") + for error in errors: + print(f" - {error}") + else: + print(f"PASS {manifest}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())