From 7f7fe05a539aa08575df00f8331e6e57306affaf Mon Sep 17 00:00:00 2001 From: liguochuan <292761894@qq.com> Date: Mon, 17 Aug 2026 16:26:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=9A=80=20feat(core):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20DeepSeek=20Harness=20=E5=8E=9F=E7=94=9F=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 10 +- package.json | 2 +- packages/cli/package.json | 4 +- packages/core/README.md | 22 +++ packages/core/capabilities.json | 7 +- packages/core/index.d.ts | 327 +++++++++++++++++++++++++++++++ packages/core/index.js | 40 +++- packages/core/lib/resources.js | 242 +++++++++++++++++++++++ packages/core/lib/updateCheck.js | 231 ++++++++++++++++++++++ packages/core/package.json | 4 +- scripts/test-core-api.js | 63 ++++++ scripts/test-package-install.js | 9 +- 12 files changed, 947 insertions(+), 14 deletions(-) create mode 100644 packages/core/index.d.ts create mode 100644 packages/core/lib/resources.js create mode 100644 packages/core/lib/updateCheck.js diff --git a/package-lock.json b/package-lock.json index dcf4b2a..3c9d022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "flow2spec-workspace", - "version": "3.3.1", + "version": "3.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flow2spec-workspace", - "version": "3.3.1", + "version": "3.4.0", "workspaces": [ "packages/core", "packages/cli" @@ -25,10 +25,10 @@ }, "packages/cli": { "name": "@double-coding/flow2spec", - "version": "3.3.1", + "version": "3.4.0", "license": "ISC", "dependencies": { - "@double-coding/flow2spec-core": "3.3.1" + "@double-coding/flow2spec-core": "3.4.0" }, "bin": { "flow2spec": "cli.js" @@ -39,7 +39,7 @@ }, "packages/core": { "name": "@double-coding/flow2spec-core", - "version": "3.3.1", + "version": "3.4.0", "license": "ISC", "engines": { "node": ">=16" diff --git a/package.json b/package.json index 4436f05..432d88f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "flow2spec-workspace", "private": true, - "version": "3.3.1", + "version": "3.4.0", "description": "Flow2Spec workspace for the Core library and CLI packages", "workspaces": [ "packages/core", diff --git a/packages/cli/package.json b/packages/cli/package.json index 4aa7373..08698be 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@double-coding/flow2spec", - "version": "3.3.1", + "version": "3.4.0", "description": "在业务仓库初始化文档驱动、可写回知识库的 AI 协作骨架", "homepage": "https://github.com/double-coding-lab/Flow2Spec#readme", "repository": { @@ -20,7 +20,7 @@ "README.md" ], "dependencies": { - "@double-coding/flow2spec-core": "3.3.1" + "@double-coding/flow2spec-core": "3.4.0" }, "publishConfig": { "access": "public", diff --git a/packages/core/README.md b/packages/core/README.md index 3901612..40cf9be 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -3,3 +3,25 @@ Flow2Spec 的程序化核心能力包,供 CLI 和原生开发工具插件共同使用。 普通用户通常不需要单独安装此包;安装 `@double-coding/flow2spec` 或 Flow2Spec 原生插件时会自动带上对应版本的 Core。 + +## 原生插件 API + +```js +const { createFlow2Spec } = require("@double-coding/flow2spec-core"); + +const flow2spec = createFlow2Spec({ cwd: process.cwd() }); +const skills = flow2spec.resources.skillCatalog({ host: "dsh", locale: "zh-CN" }); +const entry = flow2spec.resources.unifiedEntry({ + host: "dsh", + locale: "zh-CN", + projectConfig: flow2spec.config.load(), +}); +const update = await flow2spec.update.check(); +``` + +- `resources.skillCatalog()` 返回带正文和关联规则资源的结构化 Skill 清单。Core 根据宿主适配规则与 Skill 路径,插件不需要重写其他客户端目录。 +- `resources.unifiedEntry()` 返回宿主适配后的统一入口,可附带当前项目配置摘要。 +- `update.check()` 复用 `.Knowledge/update-check.json` 的每日缓存与版本比较语义;网络不可用时返回 `unavailable`,不会阻断宿主。 +- `capabilities.json` 的 `protocolVersion` 用于插件启动时执行能力兼容校验。 + +包通过 `index.d.ts` 导出完整公共契约类型。普通 CLI 用户继续使用 `npx @double-coding/flow2spec init`,无需直接调用这些 API。 diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index 44cdd51..675136e 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -1,6 +1,6 @@ { "schema": "flow2spec.capabilities.v1", - "protocolVersion": 1, + "protocolVersion": 2, "package": "@double-coding/flow2spec-core", "capabilities": [ { "id": "project.init", "api": "project.init", "since": "3.3.0" }, @@ -22,6 +22,9 @@ { "id": "resources.capabilities", "api": "resources.capabilities", "since": "3.3.0" }, { "id": "resources.skills", "api": "resources.listSkills", "since": "3.3.0" }, { "id": "resources.rules", "api": "resources.listRules", "since": "3.3.0" }, - { "id": "resources.read", "api": "resources.read", "since": "3.3.0" } + { "id": "resources.read", "api": "resources.read", "since": "3.3.0" }, + { "id": "resources.skill-catalog", "api": "resources.skillCatalog", "since": "3.4.0" }, + { "id": "resources.unified-entry", "api": "resources.unifiedEntry", "since": "3.4.0" }, + { "id": "update.check", "api": "update.check", "since": "3.4.0" } ] } diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts new file mode 100644 index 0000000..317503c --- /dev/null +++ b/packages/core/index.d.ts @@ -0,0 +1,327 @@ +export type Flow2SpecLocale = "zh-CN" | "en-US"; +export type Flow2SpecHost = "dsh" | "cursor" | "claude" | "codex"; +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export interface Flow2SpecProjectConfig { + subAgent?: boolean; + switchAgentVerification?: boolean; + intentRecognition?: boolean; + locale?: Flow2SpecLocale; + changeTracking?: { + feat?: boolean; + fix?: boolean; + implement?: boolean; + [key: string]: boolean | undefined; + }; + updateCheck?: { + enabled?: boolean; + [key: string]: JsonValue | undefined; + }; + collaboration?: { + enabled?: boolean; + developerId?: string; + [key: string]: JsonValue | undefined; + }; + [key: string]: unknown; +} + +export interface CreateFlow2SpecOptions { + cwd?: string; + signal?: AbortSignal; + onProgress?: (event: unknown) => void; +} + +export interface ProjectInitOptions { + mode?: "native-host" | "project-adapter" | string; + integrations?: Flow2SpecHost[] | string[]; + locale?: Flow2SpecLocale; + overwriteKnowledge?: boolean; + configValues?: Partial; + [key: string]: unknown; +} + +export interface ProjectInitResult { + ids: string[]; + mode: string; + overwriteKnowledge: boolean; + locale: Flow2SpecLocale; + projectConfig: Flow2SpecProjectConfig; + [key: string]: unknown; +} + +export interface ProjectInspection { + cwd: string; + config: Flow2SpecProjectConfig; +} + +export interface RoutingRule { + task?: string; + matcherId?: string; + matcherPath?: string; + topics?: string[]; + [key: string]: JsonValue | undefined; +} + +export interface RoutingCandidate { + rule: RoutingRule | null; + score: number; + order?: number; + confidence: "high" | "medium" | "low"; + matchedPhrases: string[]; + topics: string[]; + fallback?: boolean; +} + +export interface RoutingMatchInput { + request?: string; + query?: string; + task?: string; +} + +export interface RoutingMatchResult { + request: string; + task: string | null; + primary: RoutingCandidate; + alternatives: RoutingCandidate[]; + candidates: RoutingCandidate[]; + manifestVersion: string; + topics?: string[]; +} + +export interface RoutingMissingContext { + kind: "topic" | "context"; + id?: string; + path: string | null; +} + +export interface RoutingVerification { + ok: boolean; + missing: RoutingMissingContext[]; + confidence: "high" | "medium" | "low"; + fallback: boolean; +} + +export interface RoutingContextFile { + topic: string; + path: string; + content: string; + truncated: boolean; +} + +export interface RoutingContext { + files: RoutingContextFile[]; + lineCount: number; + truncated: boolean; +} + +export interface KnowledgeValidation { + ok: boolean; + issues: string[]; + warnings: string[]; + topicCount: number; +} + +export type KnowledgeDeltaChangeType = + | "createTopic" + | "appendBody" + | "replaceBody" + | "updateFrontmatter"; + +export interface KnowledgeDeltaChange { + type: KnowledgeDeltaChangeType | string; + targetTopic: string; + content?: string; + frontmatter?: Record; + [key: string]: unknown; +} + +export interface KnowledgeDelta { + taskId: string; + developerId: string; + baseRevisions: Record; + changes: KnowledgeDeltaChange[]; + notes?: string; +} + +export interface KnowledgePlanResult { + delta: KnowledgeDelta; + plan: unknown[]; + conflicts: unknown[]; + mergeable: boolean; + [key: string]: unknown; +} + +export interface KnowledgeApplyResult { + dryRun: boolean; + changedFiles: string[]; + plan: unknown[]; + [key: string]: unknown; +} + +export interface DeveloperContext { + developerId: string | null; + source: "config" | "git-email" | "git-email-hash" | "git-name" | "git-name-hash" | "legacy"; + legacy: boolean; + taskRoot: string; + enabled: boolean; + warnings: string[]; +} + +export type DoctorCheckStatus = "pass" | "warning" | "error"; + +export interface DoctorCheck { + id: string; + label: string; + status: DoctorCheckStatus; + message: string; + repair: string | null; + details?: unknown; +} + +export interface DoctorReport { + ok: boolean; + package: { name: string; version: string }; + cwd: string; + summary: { passed: number; warnings: number; errors: number }; + checks: DoctorCheck[]; +} + +export interface CapabilityDefinition { + id: string; + api: string; + since: string; +} + +export interface CapabilityManifest { + schema: "flow2spec.capabilities.v1" | string; + protocolVersion: number; + package: string; + capabilities: CapabilityDefinition[]; +} + +export interface HostResourceOptions { + host: Flow2SpecHost; + locale?: Flow2SpecLocale; + projectConfig?: Flow2SpecProjectConfig; +} + +export interface Flow2SpecTextResource { + relativePath: string; + content: string; + mediaType: "text/markdown"; +} + +export interface Flow2SpecSkillResource { + name: string; + description: string; + content: string; + relativePath: string; + resources: readonly Flow2SpecTextResource[]; +} + +export type UpdateCheckStatus = + | "disabled" + | "skipped" + | "current" + | "upgrade-available" + | "unavailable"; + +export interface UpdateCheckOptions { + packageName?: string; + force?: boolean; + signal?: AbortSignal; + timeout?: number; +} + +export interface UpdateCheckResult { + status: UpdateCheckStatus; + checked: boolean; + fromCache: boolean; + packageName: string; + manifestVersion: string | null; + latestVersion: string | null; + needsUpgrade: boolean; + notice: string; + checkedAt: number | null; + reason: string | null; +} + +export interface Flow2SpecApi { + context: { + cwd: string; + signal?: AbortSignal; + onProgress: (event: unknown) => void; + }; + project: { + init(options?: ProjectInitOptions): Promise; + inspect(): ProjectInspection; + }; + config: { + load(): Flow2SpecProjectConfig; + missingFields(): unknown[]; + }; + routing: { + graph(): unknown; + state(): { graph: unknown; validation: KnowledgeValidation }; + match(input?: RoutingMatchInput): RoutingMatchResult; + expand(result: RoutingMatchResult): RoutingMatchResult & { topics: string[] }; + verify( + result: RoutingMatchResult, + options?: { requiredContext?: string[] }, + ): RoutingVerification; + loadContext( + result: RoutingMatchResult, + options?: { maxFiles?: number; maxLines?: number }, + ): RoutingContext; + }; + knowledge: { + status(options?: Record): unknown; + check(options?: { strict?: boolean; strictRevision?: boolean }): KnowledgeValidation; + plan(options?: { delta?: KnowledgeDelta; deltaFile?: string }): KnowledgePlanResult; + apply(options?: { + delta?: KnowledgeDelta; + deltaFile?: string; + dryRun?: boolean; + planHash?: string; + [key: string]: unknown; + }): KnowledgeApplyResult; + build(options?: Record): unknown; + }; + collaboration: { + resolveDeveloper(options?: Record): DeveloperContext; + }; + doctor: { + run(options?: Record): DoctorReport; + }; + resources: { + root: string; + capabilities(): CapabilityManifest; + listSkills(locale?: Flow2SpecLocale): string[]; + listRules(locale?: Flow2SpecLocale): string[]; + listHooks(locale?: Flow2SpecLocale): string[]; + read(relativePath: string, locale?: Flow2SpecLocale): string; + skillCatalog(options: HostResourceOptions): Flow2SpecSkillResource[]; + unifiedEntry(options: HostResourceOptions): string; + }; + update: { + check(options?: UpdateCheckOptions): Promise; + }; +} + +export class Flow2SpecError extends Error { + constructor( + code: string, + message: string, + details?: Record, + options?: { recoverable?: boolean }, + ); + code: string; + details: Record; + recoverable: boolean; +} + +export function createFlow2Spec(options?: CreateFlow2SpecOptions): Flow2SpecApi; +export function getCapabilities(): CapabilityManifest; +export const resourcesRoot: string; +export const legacy: Record; diff --git a/packages/core/index.js b/packages/core/index.js index 3023d68..f9ec5f8 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -13,6 +13,8 @@ const dshAgentsAdapter = require("./lib/dshAgentsAdapter"); const knowledgeEngine = require("./lib/knowledgeEngine"); const init = require("./lib/init"); const routing = require("./lib/routing"); +const hostResources = require("./lib/resources"); +const updateCheck = require("./lib/updateCheck"); const capabilities = require("./capabilities.json"); class Flow2SpecError extends Error { @@ -74,6 +76,30 @@ function readResource(relativePath, locale = "zh-CN") { return fs.readFileSync(resolved, "utf8"); } +function toFlow2SpecError(error) { + if (error instanceof Flow2SpecError) return error; + if (error && typeof error.code === "string" && error.code.startsWith("F2S_")) { + return new Flow2SpecError(error.code, error.message || String(error), error.details || {}); + } + return error; +} + +function callCore(operation) { + try { + return operation(); + } catch (error) { + throw toFlow2SpecError(error); + } +} + +async function callCoreAsync(operation) { + try { + return await operation(); + } catch (error) { + throw toFlow2SpecError(error); + } +} + function createFlow2Spec(options = {}) { const cwd = assertCwd(options.cwd || process.cwd()); const context = { @@ -112,8 +138,8 @@ function createFlow2Spec(options = {}) { const parsed = delta || knowledgeEngine.parseKnowledgeDelta(deltaFile); return knowledgeEngine.planKnowledgeDelta(graph, parsed); }, - apply: ({ deltaFile, ...applyOptions } = {}) => - knowledgeEngine.applyKnowledgeDelta(cwd, deltaFile, applyOptions), + apply: ({ delta, deltaFile, ...applyOptions } = {}) => + knowledgeEngine.applyKnowledgeDelta(cwd, delta || deltaFile, applyOptions), build: (buildOptions = {}) => knowledgeEngine.buildKnowledgeGraph(cwd, buildOptions), }, collaboration: { @@ -133,6 +159,16 @@ function createFlow2Spec(options = {}) { listRules: (locale = "zh-CN") => listResourceFiles(locale, "rules"), listHooks: (locale = "zh-CN") => listResourceFiles(locale, "hooks"), read: (relativePath, locale = "zh-CN") => readResource(relativePath, locale), + skillCatalog: (resourceOptions = {}) => + callCore(() => hostResources.skillCatalog(path.join(__dirname, "templates"), resourceOptions)), + unifiedEntry: (resourceOptions = {}) => + callCore(() => hostResources.unifiedEntry(path.join(__dirname, "templates"), resourceOptions)), + }, + update: { + check: (checkOptions = {}) => + callCoreAsync(() => + updateCheck.checkUpdate(cwd, config.loadFlow2specConfig(cwd), checkOptions), + ), }, }; } diff --git a/packages/core/lib/resources.js b/packages/core/lib/resources.js new file mode 100644 index 0000000..7003e0b --- /dev/null +++ b/packages/core/lib/resources.js @@ -0,0 +1,242 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { normalizeLocale, DEFAULT_LOCALE } = require("./flow2specConfig"); + +const SUPPORTED_HOSTS = new Set(["dsh", "cursor", "claude", "codex"]); +const HOST_PATHS = { + dsh: { rules: "rules", skills: "skills", ruleExtension: ".md" }, + cursor: { rules: ".cursor/rules", skills: ".cursor/skills", ruleExtension: ".mdc" }, + claude: { rules: ".claude/rules", skills: ".claude/skills", ruleExtension: ".md" }, + codex: { rules: ".codex/topics", skills: ".codex/skills", ruleExtension: ".md" }, +}; + +function resourceError(code, message, details = {}) { + const error = new Error(message); + error.code = code; + error.details = details; + return error; +} + +function normalizeOptions(options = {}) { + const host = String(options.host || "").trim().toLowerCase(); + if (!SUPPORTED_HOSTS.has(host)) { + throw resourceError( + "F2S_INVALID_ARGUMENT", + `unsupported resource host: ${options.host || ""}`, + { field: "host", supported: Array.from(SUPPORTED_HOSTS) }, + ); + } + return { + host, + locale: normalizeLocale(options.locale, DEFAULT_LOCALE), + projectConfig: options.projectConfig, + }; +} + +function templatesDir(templatesRoot, locale) { + return path.join(templatesRoot, locale); +} + +function parseSkillDocument(raw, relativePath) { + const match = String(raw).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!match) { + throw resourceError("F2S_RESOURCE_INVALID", `skill frontmatter missing: ${relativePath}`, { + relativePath, + }); + } + const frontmatter = {}; + for (const line of match[1].split(/\r?\n/)) { + const separator = line.indexOf(":"); + if (separator < 0) continue; + const key = line.slice(0, separator).trim(); + let value = line.slice(separator + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + frontmatter[key] = value; + } + if (!frontmatter.name || !frontmatter.description) { + throw resourceError( + "F2S_RESOURCE_INVALID", + `skill name or description missing: ${relativePath}`, + { relativePath }, + ); + } + return { + name: frontmatter.name, + description: frontmatter.description, + body: raw.slice(match[0].length), + }; +} + +function ruleReferenceIds(content) { + const ids = new Set(["f2s-flow2spec-unified-entry", "f2s-config-check"]); + const pattern = /(?:(?:\.codex|\.dsh)\/topics\/|(?:\.cursor|\.claude)\/rules\/|rules\/)(f2s-[a-zA-Z0-9-]+)/g; + let match; + while ((match = pattern.exec(content))) ids.add(match[1]); + return Array.from(ids).sort(); +} + +function hostRulePath(profile, ruleId) { + return `${profile.rules}/${ruleId}${profile.ruleExtension}`; +} + +function hostSkillPath(profile, skillName) { + return `${profile.skills}/${skillName}/SKILL.md`; +} + +function nativeSkillName(name, host) { + if (host !== "dsh") return name; + return String(name) + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .toLowerCase(); +} + +function adaptNativeSkillNames(content, host) { + if (host !== "dsh") return String(content); + return String(content).replace(/\bf2s-[a-zA-Z0-9-]*[A-Z][a-zA-Z0-9-]*\b/g, (name) => + nativeSkillName(name, host), + ); +} + +function adaptHostPaths(content, host) { + const profile = HOST_PATHS[host]; + const adapted = String(content) + .replace( + /(?:(?:\.codex|\.dsh)\/topics\/|(?:\.cursor|\.claude)\/rules\/|rules\/)(f2s-[a-zA-Z0-9-]+)(?:\.(?:mdc?|\*))?/g, + (_, ruleId) => hostRulePath(profile, ruleId), + ) + .replace( + /(?:(?:\.codex|\.dsh|\.cursor|\.claude)\/skills\/|skills\/)(f2s-[a-zA-Z0-9-]+)\/SKILL\.md/g, + (_, skillName) => hostSkillPath(profile, skillName), + ); + if (host !== "dsh") return adapted; + // Native DSH resources are supplied by the provider, not copied into another + // client's project directory. Keep any remaining ancillary paths provider-relative. + return adaptNativeSkillNames( + adapted.replace(/\.(?:codex|cursor|claude|dsh)\//g, ""), + host, + ); +} + +function replaceSection(content, startHeading, endHeading, replacement) { + const start = content.indexOf(startHeading); + if (start < 0) return content; + const end = content.indexOf(endHeading, start + startHeading.length); + if (end < 0) return content; + return `${content.slice(0, start)}${replacement.trim()}\n\n${content.slice(end)}`; +} + +function adaptNativeDshEntry(content, locale) { + if (locale === "en-US") { + return replaceSection( + content, + "## Knowledge Base Version Check", + "## Topic Authoring Pointer", + `## Knowledge Base Version Check + +The native host calls Core \`update.check()\` on session start. The API respects the project \`updateCheck.enabled\` switch and the daily \`.Knowledge/update-check.json\` cache. A notice is displayed when the published Core is newer than the project knowledge version. This check only detects and reports updates; it does not replace the configuration-read or knowledge-routing gates.`, + ); + } + return replaceSection( + content, + "## 知识库版本自检", + "## 主题创作", + `## 知识库版本自检 + +原生宿主在会话启动时调用 Core \`update.check()\`。该 API 服从项目的 \`updateCheck.enabled\` 开关并复用每日 \`.Knowledge/update-check.json\` 缓存;Core 发布版本高于项目知识版本时,由宿主展示升级提示。版本检查只负责检测与提醒,不替代配置前置读取和知识路由门禁。`, + ); +} + +function configSummary(projectConfig, locale) { + if (!projectConfig || typeof projectConfig !== "object") return ""; + const known = { + subAgent: projectConfig.subAgent, + switchAgentVerification: projectConfig.switchAgentVerification, + intentRecognition: projectConfig.intentRecognition, + locale: projectConfig.locale, + changeTracking: projectConfig.changeTracking, + updateCheck: projectConfig.updateCheck, + collaboration: projectConfig.collaboration, + }; + for (const key of Object.keys(known)) { + if (known[key] === undefined) delete known[key]; + } + if (Object.keys(known).length === 0) return ""; + const heading = locale === "en-US" ? "## Current Project Configuration" : "## 当前项目配置"; + const note = + locale === "en-US" + ? "This snapshot is contextual only. Read `flow2spec.config.json` again before running any `f2s-*` skill." + : "此摘要只提供当前上下文。执行任何 `f2s-*` 技能前仍须重新读取 `flow2spec.config.json`。"; + return `${heading}\n\n${note}\n\n\`\`\`json\n${JSON.stringify(known, null, 2)}\n\`\`\`\n\n`; +} + +function readRule(templatesRoot, locale, ruleId) { + const relativePath = path.posix.join("rules", `${ruleId}.md`); + const absolutePath = path.join(templatesDir(templatesRoot, locale), ...relativePath.split("/")); + if (!fs.existsSync(absolutePath)) return null; + return { relativePath, content: fs.readFileSync(absolutePath, "utf8") }; +} + +function skillCatalog(templatesRoot, options = {}) { + const { host, locale } = normalizeOptions(options); + const profile = HOST_PATHS[host]; + const skillsRoot = path.join(templatesDir(templatesRoot, locale), "skills"); + if (!fs.existsSync(skillsRoot)) return []; + return fs + .readdirSync(skillsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => { + const sourceRelativePath = path.posix.join("skills", entry.name, "SKILL.md"); + const sourcePath = path.join(skillsRoot, entry.name, "SKILL.md"); + if (!fs.existsSync(sourcePath)) { + throw resourceError("F2S_RESOURCE_MISSING", `skill resource missing: ${sourceRelativePath}`, { + relativePath: sourceRelativePath, + locale, + }); + } + const raw = fs.readFileSync(sourcePath, "utf8"); + const parsed = parseSkillDocument(raw, sourceRelativePath); + const skillName = nativeSkillName(parsed.name, host); + const resources = ruleReferenceIds(raw) + .map((ruleId) => readRule(templatesRoot, locale, ruleId)) + .filter(Boolean) + .map((resource) => ({ + relativePath: hostRulePath(profile, path.basename(resource.relativePath, ".md")), + content: adaptHostPaths(resource.content, host), + mediaType: "text/markdown", + })); + return { + name: skillName, + description: adaptNativeSkillNames(parsed.description, host), + content: adaptHostPaths(parsed.body, host), + relativePath: hostSkillPath(profile, skillName), + resources, + }; + }); +} + +function unifiedEntry(templatesRoot, options = {}) { + const { host, locale, projectConfig } = normalizeOptions(options); + const entry = readRule(templatesRoot, locale, "f2s-flow2spec-unified-entry"); + if (!entry) { + throw resourceError("F2S_RESOURCE_MISSING", "unified entry resource is missing", { locale }); + } + let content = adaptHostPaths(entry.content, host); + if (host === "dsh") content = adaptNativeDshEntry(content, locale); + return `${configSummary(projectConfig, locale)}${content}`; +} + +module.exports = { + SUPPORTED_HOSTS, + adaptHostPaths, + skillCatalog, + unifiedEntry, +}; diff --git a/packages/core/lib/updateCheck.js b/packages/core/lib/updateCheck.js new file mode 100644 index 0000000..f012de6 --- /dev/null +++ b/packages/core/lib/updateCheck.js @@ -0,0 +1,231 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execFile } = require("child_process"); + +const DEFAULT_PACKAGE_NAME = "@double-coding/flow2spec-core"; +const KNOWLEDGE_ROOT = ".Knowledge"; +const CACHE_FILENAME = "update-check.json"; + +function parseVersion(version) { + return String(version || "") + .replace(/^v/, "") + .split(/[.-]/) + .slice(0, 3) + .map((part) => { + const number = Number.parseInt(part, 10); + return Number.isFinite(number) ? number : 0; + }); +} + +function compareVersions(left, right) { + const a = parseVersion(left); + const b = parseVersion(right); + for (let index = 0; index < 3; index += 1) { + const difference = (a[index] || 0) - (b[index] || 0); + if (difference !== 0) return difference; + } + return 0; +} + +function abortError() { + const error = new Error("update check aborted"); + error.code = "F2S_ABORTED"; + error.details = { operation: "update.check" }; + return error; +} + +function assertNotAborted(signal) { + if (signal?.aborted) throw abortError(); +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (_) { + return null; + } +} + +function sameLocalDay(timestamp, now) { + const checkedAt = Number(timestamp || 0); + return checkedAt > 0 && new Date(checkedAt).toDateString() === new Date(now).toDateString(); +} + +function projectName(cwd) { + const pkg = readJson(path.join(cwd, "package.json")); + return pkg?.name ? String(pkg.name) : path.basename(cwd); +} + +function buildNotice({ cwd, locale, manifestVersion, latestVersion }) { + if (locale === "en-US") { + return `[flow2spec] The project "${projectName(cwd)}" knowledge version is v${manifestVersion}; Core v${latestVersion} is available. Run the f2s-kb-upgrade skill to align templates and routing.`; + } + return `[flow2spec] 当前项目「${projectName(cwd)}」知识版本为 v${manifestVersion},Core 最新版本为 v${latestVersion}。可执行 f2s-kb-upgrade skill 对齐模板与路由。`; +} + +function queryLatestVersion(packageName, options = {}) { + const npmExecutable = process.platform === "win32" ? "npm.cmd" : "npm"; + return new Promise((resolve, reject) => { + execFile( + npmExecutable, + ["view", packageName, "version", "--registry=https://registry.npmjs.org"], + { + encoding: "utf8", + timeout: options.timeout || 5000, + maxBuffer: 1024 * 1024, + signal: options.signal, + windowsHide: true, + }, + (error, stdout) => { + if (options.signal?.aborted || error?.name === "AbortError" || error?.code === "ABORT_ERR") { + reject(abortError()); + return; + } + if (error) { + reject(error); + return; + } + resolve(String(stdout || "").trim()); + }, + ); + }); +} + +function result(status, values = {}) { + return { + status, + checked: false, + fromCache: false, + packageName: values.packageName || DEFAULT_PACKAGE_NAME, + manifestVersion: values.manifestVersion || null, + latestVersion: values.latestVersion || null, + needsUpgrade: status === "upgrade-available", + notice: values.notice || "", + checkedAt: values.checkedAt || null, + reason: values.reason || null, + ...values, + }; +} + +async function checkUpdate(cwd, config, options = {}) { + assertNotAborted(options.signal); + const packageName = options.packageName || DEFAULT_PACKAGE_NAME; + const locale = config?.locale === "en-US" ? "en-US" : "zh-CN"; + if (config?.updateCheck?.enabled === false) { + return result("disabled", { packageName, reason: "config-disabled" }); + } + if (!options.force && (process.env.CI || process.env.CONTINUOUS_INTEGRATION)) { + return result("skipped", { packageName, reason: "continuous-integration" }); + } + + const knowledgeDir = path.join(cwd, KNOWLEDGE_ROOT); + const manifestPath = path.join(knowledgeDir, "manifest-routing.json"); + const cachePath = path.join(knowledgeDir, CACHE_FILENAME); + const manifestVersion = readJson(manifestPath)?.version || null; + if (!manifestVersion) { + return result("skipped", { packageName, reason: "manifest-missing" }); + } + + const now = Date.now(); + const cache = readJson(cachePath); + const cachePackageMatches = cache?.packageName + ? cache.packageName === packageName + : packageName === DEFAULT_PACKAGE_NAME; + if (!options.force && cache && cachePackageMatches && sameLocalDay(cache.checkedAt, now)) { + const latestVersion = cache.latestVersion || cache.latestNpm || null; + if (latestVersion && compareVersions(manifestVersion, latestVersion) >= 0) { + try { + fs.rmSync(cachePath, { force: true }); + } catch (_) {} + return result("current", { + checked: true, + fromCache: true, + packageName, + manifestVersion, + latestVersion, + checkedAt: Number(cache.checkedAt), + }); + } + const needsUpgrade = + Boolean(latestVersion) && + (cache.needsUpgrade === true || compareVersions(manifestVersion, latestVersion) < 0); + return result(needsUpgrade ? "upgrade-available" : "current", { + checked: true, + fromCache: true, + packageName, + manifestVersion, + latestVersion, + needsUpgrade, + notice: needsUpgrade + ? buildNotice({ cwd, locale, manifestVersion, latestVersion }) + : "", + checkedAt: Number(cache.checkedAt), + }); + } + + let latestVersion; + try { + latestVersion = await queryLatestVersion(packageName, { + signal: options.signal, + timeout: options.timeout, + }); + } catch (error) { + if (error?.code === "F2S_ABORTED") throw error; + return result("unavailable", { + packageName, + manifestVersion, + reason: "registry-unavailable", + }); + } + assertNotAborted(options.signal); + if (!latestVersion) { + return result("unavailable", { + packageName, + manifestVersion, + reason: "empty-registry-version", + }); + } + + const needsUpgrade = compareVersions(manifestVersion, latestVersion) < 0; + const notice = needsUpgrade + ? buildNotice({ cwd, locale, manifestVersion, latestVersion }) + : ""; + const checkedAt = Date.now(); + try { + fs.mkdirSync(knowledgeDir, { recursive: true }); + fs.writeFileSync( + cachePath, + `${JSON.stringify( + { + packageName, + latestVersion, + latestNpm: latestVersion, + manifestVersion, + needsUpgrade, + notice, + checkedAt, + }, + null, + 2, + )}\n`, + "utf8", + ); + } catch (_) {} + return result(needsUpgrade ? "upgrade-available" : "current", { + checked: true, + packageName, + manifestVersion, + latestVersion, + needsUpgrade, + notice, + checkedAt, + }); +} + +module.exports = { + DEFAULT_PACKAGE_NAME, + compareVersions, + checkUpdate, +}; diff --git a/packages/core/package.json b/packages/core/package.json index 0c4a3f3..b9eaa89 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@double-coding/flow2spec-core", - "version": "3.3.1", + "version": "3.4.0", "description": "Flow2Spec Core APIs, knowledge engine, project initialization and shared resources", "homepage": "https://github.com/double-coding-lab/Flow2Spec#readme", "repository": { @@ -9,8 +9,10 @@ "directory": "packages/core" }, "main": "./index.js", + "types": "./index.d.ts", "files": [ "index.js", + "index.d.ts", "lib", "templates", "capabilities.json", diff --git a/scripts/test-core-api.js b/scripts/test-core-api.js index 6b963f9..9b9bb3b 100644 --- a/scripts/test-core-api.js +++ b/scripts/test-core-api.js @@ -17,6 +17,14 @@ async function main() { assert.strictEqual(typeof api.project.init, "function"); assert.strictEqual(typeof api.knowledge.check, "function"); assert.strictEqual(core.getCapabilities().schema, "flow2spec.capabilities.v1"); + assert.strictEqual(core.getCapabilities().protocolVersion, 2); + assert.deepStrictEqual( + core + .getCapabilities() + .capabilities.filter((capability) => capability.since === "3.4.0") + .map((capability) => capability.id), + ["resources.skill-catalog", "resources.unified-entry", "update.check"], + ); await api.project.init({ mode: "native-host", integrations: ["dsh"], locale: "zh-CN" }); assert.ok(fs.existsSync(path.join(tempDir, ".Knowledge"))); @@ -30,6 +38,61 @@ async function main() { assert.ok(expanded.topics.includes("flow2spec-dsh-adapter")); assert.strictEqual(api.routing.verify(expanded).ok, true); assert.ok(api.resources.listSkills().some((file) => file.endsWith("f2s-kb-sync/SKILL.md"))); + const catalog = api.resources.skillCatalog({ host: "dsh", locale: "zh-CN" }); + assert.strictEqual(catalog.length, api.resources.listSkills().length); + assert.ok(catalog.every((skill) => skill.name.startsWith("f2s-"))); + assert.ok(catalog.some((skill) => skill.name === "f2s-kb-add-rules")); + assert.ok(catalog.every((skill) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skill.name))); + assert.ok(catalog.every((skill) => skill.description && skill.content)); + assert.ok(catalog.every((skill) => skill.relativePath === `skills/${skill.name}/SKILL.md`)); + assert.ok(catalog.every((skill) => skill.resources.length >= 2)); + const dshResources = [ + ...catalog.map((skill) => skill.content), + ...catalog.flatMap((skill) => skill.resources.map((resource) => resource.content)), + ].join("\n"); + for (const foreignRoot of [".codex/", ".cursor/", ".claude/", ".dsh/"]) { + assert.ok(!dshResources.includes(foreignRoot), `native DSH resource leaked ${foreignRoot}`); + } + + const entry = api.resources.unifiedEntry({ + host: "dsh", + locale: "zh-CN", + projectConfig: { subAgent: false, locale: "zh-CN" }, + }); + assert.ok(entry.includes("\"subAgent\": false")); + assert.ok(entry.includes("update.check()")); + assert.ok(!entry.includes(".codex/")); + assert.throws( + () => api.resources.skillCatalog({ host: "unknown", locale: "zh-CN" }), + (error) => error instanceof core.Flow2SpecError && error.code === "F2S_INVALID_ARGUMENT", + ); + + const manifestPath = path.join(tempDir, ".Knowledge", "manifest-routing.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + manifest.version = "1.0.0"; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + const cachePath = path.join(tempDir, ".Knowledge", "update-check.json"); + fs.writeFileSync( + cachePath, + `${JSON.stringify({ + latestNpm: "9.0.0", + manifestVersion: "1.0.0", + needsUpgrade: true, + checkedAt: Date.now(), + })}\n`, + "utf8", + ); + const update = await api.update.check(); + assert.strictEqual(update.status, "upgrade-available"); + assert.strictEqual(update.fromCache, true); + assert.strictEqual(update.latestVersion, "9.0.0"); + assert.ok(update.notice.includes("f2s-kb-upgrade")); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + api.update.check({ signal: controller.signal }), + (error) => error instanceof core.Flow2SpecError && error.code === "F2S_ABORTED", + ); assert.ok(Array.isArray(events)); console.log("test-core-api: ok"); diff --git a/scripts/test-package-install.js b/scripts/test-package-install.js index 16e9775..1182536 100644 --- a/scripts/test-package-install.js +++ b/scripts/test-package-install.js @@ -47,11 +47,18 @@ const coreProbe = path.join(tempDir, "core-probe.js"); fs.writeFileSync( coreProbe, "const core = require('@double-coding/flow2spec-core');\n" + - "if (core.getCapabilities().protocolVersion !== 1) process.exit(1);\n", + "if (core.getCapabilities().protocolVersion !== 2) process.exit(1);\n" + + "if (typeof core.createFlow2Spec({ cwd: process.cwd() }).resources.skillCatalog !== 'function') process.exit(1);\n", "utf8", ); const coreCheck = run(process.execPath, [coreProbe], { cwd: tempDir }); assert.strictEqual(coreCheck, ""); +assert.ok( + fs.existsSync( + path.join(tempDir, "node_modules", "@double-coding", "flow2spec-core", "index.d.ts"), + ), + "Core package must publish TypeScript declarations", +); const cliPath = path.join(tempDir, "node_modules", "@double-coding", "flow2spec", "cli.js"); assert.ok(fs.existsSync(cliPath)); run(process.execPath, [cliPath, "--help"], { cwd: tempDir }); From 8753e47a3f285b6f0cfa4aec50e93d880787c07c Mon Sep 17 00:00:00 2001 From: liguochuan <292761894@qq.com> Date: Mon, 17 Aug 2026 16:40:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20test(core):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20CI=20=E7=8E=AF=E5=A2=83=E4=B8=8B=E7=9A=84=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=A3=80=E6=9F=A5=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/test-core-api.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/test-core-api.js b/scripts/test-core-api.js index 9b9bb3b..2356b6b 100644 --- a/scripts/test-core-api.js +++ b/scripts/test-core-api.js @@ -82,11 +82,27 @@ async function main() { })}\n`, "utf8", ); - const update = await api.update.check(); - assert.strictEqual(update.status, "upgrade-available"); - assert.strictEqual(update.fromCache, true); - assert.strictEqual(update.latestVersion, "9.0.0"); - assert.ok(update.notice.includes("f2s-kb-upgrade")); + const originalCI = process.env.CI; + const originalContinuousIntegration = process.env.CONTINUOUS_INTEGRATION; + process.env.CI = "true"; + const ciUpdate = await api.update.check(); + assert.strictEqual(ciUpdate.status, "skipped"); + assert.strictEqual(ciUpdate.reason, "continuous-integration"); + + delete process.env.CI; + delete process.env.CONTINUOUS_INTEGRATION; + try { + const update = await api.update.check(); + assert.strictEqual(update.status, "upgrade-available"); + assert.strictEqual(update.fromCache, true); + assert.strictEqual(update.latestVersion, "9.0.0"); + assert.ok(update.notice.includes("f2s-kb-upgrade")); + } finally { + if (originalCI === undefined) delete process.env.CI; + else process.env.CI = originalCI; + if (originalContinuousIntegration === undefined) delete process.env.CONTINUOUS_INTEGRATION; + else process.env.CONTINUOUS_INTEGRATION = originalContinuousIntegration; + } const controller = new AbortController(); controller.abort(); await assert.rejects(