diff --git a/docs/en/architecture.md b/docs/en/architecture.md index 732d8ca..5340427 100644 --- a/docs/en/architecture.md +++ b/docs/en/architecture.md @@ -35,11 +35,13 @@ Four rings in the repo (do not collapse rules + skills into a single "third ring | Layer | Path / mechanism | Stores | Typical read | | --- | --- | --- | --- | | **L0 routing** | `manifest-routing.json` | task→topic, `topicDependencies`, `topicPaths`, `topicMetadata` | First read (machine source of truth) | -| **L1 matcher shard** | `matchers/.json` | `includeAny` triggers | **match**: one shard only | +| **L1 matcher shard** | `matchers/.json` | `includeAny`/`includeAll` qualifiers, `excludeAny`/`excludeAll` vetoes | **match**: one shard only | | **L2 topic summary** | `topics/.md` | Hard constraints, boundaries, pointers | **expand**: pull dependency topics | | **L3 long docs** | `stock-docs/`, `req-docs/` | Architecture finals, tech specs | Drill down on demand | | **Vertical chain** | `topicDependencies` | Common → subdomain → whitelist → domain rules | **expand** stacks layers | +**L1 shard semantics**: a task rule qualifies when any `includeAny` phrase hits or all `includeAll` phrases hit (an exact task match exempts phrase requirements); the rule is vetoed when any `excludeAny` phrase hits or all `excludeAll` phrases hit, and **the veto takes precedence over an exact task match**. + The pipeline `match → expand → verify → act` operates on L0–L2 (and L3 when needed). Topic-level dependencies are declared once and shared by all tasks. `index.md` is human navigation only; it does not replace the machine-readable chain. diff --git a/docs/en/commands-reference.md b/docs/en/commands-reference.md index 014db04..3d8854a 100644 --- a/docs/en/commands-reference.md +++ b/docs/en/commands-reference.md @@ -89,7 +89,7 @@ The table above lists conversation-triggered `/f2s-*` skills. The repository als **Purpose**: Synchronizes documents from `stock-docs/` (architecture, final drafts) into the knowledge base routing system, generating/updating topic files, the index, manifest-routing, and matchers. -**How It Works**: Starting from a final-draft document, it runs a three-step "document → routing" mapping: (1) extract capability topics and keywords from the draft; (2) generate `topics/.md` (routing summary with execution boundaries and next-step pointers) and `matchers/.json` (machine-readable `includeAny` terms); (3) register task→topic rules in `manifest-routing.json` and update the human-readable `index.md`. After that, the task routing engine can hit the topic via keywords. +**How It Works**: Starting from a final-draft document, it runs a three-step "document → routing" mapping: (1) extract capability topics and keywords from the draft; (2) generate `topics/.md` (routing summary with execution boundaries and next-step pointers) and `matchers/.json` (machine-readable `includeAny`/`includeAll`/`excludeAny`/`excludeAll` terms); (3) register task→topic rules in `manifest-routing.json` and update the human-readable `index.md`. After that, the task routing engine can hit the topic via keywords. **Use Cases**: - After a final draft is complete, the knowledge base needs to "know about" these documents diff --git a/docs/en/directory-conventions.md b/docs/en/directory-conventions.md index 7657a48..d60f5bf 100644 --- a/docs/en/directory-conventions.md +++ b/docs/en/directory-conventions.md @@ -22,7 +22,7 @@ See [architecture.md §1](./architecture.md) for Memory Coding four rings. | `.Knowledge/template/` | Templates for final drafts / technical proposals | | `.Knowledge/index.md` | Human-readable index | | `.Knowledge/manifest-routing.json` | **L0** Machine-readable routing skeleton (task/topic/`topicDependencies`/`topicMetadata`) | -| `.Knowledge/matchers/*.json` | **L1** Keyword fragments (`id/includeAny`); **match** reads one shard via `matcherPath` | +| `.Knowledge/matchers/*.json` | **L1** Keyword fragments (`id` plus `includeAny`/`includeAll` qualifiers and `excludeAny`/`excludeAll` vetoes); **match** reads one shard via `matcherPath` | | `.Knowledge/migration-report.md` | Migration comparison table and deletion path list written by the historical `f2s-kb-migrate` skill (removed from the package; existing files may be kept) | | `.task/` | Local change-tracking state, ignored by Git by default. In collaboration mode it has an extra `/` layer; see “The two `.task/` layouts” below. It is created only when `changeTracking.*` is `true` or `f2s-req-plan` is explicitly invoked. | | `Config Root/rules/` | Rule files (Cursor `.mdc`, Claude `.md`) | diff --git "a/docs/\344\275\223\347\263\273\344\270\216\345\216\237\347\220\206.md" "b/docs/\344\275\223\347\263\273\344\270\216\345\216\237\347\220\206.md" index 10d75e4..bcf2132 100644 --- "a/docs/\344\275\223\347\263\273\344\270\216\345\216\237\347\220\206.md" +++ "b/docs/\344\275\223\347\263\273\344\270\216\345\216\237\347\220\206.md" @@ -37,11 +37,13 @@ Flow2Spec 的目标是把"业务知识沉淀"与"Agent 能力加载"拆开,并 | 层级 | 路径 / 机制 | 记什么 | 典型读法 | | --- | --- | --- | --- | | **L0 路由索引** | `manifest-routing.json` | task→topic、`topicDependencies`、`topicPaths`、`topicMetadata` | 会话首读(机读事实源) | -| **L1 关键词分片** | `matchers/.json` | `includeAny` 触发词 | **match**:只打开命中的一个分片 | +| **L1 关键词分片** | `matchers/.json` | `includeAny`/`includeAll` 资格词、`excludeAny`/`excludeAll` 否决词 | **match**:只打开命中的一个分片 | | **L2 主题摘要** | `topics/.md` | 硬约束摘要、边界、下一步指针 | **expand**:拉齐依赖主题 | | **L3 长文档** | `stock-docs/`、`req-docs/` | 架构终稿、技术方案全文 | 按需下钻背景 | | **纵链(横切)** | `topicDependencies` | 通用约定 → 子域 → 白名单 → 本域细则 | **expand** 时按依赖顺序叠层 | +**L1 分片语义**:`includeAny` 任一命中或 `includeAll` 全部命中,该任务规则即具备候选资格(task 精确命中豁免短语要求);`excludeAny` 任一命中或 `excludeAll` 全部命中则整条规则否决,且**否决优先于 task 精确命中**。 + **渐进式读取**(`match → expand → verify → act`)作用在 L0–L2(必要时再到 L3):先收窄入口,再展开依赖与缺口检查,最后才改代码。主题级依赖挂一次、所有任务共享,避免每个任务重复声明前置约束。 人读导航:`index.md` 仅作语义边界校验,**不**替代 `manifest-routing` 机读链。 diff --git "a/docs/\345\221\275\344\273\244\350\257\264\346\230\216.md" "b/docs/\345\221\275\344\273\244\350\257\264\346\230\216.md" index 9e66d46..f6711fe 100644 --- "a/docs/\345\221\275\344\273\244\350\257\264\346\230\216.md" +++ "b/docs/\345\221\275\344\273\244\350\257\264\346\230\216.md" @@ -101,7 +101,7 @@ **作用**:将 `stock-docs/` 中的沉淀文档(架构、终稿)同步到知识库路由系统,生成/更新主题文件、索引、manifest-routing、matchers。 -**工作原理**:以终稿文档为输入,执行「文档→路由」的三步映射:① 从终稿中提取能力主题与关键词;② 生成 `topics/.md`(路由摘要,含执行边界与下一步指针)和 `matchers/.json`(机读匹配词 `includeAny`);③ 在 `manifest-routing.json` 注册 task→topic 映射规则,并更新 `index.md` 人读导航。完成后,任务路由引擎即可通过关键词命中该主题。 +**工作原理**:以终稿文档为输入,执行「文档→路由」的三步映射:① 从终稿中提取能力主题与关键词;② 生成 `topics/.md`(路由摘要,含执行边界与下一步指针)和 `matchers/.json`(机读匹配词 `includeAny`/`includeAll`/`excludeAny`/`excludeAll`);③ 在 `manifest-routing.json` 注册 task→topic 映射规则,并更新 `index.md` 人读导航。完成后,任务路由引擎即可通过关键词命中该主题。 **使用场景**: diff --git "a/docs/\347\233\256\345\275\225\344\270\216\350\267\257\345\276\204\347\272\246\345\256\232.md" "b/docs/\347\233\256\345\275\225\344\270\216\350\267\257\345\276\204\347\272\246\345\256\232.md" index 3440eed..cc1f124 100644 --- "a/docs/\347\233\256\345\275\225\344\270\216\350\267\257\345\276\204\347\272\246\345\256\232.md" +++ "b/docs/\347\233\256\345\275\225\344\270\216\350\267\257\345\276\204\347\272\246\345\256\232.md" @@ -22,7 +22,7 @@ Memory Coding 四环总览见 [体系与原理 §1](./体系与原理.md)。 | `.Knowledge/template/` | 终稿/技术方案模板 | | `.Knowledge/index.md` | 人类可读索引 | | `.Knowledge/manifest-routing.json` | **L0** 机读路由骨架(task/topic/`topicDependencies`/`topicMetadata`) | -| `.Knowledge/matchers/*.json` | **L1** 关键词分片(`id/includeAny`),由 `matcherPath` 直链;**match** 只读一片 | +| `.Knowledge/matchers/*.json` | **L1** 关键词分片(`id` + `includeAny`/`includeAll` 资格词、`excludeAny`/`excludeAll` 否决词),由 `matcherPath` 直链;**match** 只读一片 | | `.Knowledge/migration-report.md` | 历史版本 `f2s-kb-migrate` 落盘的迁移对照表与拟删除路径列表(该技能已移除,存量文件可保留) | | `.task/` | 变更追踪任务清单目录,本地运行态、默认进 `.gitignore`。多人协作时按 `/` 再分一层,两种形态见下文 §`.task/` 的两种形态。仅当 `changeTracking.*` 为 `true` 或显式调用 `f2s-req-plan` 时创建 | | `配置根/rules/` | 规则文件(Cursor `.mdc`,Claude `.md`) | diff --git a/package.json b/package.json index 1487057..db573a0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "node": ">=16" }, "scripts": { - "test": "node cli.js --help && node cli.js kb check && node scripts/sync-package-readme.js --check && node scripts/test-workspace-version.js && node scripts/test-cli-update.js && node scripts/test-knowledge-engine.js && node scripts/test-developer-id.js && node scripts/test-template-knowledge.js && node scripts/test-init-gitignore.js && node scripts/test-dsh-init.js && node scripts/test-plugin-init.js && node scripts/test-doctor.js && node scripts/test-core-api.js && node scripts/test-package-install.js", + "test": "node cli.js --help && node cli.js kb check && node scripts/sync-package-readme.js --check && node scripts/test-workspace-version.js && node scripts/test-cli-update.js && node scripts/test-knowledge-engine.js && node scripts/test-routing-semantics.js && node scripts/test-developer-id.js && node scripts/test-template-knowledge.js && node scripts/test-init-gitignore.js && node scripts/test-dsh-init.js && node scripts/test-plugin-init.js && node scripts/test-doctor.js && node scripts/test-core-api.js && node scripts/test-package-install.js", "test:core": "node scripts/test-core-api.js", "test:cli": "node cli.js --help && node cli.js doctor --json", "sync:agents": "node cli.js init cursor claude codex", diff --git a/packages/core/lib/routing.js b/packages/core/lib/routing.js index b410ed4..e01cd42 100644 --- a/packages/core/lib/routing.js +++ b/packages/core/lib/routing.js @@ -51,21 +51,35 @@ function match(cwd, input = {}) { for (let index = 0; index < rules.length; index += 1) { const rule = rules[index]; const exactTask = task && normalizeText(rule.task) === task; - let phrases = []; + let matcher = null; const matcherFile = matcherPathFor(cwd, rule); if (fs.existsSync(matcherFile)) { try { - const matcher = readJson(matcherFile); - phrases = Array.isArray(matcher.includeAny) ? matcher.includeAny : []; + matcher = readJson(matcherFile); } catch { - phrases = []; + matcher = null; } } - const phraseHits = phrases - .map((phrase) => ({ phrase, score: phraseScore(request, phrase) })) - .filter((hit) => hit.score > 0) - .sort((a, b) => b.score - a.score); - if (!exactTask && phraseHits.length === 0) continue; + const phraseList = (key) => (Array.isArray(matcher?.[key]) ? matcher[key] : []); + const includeAny = phraseList("includeAny"); + const includeAll = phraseList("includeAll"); + const excludeAny = phraseList("excludeAny"); + const excludeAll = phraseList("excludeAll"); + const hits = (phrases) => + phrases + .map((phrase) => ({ phrase, score: phraseScore(request, phrase) })) + .filter((hit) => hit.score > 0); + const excludeAnyHits = hits(excludeAny); + const excludeAllHits = hits(excludeAll); + // 否决门恒胜:排除词命中即整条规则出局,task 精确命中也不例外 + const excluded = + excludeAnyHits.length > 0 || (excludeAll.length > 0 && excludeAllHits.length === excludeAll.length); + if (excluded) continue; + const includeAnyHits = hits(includeAny); + const includeAllHits = hits(includeAll); + const allIncludeAll = includeAll.length > 0 && includeAllHits.length === includeAll.length; + const phraseHits = [...includeAnyHits, ...includeAllHits].sort((a, b) => b.score - a.score); + if (!exactTask && includeAnyHits.length === 0 && !allIncludeAll) continue; const score = exactTask ? 10000 : phraseHits[0].score; candidates.push({ rule, diff --git a/scripts/test-routing-semantics.js b/scripts/test-routing-semantics.js new file mode 100644 index 0000000..66256be --- /dev/null +++ b/scripts/test-routing-semantics.js @@ -0,0 +1,171 @@ +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const core = require("@double-coding/flow2spec-core"); + +function writeJson(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); +} + +function writeText(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, "utf8"); +} + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow2spec-routing-")); + +try { + writeJson(path.join(tmpRoot, ".Knowledge", "manifest-routing.json"), { + version: "test", + knowledgeRoot: ".Knowledge", + matcherKey: "matcherId", + sourceOfTruth: ".Knowledge/manifest-routing.json", + fallbackTopic: "topic-b", + topicDependencies: {}, + topicPaths: { + "topic-a": ".Knowledge/topics/topic-a.md", + "topic-b": ".Knowledge/topics/topic-b.md", + }, + taskToTopicRules: [ + { + task: "deploy", + matcherId: "m-deploy", + matcherPath: ".Knowledge/matchers/m-deploy.json", + topics: ["topic-a"], + }, + { + task: "rollback", + matcherId: "m-rollback", + matcherPath: ".Knowledge/matchers/m-rollback.json", + topics: ["topic-b"], + }, + { + task: "data-migration", + matcherId: "m-data-migration", + matcherPath: ".Knowledge/matchers/m-data-migration.json", + topics: ["topic-a"], + }, + { + task: "cache-purge", + matcherId: "m-cache-purge", + matcherPath: ".Knowledge/matchers/m-cache-purge.json", + topics: ["topic-b"], + }, + { + task: "legacy-api", + matcherId: "m-legacy-api", + matcherPath: ".Knowledge/matchers/m-legacy-api.json", + topics: ["topic-b"], + }, + ], + topicMetadata: { + "topic-a": { primary: "policy", confidence: "manual" }, + "topic-b": { primary: "feature", confidence: "manual" }, + }, + }); + + writeJson(path.join(tmpRoot, ".Knowledge", "matchers", "m-deploy.json"), { + id: "m-deploy", + includeAny: ["部署服务"], + excludeAny: ["预演", "preview"], + }); + writeJson(path.join(tmpRoot, ".Knowledge", "matchers", "m-rollback.json"), { + id: "m-rollback", + includeAny: ["回滚"], + }); + writeJson(path.join(tmpRoot, ".Knowledge", "matchers", "m-data-migration.json"), { + id: "m-data-migration", + includeAll: ["数据", "迁移"], + }); + writeJson(path.join(tmpRoot, ".Knowledge", "matchers", "m-cache-purge.json"), { + id: "m-cache-purge", + includeAny: ["清理缓存"], + includeAll: ["缓存", "失效"], + }); + writeJson(path.join(tmpRoot, ".Knowledge", "matchers", "m-legacy-api.json"), { + id: "m-legacy-api", + includeAny: ["老接口"], + excludeAll: ["归档", "下线"], + }); + + writeText( + path.join(tmpRoot, ".Knowledge", "topics", "topic-a.md"), + ["---", "id: topic-a", "revision: 0", "summary: topic a", "primary: policy", "confidence: manual", "---", "# Topic A", ""].join("\n"), + ); + writeText( + path.join(tmpRoot, ".Knowledge", "topics", "topic-b.md"), + ["---", "id: topic-b", "revision: 0", "summary: topic b", "primary: feature", "confidence: manual", "---", "# Topic B", ""].join("\n"), + ); + + const api = core.createFlow2Spec({ cwd: tmpRoot }); + + // 1. 黄金样本:存量 includeAny 行为与打分完全不变 + const golden = api.routing.match({ request: "如何部署服务到生产" }); + assert.strictEqual(golden.primary.rule.task, "deploy"); + assert.deepStrictEqual(golden.primary.matchedPhrases, ["部署服务"]); + assert.strictEqual(golden.primary.score, 14); + assert.strictEqual(golden.primary.confidence, "low"); + assert.strictEqual(golden.primary.fallback, undefined); + for (const key of ["request", "task", "primary", "alternatives", "candidates", "manifestVersion"]) { + assert.ok(key in golden, `match result should keep field: ${key}`); + } + + // 2. excludeAny 否决:含排除词的请求不再命中该任务域 + const vetoed = api.routing.match({ request: "预演环境如何部署服务" }); + assert.strictEqual(vetoed.candidates.length, 0); + assert.strictEqual(vetoed.primary.fallback, true); + assert.deepStrictEqual(vetoed.primary.topics, ["topic-b"]); + + // 3. 否决恒胜:task 精确命中同样被排除词否决 + const vetoedExact = api.routing.match({ task: "deploy", request: "预演环境部署服务" }); + assert.strictEqual(vetoedExact.primary.fallback, true); + assert.strictEqual(vetoedExact.candidates.length, 0); + + // 4. excludeAll 为 AND 否决:全部出现才否决,部分出现不否决 + const vetoedAll = api.routing.match({ request: "老接口 归档 下线 流程" }); + assert.strictEqual(vetoedAll.candidates.length, 0); + assert.strictEqual(vetoedAll.primary.fallback, true); + const notVetoedPartial = api.routing.match({ request: "老接口 归档 流程" }); + assert.strictEqual(notVetoedPartial.primary.rule.task, "legacy-api"); + + // 5. includeAll 纯 AND 资格:全部命中才成为候选(无 includeAny 也可表达) + const andQualified = api.routing.match({ request: "数据 迁移 步骤" }); + assert.strictEqual(andQualified.primary.rule.task, "data-migration"); + assert.deepStrictEqual(andQualified.primary.matchedPhrases, ["数据", "迁移"]); + const andNotQualified = api.routing.match({ request: "数据 备份 步骤" }); + assert.strictEqual(andNotQualified.primary.fallback, true); + + // 6. includeAny 与 includeAll 并存:includeAny 命中即具备资格(OR 路线) + const anyRoute = api.routing.match({ request: "清理缓存 的脚本" }); + assert.strictEqual(anyRoute.primary.rule.task, "cache-purge"); + + // 7. includeAll 短语并入打分池:命中短语与得分覆盖 includeAny 与 includeAll + const pooled = api.routing.match({ request: "清理缓存 之后 缓存 失效 处理" }); + assert.strictEqual(pooled.primary.rule.task, "cache-purge"); + assert.deepStrictEqual(pooled.primary.matchedPhrases, ["清理缓存", "缓存", "失效"]); + assert.strictEqual(pooled.primary.score, 14); + + // 8. 被否决的规则不出现在 candidates/alternatives,次优候选正常晋升 + const promote = api.routing.match({ request: "预演 部署服务 回滚 记录" }); + assert.strictEqual(promote.primary.rule.task, "rollback"); + assert.ok(promote.candidates.every((candidate) => candidate.rule.task !== "deploy")); + + // 9. 否决词判定沿用归一化口径(大小写不敏感) + const normalizedVeto = api.routing.match({ request: "PREVIEW 环境部署服务" }); + assert.strictEqual(normalizedVeto.primary.fallback, true); + + // 10. task 精确命中保持 10000 分与 high 置信(未被否决时) + const exact = api.routing.match({ task: "rollback", request: "发布出错怎么办" }); + assert.strictEqual(exact.primary.rule.task, "rollback"); + assert.strictEqual(exact.primary.score, 10000); + assert.strictEqual(exact.primary.confidence, "high"); + + console.log("routing semantics tests passed"); +} finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}