Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/en/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.json` | `includeAny` triggers | **match**: one shard only |
| **L1 matcher shard** | `matchers/<id>.json` | `includeAny`/`includeAll` qualifiers, `excludeAny`/`excludeAll` vetoes | **match**: one shard only |
| **L2 topic summary** | `topics/<topic>.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.
Expand Down
2 changes: 1 addition & 1 deletion docs/en/commands-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<topic>.md` (routing summary with execution boundaries and next-step pointers) and `matchers/<id>.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/<topic>.md` (routing summary with execution boundaries and next-step pointers) and `matchers/<id>.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
Expand Down
2 changes: 1 addition & 1 deletion docs/en/directory-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<developerId>/` 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`) |
Expand Down
4 changes: 3 additions & 1 deletion docs/体系与原理.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ Flow2Spec 的目标是把"业务知识沉淀"与"Agent 能力加载"拆开,并
| 层级 | 路径 / 机制 | 记什么 | 典型读法 |
| --- | --- | --- | --- |
| **L0 路由索引** | `manifest-routing.json` | task→topic、`topicDependencies`、`topicPaths`、`topicMetadata` | 会话首读(机读事实源) |
| **L1 关键词分片** | `matchers/<id>.json` | `includeAny` 触发词 | **match**:只打开命中的一个分片 |
| **L1 关键词分片** | `matchers/<id>.json` | `includeAny`/`includeAll` 资格词、`excludeAny`/`excludeAll` 否决词 | **match**:只打开命中的一个分片 |
| **L2 主题摘要** | `topics/<topic>.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` 机读链。
Expand Down
2 changes: 1 addition & 1 deletion docs/命令说明.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@

**作用**:将 `stock-docs/` 中的沉淀文档(架构、终稿)同步到知识库路由系统,生成/更新主题文件、索引、manifest-routing、matchers。

**工作原理**:以终稿文档为输入,执行「文档→路由」的三步映射:① 从终稿中提取能力主题与关键词;② 生成 `topics/<topic>.md`(路由摘要,含执行边界与下一步指针)和 `matchers/<id>.json`(机读匹配词 `includeAny`);③ 在 `manifest-routing.json` 注册 task→topic 映射规则,并更新 `index.md` 人读导航。完成后,任务路由引擎即可通过关键词命中该主题。
**工作原理**:以终稿文档为输入,执行「文档→路由」的三步映射:① 从终稿中提取能力主题与关键词;② 生成 `topics/<topic>.md`(路由摘要,含执行边界与下一步指针)和 `matchers/<id>.json`(机读匹配词 `includeAny`/`includeAll`/`excludeAny`/`excludeAll`);③ 在 `manifest-routing.json` 注册 task→topic 映射规则,并更新 `index.md` 人读导航。完成后,任务路由引擎即可通过关键词命中该主题。

**使用场景**:

Expand Down
2 changes: 1 addition & 1 deletion docs/目录与路径约定.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。多人协作时按 `<developerId>/` 再分一层,两种形态见下文 §`.task/` 的两种形态。仅当 `changeTracking.*` 为 `true` 或显式调用 `f2s-req-plan` 时创建 |
| `配置根/rules/` | 规则文件(Cursor `.mdc`,Claude `.md`) |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 23 additions & 9 deletions packages/core/lib/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
171 changes: 171 additions & 0 deletions scripts/test-routing-semantics.js
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading