@code2knowledge/skill 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +352 -2
- package/SKILL.md +46 -0
- package/agents/openai.yaml +4 -0
- package/assets/notification-config.example.yml +6 -0
- package/assets/project-context/ontology/concept-types.yaml +52 -0
- package/assets/project-context/ontology/node-types.yaml +82 -0
- package/assets/project-context/ontology/relation-types.yaml +67 -0
- package/assets/project-context/ontology/schema.md +37 -0
- package/assets/project-context/rules/sync-rules.md +15 -0
- package/assets/project-context/rules/validation-rules.yaml +92 -0
- package/package.json +41 -4
- package/references/analysis.md +33 -0
- package/references/downstream-contract.md +23 -0
- package/references/execution.md +154 -0
- package/references/knowledge-model.md +108 -0
- package/references/notifications.md +46 -0
- package/references/wizard.md +110 -0
- package/scripts/knowledge.mjs +54 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/contract.mjs +347 -0
- package/scripts/lib/knowledge.mjs +1054 -0
- package/scripts/lib/notify.mjs +90 -0
- package/scripts/lib/preflight.mjs +63 -0
- package/scripts/lib/wizard.mjs +205 -0
- package/scripts/postinstall.mjs +8 -0
- package/scripts/register.mjs +107 -0
- package/scripts/setup.mjs +9 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { parse } from 'yaml';
|
|
6
|
+
|
|
7
|
+
const hash = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
|
+
// Escape caller content before placing it in trusted card markdown.
|
|
9
|
+
const escapeMarkdown = value => String(value).slice(0, 2400)
|
|
10
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
11
|
+
.replace(/([\\`*_\[\]~])/g, '\\$1').replace(/\r?\n/g, ' ');
|
|
12
|
+
const inlineCode = value => "`" + String(value).slice(0, 2400).replace(/`/g, 'ˋ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\r?\n/g, ' ') + "`";
|
|
13
|
+
export function buildNotificationCard({ event, task, summary, details = [], test = false }) {
|
|
14
|
+
const completed = event === 'completed';
|
|
15
|
+
const section = (title, lines) => ({ tag: 'markdown', content: `**${title}**\n${lines.map(line => `- ${line}`).join('\n')}`, text_align: 'left', text_size: 'normal_v2' });
|
|
16
|
+
const labelLine = value => {
|
|
17
|
+
const raw = String(value);
|
|
18
|
+
const separator = raw.indexOf(':');
|
|
19
|
+
if (separator < 0 || separator > 30) return escapeMarkdown(raw);
|
|
20
|
+
const label = raw.slice(0, separator), content = raw.slice(separator + 1).trim();
|
|
21
|
+
const isLocation = /路径|位置|记录|版本|服务|步骤/.test(label);
|
|
22
|
+
return `${escapeMarkdown(label)}:${isLocation ? inlineCode(content) : escapeMarkdown(content)}`;
|
|
23
|
+
};
|
|
24
|
+
const locations = [], information = [];
|
|
25
|
+
for (const line of details.slice(0, 8)) {
|
|
26
|
+
(/^(本机输出位置|输出位置|报告路径|检查记录|产物版本)[:(]/.test(String(line)) ? locations : information).push(labelLine(line));
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
schema: '2.0',
|
|
30
|
+
config: { update_multi: true, style: { text_size: { normal_v2: { default: 'normal', pc: 'normal', mobile: 'heading' } } } },
|
|
31
|
+
header: { template: completed ? 'green' : 'orange', title: { tag: 'plain_text', content: `${test ? '【测试】' : ''}Code2Knowledge 任务通知` } },
|
|
32
|
+
body: { direction: 'vertical', padding: '12px 12px 12px 12px', elements: [
|
|
33
|
+
section('任务摘要', [
|
|
34
|
+
`任务:${inlineCode(task)}`,
|
|
35
|
+
`状态:${completed ? '已导出 ✅ · 业务待审核' : '已暂停 ⏸ · 等待人工处理'}`,
|
|
36
|
+
...(test ? ['用途:样式测试,非真实任务'] : []),
|
|
37
|
+
]),
|
|
38
|
+
section(completed ? '任务结果' : '阻塞说明', [escapeMarkdown(summary), ...information]),
|
|
39
|
+
...(locations.length ? [section(completed ? '交付位置' : '排查记录', locations)] : []),
|
|
40
|
+
section('下一步', [completed ? '查看交付物,复核业务口径和未核验项;导出不代表业务审核通过。' : '处理上述阻塞后,回到 Agent 会话回复“继续”,系统将重新检查前置条件。']),
|
|
41
|
+
{ tag: 'markdown', content: '本机路径仅供定位,不是在线访问链接。', text_size: 'notation' },
|
|
42
|
+
] },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function notify({ workspace, event, task, summary, details = [], test = false }, { configPath = path.join(os.homedir(), '.code2knowledge', 'config.yml'), fetchImpl = fetch } = {}) {
|
|
47
|
+
if (!['intervention', 'completed'].includes(event) || !task || !summary || !Array.isArray(details)) throw Error('通知必须包含有效 event、task、summary 和 details 数组');
|
|
48
|
+
// node:test propagates this context to spawned CLI children. Never use real transport in tests.
|
|
49
|
+
if ((process.env.NODE_TEST_CONTEXT || process.env.C2K_NOTIFICATIONS_DISABLED === '1') && fetchImpl === fetch) return { status: 'skipped', reason: 'test_or_disabled' };
|
|
50
|
+
const payload = { event, task, summary, details: details.filter(line => !String(line).startsWith('检查记录:')) };
|
|
51
|
+
const id = hash(payload);
|
|
52
|
+
const dir = path.join(workspace, 'notifications');
|
|
53
|
+
await fs.mkdir(dir, { recursive: true });
|
|
54
|
+
const recordPath = path.join(dir, `${id}.json`);
|
|
55
|
+
const lockPath = `${recordPath}.lock`;
|
|
56
|
+
let lock;
|
|
57
|
+
try { lock = await fs.open(lockPath, 'wx'); }
|
|
58
|
+
catch (error) { if (error.code === 'EEXIST') return { status: 'in_progress', recordPath }; throw error; }
|
|
59
|
+
try {
|
|
60
|
+
try {
|
|
61
|
+
const previous = JSON.parse(await fs.readFile(recordPath, 'utf8'));
|
|
62
|
+
if (previous.status === 'sent') return { ...previous, duplicate: true, recordPath };
|
|
63
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
64
|
+
let status = 'skipped', reason = 'not_configured';
|
|
65
|
+
try {
|
|
66
|
+
let config;
|
|
67
|
+
try { config = parse(await fs.readFile(configPath, 'utf8')); }
|
|
68
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
69
|
+
const settings = config?.notification?.feishu;
|
|
70
|
+
if (settings?.enabled === true) {
|
|
71
|
+
const url = new URL(settings.webhook_url);
|
|
72
|
+
if (url.protocol !== 'https:' || !['open.feishu.cn', 'open.larksuite.com'].includes(url.hostname) || !url.pathname.startsWith('/open-apis/bot/v2/hook/')) throw Error('invalid_config');
|
|
73
|
+
const body = { msg_type: 'interactive', card: buildNotificationCard({ event, task, summary, details, test }) };
|
|
74
|
+
if (settings.secret) {
|
|
75
|
+
body.timestamp = String(Math.floor(Date.now() / 1000));
|
|
76
|
+
body.sign = crypto.createHmac('sha256', `${body.timestamp}\n${settings.secret}`).update('').digest('base64');
|
|
77
|
+
}
|
|
78
|
+
const response = await fetchImpl(url.href, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000), redirect: 'error' });
|
|
79
|
+
if (!response.ok) throw Error('http_error');
|
|
80
|
+
const result = await response.json();
|
|
81
|
+
if (result.code !== 0 && result.StatusCode !== 0) throw Error('provider_rejected');
|
|
82
|
+
status = 'sent'; reason = null;
|
|
83
|
+
}
|
|
84
|
+
} catch { status = 'failed'; reason = '配置、网络或飞书响应异常;请检查本机通知配置及机器人状态'; }
|
|
85
|
+
// Do not persist webhook, secret, response body or potentially sensitive error messages.
|
|
86
|
+
const record = { id, event, task, status, reason, timestamp: new Date().toISOString() };
|
|
87
|
+
await fs.writeFile(recordPath, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
88
|
+
return { ...record, recordPath };
|
|
89
|
+
} finally { await lock.close(); await fs.unlink(lockPath); }
|
|
90
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { notify } from './notify.mjs';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
export function requireDocumentChoice(payload) {
|
|
9
|
+
if (!Object.hasOwn(payload, 'rawDocsDir') || typeof payload.rawDocsDir !== 'string') throw Error('需要人工选择文档资料:显式提供 rawDocsDir(不添加时为空字符串)');
|
|
10
|
+
}
|
|
11
|
+
export async function syncMaster(repoDirs, { inspect, workspace }) {
|
|
12
|
+
const report = { checkedAt: new Date().toISOString(), status: 'running', repositories: [] };
|
|
13
|
+
const git = async (cwd, ...args) => (await exec('git', args, { cwd, timeout: 60000, maxBuffer: 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })).stdout.trim();
|
|
14
|
+
let step = '工作区检查', current;
|
|
15
|
+
try {
|
|
16
|
+
for (const dir of [...new Set(repoDirs)]) {
|
|
17
|
+
current = { path: dir, status: 'pending' }; report.repositories.push(current);
|
|
18
|
+
const info = await inspect(dir);
|
|
19
|
+
if (info.worktreeStatus !== 'clean') throw Error('工作区不是 clean,保留改动并请求人工处理');
|
|
20
|
+
current.originalCommit = await git(dir, 'rev-parse', 'HEAD');
|
|
21
|
+
current.originalBranch = await git(dir, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
22
|
+
for (const marker of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply', 'sequencer']) {
|
|
23
|
+
const markerPath = await git(dir, 'rev-parse', '--git-path', marker);
|
|
24
|
+
try { await fs.access(path.resolve(dir, markerPath)); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
|
|
25
|
+
throw Error(`存在未完成 Git 操作:${marker}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const item of report.repositories) {
|
|
29
|
+
current = item; const dir = item.path;
|
|
30
|
+
step = 'fetch origin/master';
|
|
31
|
+
await git(dir, 'fetch', 'origin', '+refs/heads/master:refs/remotes/origin/master');
|
|
32
|
+
const remote = await git(dir, 'rev-parse', 'refs/remotes/origin/master');
|
|
33
|
+
let local;
|
|
34
|
+
try { local = await git(dir, 'rev-parse', '--verify', 'refs/heads/master'); } catch { /* Create tracking branch below. */ }
|
|
35
|
+
if (local) {
|
|
36
|
+
step = 'master 快进可行性检查';
|
|
37
|
+
await git(dir, 'merge-base', '--is-ancestor', local, remote);
|
|
38
|
+
}
|
|
39
|
+
step = '切换 master';
|
|
40
|
+
if (local) await git(dir, 'switch', 'master');
|
|
41
|
+
else await git(dir, 'switch', '--track', '-c', 'master', 'origin/master');
|
|
42
|
+
step = 'pull master'; await git(dir, 'pull', '--ff-only', 'origin', 'master');
|
|
43
|
+
step = '最终版本检查';
|
|
44
|
+
item.commit = await git(dir, 'rev-parse', 'HEAD');
|
|
45
|
+
if (item.commit !== await git(dir, 'rev-parse', 'refs/remotes/origin/master') || await git(dir, 'branch', '--show-current') !== 'master' || (await inspect(dir)).worktreeStatus !== 'clean') throw Error('master 版本或工作区状态不一致');
|
|
46
|
+
item.status = 'passed';
|
|
47
|
+
}
|
|
48
|
+
report.status = 'passed';
|
|
49
|
+
} catch (error) {
|
|
50
|
+
report.status = 'blocked';
|
|
51
|
+
if (current) { current.status = 'blocked'; current.step = step; }
|
|
52
|
+
// Keep credentials from remote URLs out of persisted diagnostics.
|
|
53
|
+
report.error = `master 检查失败:${step};请人工检查仓库、分支、网络和权限,处理后重试`;
|
|
54
|
+
}
|
|
55
|
+
const dir = path.join(workspace, 'preflight'); await fs.mkdir(dir, { recursive: true });
|
|
56
|
+
const recordPath = path.join(dir, `${Date.now()}-${crypto.randomUUID()}.json`);
|
|
57
|
+
await fs.writeFile(recordPath, JSON.stringify(report, null, 2));
|
|
58
|
+
if (report.status !== 'passed') {
|
|
59
|
+
try { await notify({ workspace, event: 'intervention', task: current?.path || workspace, summary: report.error, details: [`仓库:${current?.path}`, `检查记录:${recordPath}`] }); } catch { /* Preserve the original blocker. */ }
|
|
60
|
+
throw Error(`${report.error};仓库:${current?.path};检查记录:${recordPath}`);
|
|
61
|
+
}
|
|
62
|
+
return { ...report, recordPath };
|
|
63
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { syncMaster } from "./preflight.mjs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
workspaceDir, listMaterials, readMaterial, readRun, inspectRepository,
|
|
7
|
+
checkMaterial, buildKnowledgeBase, createCenterMaterial, inferBusinessSlug, assertBusinessSlug,
|
|
8
|
+
} from "./knowledge.mjs";
|
|
9
|
+
|
|
10
|
+
const wizardRoot = path.join(workspaceDir, "wizards");
|
|
11
|
+
const intents = new Set(["new", "reuse", "resume", "material"]);
|
|
12
|
+
const textFields = ["productName", "productSlug", "domainName", "domainSlug", "domainScope", "rawDocsDir", "outputRootDir", "contextMode"];
|
|
13
|
+
const byRecent = (a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || ""));
|
|
14
|
+
const isMissing = (value) => typeof value !== "string" || !value.trim();
|
|
15
|
+
|
|
16
|
+
async function directoryEntries(dir) {
|
|
17
|
+
try { return await fs.readdir(dir, { withFileTypes: true }); }
|
|
18
|
+
catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function listRuns() {
|
|
22
|
+
const result = [];
|
|
23
|
+
for (const entry of await directoryEntries(path.join(workspaceDir, "runs"))) {
|
|
24
|
+
if (!entry.isDirectory()) continue;
|
|
25
|
+
try { result.push(await readRun(path.join(workspaceDir, "runs", entry.name))); }
|
|
26
|
+
catch { /* Incomplete/legacy directories must not prevent discovery. */ }
|
|
27
|
+
}
|
|
28
|
+
return result.sort(byRecent);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function wizardFile(id) {
|
|
32
|
+
if (typeof id !== "string" || !/^[a-zA-Z0-9-]{1,80}$/.test(id)) throw new Error("向导编号无效");
|
|
33
|
+
return path.join(wizardRoot, `${id}.json`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readWizard(id) {
|
|
37
|
+
const wizard = JSON.parse(await fs.readFile(wizardFile(id), "utf8"));
|
|
38
|
+
if (wizard.version !== 1 || wizard.wizardId !== id || !intents.has(wizard.intent)) throw new Error("向导记录无效");
|
|
39
|
+
return wizard;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function listWizards() {
|
|
43
|
+
const result = [];
|
|
44
|
+
for (const entry of await directoryEntries(wizardRoot)) {
|
|
45
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
46
|
+
try {
|
|
47
|
+
const wizard = await readWizard(entry.name.slice(0, -5));
|
|
48
|
+
if (wizard.status !== "started") result.push(wizard);
|
|
49
|
+
} catch { /* Ignore incomplete checkpoint files. */ }
|
|
50
|
+
}
|
|
51
|
+
return result.sort(byRecent);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Read local manifests only; no source extraction or whole-machine discovery.
|
|
55
|
+
export async function discoverWorkspace() {
|
|
56
|
+
const [materials, runs, wizards] = await Promise.all([listMaterials(), listRuns(), listWizards()]);
|
|
57
|
+
const products = new Map();
|
|
58
|
+
for (const item of [...materials, ...runs]) {
|
|
59
|
+
if (!products.has(item.productSlug)) products.set(item.productSlug, {
|
|
60
|
+
productName: item.productName, productSlug: item.productSlug, domains: [],
|
|
61
|
+
});
|
|
62
|
+
const product = products.get(item.productSlug);
|
|
63
|
+
if (item.domainName && !product.domains.includes(item.domainName)) product.domains.push(item.domainName);
|
|
64
|
+
}
|
|
65
|
+
let currentRepository = null;
|
|
66
|
+
// Only consider the exact current directory; do not search parents or siblings for repositories.
|
|
67
|
+
if ((await directoryEntries(process.cwd())).some((entry) => entry.name === ".git")) {
|
|
68
|
+
try { currentRepository = await inspectRepository(process.cwd()); } catch { /* Optional candidate. */ }
|
|
69
|
+
}
|
|
70
|
+
const resumable = runs.filter((run) => run.status !== "exported");
|
|
71
|
+
const menu = [];
|
|
72
|
+
if (wizards.length || resumable.length) menu.push({ id: "resume", label: "继续上次任务", description: "保留已填信息和分析进度" });
|
|
73
|
+
if (materials.length) menu.push({ id: "reuse", label: "复用共享原料,整理业务域", description: "沿用业务中心和仓库,不重复填写" });
|
|
74
|
+
menu.push({ id: "new", label: "新建业务中心或共享原料", description: "使用当前仓库,或添加其他仓库" });
|
|
75
|
+
const limit = 8;
|
|
76
|
+
return {
|
|
77
|
+
workspaceDir, projectDir: process.cwd(), currentRepository, menu,
|
|
78
|
+
defaults: { outputRootDir: process.cwd(), rawDocsDir: "", contextMode: "repomix" },
|
|
79
|
+
products: [...products.values()],
|
|
80
|
+
materials: materials.slice(0, limit).map((item) => ({ materialId: item.materialId, materialDir: item.materialDir,
|
|
81
|
+
productName: item.productName, productSlug: item.productSlug, createdAt: item.createdAt, repositories: item.repositories.map((repo) => repo.name), freshness: "unchecked" })),
|
|
82
|
+
runs: resumable.slice(0, limit).map((run) => ({ runId: run.runId, runDir: run.runDir,
|
|
83
|
+
productName: run.productName, productSlug: run.productSlug, domainName: run.domainName, domainSlug: run.domainSlug, domainScope: run.domainScope,
|
|
84
|
+
status: run.status, updatedAt: run.updatedAt, materialDir: run.materialDir })),
|
|
85
|
+
wizards: wizards.slice(0, limit).map((wizard) => ({ wizardId: wizard.wizardId, intent: wizard.intent,
|
|
86
|
+
productName: wizard.answers.productName, domainName: wizard.answers.domainName,
|
|
87
|
+
nextStep: wizard.nextStep, updatedAt: wizard.updatedAt })),
|
|
88
|
+
totals: { materials: materials.length, runs: resumable.length, wizards: wizards.length },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function persist(wizard) {
|
|
93
|
+
await fs.mkdir(wizardRoot, { recursive: true });
|
|
94
|
+
const file = wizardFile(wizard.wizardId);
|
|
95
|
+
const temporary = `${file}.${crypto.randomUUID()}.tmp`;
|
|
96
|
+
await fs.writeFile(temporary, JSON.stringify(wizard, null, 2) + "\n");
|
|
97
|
+
await fs.rename(temporary, file);
|
|
98
|
+
return { ...wizard, wizardPath: file };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Save only choices the user actually made; missing answers are reported, never inferred.
|
|
102
|
+
export async function saveWizard(input) {
|
|
103
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("向导输入必须为对象");
|
|
104
|
+
for (const key of Object.keys(input)) {
|
|
105
|
+
if (!["wizardId", "intent", "answers", "materialDir", "runDir"].includes(key)) throw new Error(`未知向导参数:${key}`);
|
|
106
|
+
}
|
|
107
|
+
const previous = input.wizardId ? await readWizard(input.wizardId) : null;
|
|
108
|
+
if (previous?.status === "started") throw new Error("向导已启动,请继续对应任务或新建向导");
|
|
109
|
+
const intent = input.intent || previous?.intent;
|
|
110
|
+
if (!intents.has(intent)) throw new Error("请选择 new / reuse / resume / material");
|
|
111
|
+
const patch = input.answers || {};
|
|
112
|
+
if (typeof patch !== "object" || Array.isArray(patch)) throw new Error("answers 必须为对象");
|
|
113
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
114
|
+
if (!textFields.includes(key) && key !== "repoDirs") throw new Error(`未知向导字段:${key}`);
|
|
115
|
+
if (key === "repoDirs") {
|
|
116
|
+
if (!Array.isArray(value) || value.some((dir) => typeof dir !== "string" || !dir.trim())) throw new Error("repoDirs 必须为路径列表");
|
|
117
|
+
} else if (typeof value !== "string") throw new Error(`${key} 必须为文本`);
|
|
118
|
+
}
|
|
119
|
+
const answers = { contextMode: "repomix", ...previous?.answers, ...patch };
|
|
120
|
+
// A domain change is a new scope decision unless the same answer supplies its scope.
|
|
121
|
+
if (previous?.answers.domainName && patch.domainName !== undefined &&
|
|
122
|
+
patch.domainName !== previous.answers.domainName && patch.domainScope === undefined) delete answers.domainScope;
|
|
123
|
+
for (const [nameField, slugField] of [["productName", "productSlug"], ["domainName", "domainSlug"]]) {
|
|
124
|
+
if (patch[nameField] !== undefined && patch[nameField] !== previous?.answers[nameField] && patch[slugField] === undefined) delete answers[slugField];
|
|
125
|
+
if (isMissing(answers[slugField])) {
|
|
126
|
+
const inferred = inferBusinessSlug(answers[nameField]);
|
|
127
|
+
if (inferred) answers[slugField] = inferred;
|
|
128
|
+
else delete answers[slugField];
|
|
129
|
+
}
|
|
130
|
+
if (answers[slugField] !== undefined) assertBusinessSlug(answers[slugField], slugField);
|
|
131
|
+
}
|
|
132
|
+
if (!["source-index", "repomix"].includes(answers.contextMode)) throw new Error("contextMode 无效");
|
|
133
|
+
// Discovery suggests a destination; only an explicit user answer selects it.
|
|
134
|
+
if (!isMissing(answers.outputRootDir)) answers.outputRootDir = path.resolve(previous?.projectDir || process.cwd(), answers.outputRootDir);
|
|
135
|
+
else delete answers.outputRootDir;
|
|
136
|
+
if (answers.rawDocsDir) answers.rawDocsDir = path.resolve(previous?.projectDir || process.cwd(), answers.rawDocsDir);
|
|
137
|
+
if (answers.repoDirs) answers.repoDirs = [...new Set(answers.repoDirs.map((dir) => path.resolve(previous?.projectDir || process.cwd(), dir)))];
|
|
138
|
+
const materialDir = input.materialDir ?? previous?.materialDir;
|
|
139
|
+
const runDir = input.runDir ?? previous?.runDir;
|
|
140
|
+
if (intent === "reuse" && materialDir) {
|
|
141
|
+
const material = await readMaterial(materialDir);
|
|
142
|
+
Object.assign(answers, { productName: material.productName, productSlug: assertBusinessSlug(material.productSlug, "productSlug"), repoDirs: material.repositories.map((repo) => repo.path),
|
|
143
|
+
rawDocsDir: material.rawDocsDir || "", contextMode: material.contextMode || "repomix" });
|
|
144
|
+
}
|
|
145
|
+
if (intent === "resume" && runDir) {
|
|
146
|
+
const run = await readRun(runDir);
|
|
147
|
+
Object.assign(answers, { productName: run.productName, productSlug: run.productSlug, domainName: run.domainName, domainSlug: run.domainSlug,
|
|
148
|
+
domainScope: run.domainScope, outputRootDir: run.outputRootDir });
|
|
149
|
+
}
|
|
150
|
+
const missingFields = [];
|
|
151
|
+
if (intent === "resume") { if (!runDir) missingFields.push("runDir"); }
|
|
152
|
+
else {
|
|
153
|
+
if (intent !== "reuse" && !Object.hasOwn(answers, "rawDocsDir")) missingFields.push("rawDocsDir");
|
|
154
|
+
if (intent === "reuse" && !materialDir) missingFields.push("materialDir");
|
|
155
|
+
if (intent !== "reuse" && isMissing(answers.productName)) missingFields.push("productName");
|
|
156
|
+
if (isMissing(answers.productSlug)) missingFields.push("productSlug");
|
|
157
|
+
if (intent !== "material" && isMissing(answers.domainName)) missingFields.push("domainName");
|
|
158
|
+
if (intent !== "material" && isMissing(answers.domainSlug)) missingFields.push("domainSlug");
|
|
159
|
+
if (intent !== "reuse" && !answers.repoDirs?.length && !answers.rawDocsDir) missingFields.push("repoDirs");
|
|
160
|
+
if (intent !== "material" && isMissing(answers.domainScope)) missingFields.push("domainScope");
|
|
161
|
+
if (intent !== "material" && isMissing(answers.outputRootDir)) missingFields.push("outputRootDir");
|
|
162
|
+
}
|
|
163
|
+
const wizard = {
|
|
164
|
+
version: 1, wizardId: previous?.wizardId || crypto.randomUUID(), intent,
|
|
165
|
+
status: missingFields.length ? "collecting" : "ready",
|
|
166
|
+
projectDir: previous?.projectDir || process.cwd(), createdAt: previous?.createdAt || new Date().toISOString(),
|
|
167
|
+
updatedAt: new Date().toISOString(), answers,
|
|
168
|
+
...(materialDir ? { materialDir } : {}), ...(runDir ? { runDir } : {}),
|
|
169
|
+
...(answers.outputRootDir && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(answers.productSlug || "") && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(answers.domainSlug || "") ? {
|
|
170
|
+
exportDirPreview: path.join(answers.outputRootDir, "code-knowledge", answers.productSlug, answers.domainSlug),
|
|
171
|
+
} : {}),
|
|
172
|
+
missingFields, nextStep: missingFields[0] || "start",
|
|
173
|
+
};
|
|
174
|
+
return persist(wizard);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function startWizard(id) {
|
|
178
|
+
const wizard = await readWizard(id);
|
|
179
|
+
if (wizard.status === "started") {
|
|
180
|
+
const result = wizard.intent === "material" ? await readMaterial(wizard.materialDir) : await readRun(wizard.runDir);
|
|
181
|
+
await syncMaster((await readMaterial(result.materialDir)).repositories.map(repo => repo.path), { inspect: inspectRepository, workspace: workspaceDir });
|
|
182
|
+
const freshness = await checkMaterial(result.materialDir);
|
|
183
|
+
if (!freshness.reusable) throw new Error("原料已过期,请刷新后继续");
|
|
184
|
+
return { wizard, result, alreadyStarted: true };
|
|
185
|
+
}
|
|
186
|
+
if (["new", "material"].includes(wizard.intent) && !Object.hasOwn(wizard.answers, "rawDocsDir")) throw new Error("请先选择是否添加文档资料(rawDocsDir)");
|
|
187
|
+
if (wizard.status !== "ready") throw new Error(`向导尚未完成:${wizard.missingFields.join(", ")}`);
|
|
188
|
+
let result;
|
|
189
|
+
if (wizard.intent === "resume") {
|
|
190
|
+
result = await readRun(wizard.runDir);
|
|
191
|
+
await syncMaster((await readMaterial(result.materialDir)).repositories.map(repo => repo.path), { inspect: inspectRepository, workspace: workspaceDir });
|
|
192
|
+
if (!(await checkMaterial(result.materialDir)).reusable) throw new Error("原料已过期,请刷新后继续");
|
|
193
|
+
}
|
|
194
|
+
else if (wizard.intent === "material") result = await createCenterMaterial(wizard.answers);
|
|
195
|
+
else {
|
|
196
|
+
if (wizard.intent === "reuse") {
|
|
197
|
+
const check = await checkMaterial(wizard.materialDir);
|
|
198
|
+
if (!check.reusable) throw new Error("共享原料版本已变化或工作区未核验,请选择刷新原料或其他原料;已填业务域信息已保留");
|
|
199
|
+
}
|
|
200
|
+
result = await buildKnowledgeBase({ ...wizard.answers, ...(wizard.intent === "reuse" ? { materialDir: wizard.materialDir } : {}) });
|
|
201
|
+
}
|
|
202
|
+
const updated = await persist({ ...wizard, status: "started", updatedAt: new Date().toISOString(),
|
|
203
|
+
...(result.runDir ? { runDir: result.runDir } : {}), ...(result.materialDir ? { materialDir: result.materialDir } : {}) });
|
|
204
|
+
return { wizard: updated, result, alreadyStarted: false };
|
|
205
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ensureConfig } from './lib/config.mjs';
|
|
3
|
+
try {
|
|
4
|
+
const configPath = await ensureConfig();
|
|
5
|
+
console.log(`Code2Knowledge 通知配置已就绪(保留已有配置):${configPath};模板默认关闭通知。`);
|
|
6
|
+
} catch {
|
|
7
|
+
console.warn('Code2Knowledge 无法初始化通知模板;运行 code2knowledge 注册时将重试。');
|
|
8
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ensureConfig } from "./lib/config.mjs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
|
|
9
|
+
const skillDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
export const supportedAgents = ["codex", "claude", "cursor", "gemini", "copilot"];
|
|
11
|
+
const marker = "code2knowledge-managed-entry-v1";
|
|
12
|
+
|
|
13
|
+
async function writeManaged(file, content, dryRun) {
|
|
14
|
+
let previous;
|
|
15
|
+
try { previous = await fs.readFile(file, "utf8"); }
|
|
16
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
17
|
+
if (previous !== undefined && previous !== content && !previous.includes(marker)) {
|
|
18
|
+
throw new Error(`已有同名入口,未覆盖:${file}`);
|
|
19
|
+
}
|
|
20
|
+
if (!dryRun) {
|
|
21
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
22
|
+
await fs.writeFile(file, content);
|
|
23
|
+
}
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function linkSkill(target, source, dryRun) {
|
|
28
|
+
try {
|
|
29
|
+
const existing = await fs.realpath(target);
|
|
30
|
+
if (existing !== await fs.realpath(source)) throw new Error(`已有同名 Skill,未覆盖:${target}`);
|
|
31
|
+
return target;
|
|
32
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
33
|
+
// Dangling symlinks are also existing entries; never replace them implicitly.
|
|
34
|
+
try { await fs.lstat(target); throw new Error(`已有损坏入口,未覆盖:${target}`); }
|
|
35
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
36
|
+
if (!dryRun) {
|
|
37
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
38
|
+
await fs.symlink(source, target, process.platform === "win32" ? "junction" : "dir");
|
|
39
|
+
}
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function register({ agent, scope = "user", projectDir = process.cwd(), userHome = os.homedir(), source = skillDir, dryRun = false }) {
|
|
44
|
+
if (!supportedAgents.includes(agent)) throw new Error(`不支持的 Agent:${agent}`);
|
|
45
|
+
if (!["user", "project"].includes(scope)) throw new Error("scope 必须为 user 或 project");
|
|
46
|
+
const root = scope === "user" ? userHome : path.resolve(projectDir);
|
|
47
|
+
const instruction = `读取 ${JSON.stringify(path.join(source, "SKILL.md"))} 并遵循其完整流程。用户已启动 code2knowledge;先只读发现当前工作区的历史任务和共享原料,按 SKILL.md 用宿主提问工具呈现轻量选择向导;选择优先贯穿所有步骤:有可靠候选且有可用结构化提问工具时必须调用,推荐答案和自定义入口统一呈现,最后的导出位置也不能降级成文本编号;选择自定义后才补问缺失文本,工具原生自由输入不重复添加其他项。只有工具确实不可用或失败且无替代时才能用编号兜底并说明原因。中心和域的命名选项同时展示中文名称与英文目录标识,目录禁用中文;将业务域名、业务域边界说明和知识库导出位置作为明确引导项;新建原料确定仓库后必须通过结构化选择询问是否添加 PRD、ERD 等文档资料,选择添加后收集本地资料目录;默认空值不是用户已拒绝,启动前须完成选择。已有信息不重复问,复用沿用原资料,导出位置需用户选定。正式逆向前按 execution.md 检查全部依赖仓库,自动切换 master 并从 origin 快进拉取;失败停止请求人工介入,不强制覆盖或清理改动。当前 Agent 直接执行分析,不要启动其他 Agent CLI。若用户已提供信息,沿用并只问缺失项。`;
|
|
48
|
+
const files = [];
|
|
49
|
+
let invocation = "/code2knowledge";
|
|
50
|
+
if (agent === "codex" || agent === "claude") {
|
|
51
|
+
const dir = agent === "codex" ? ".agents" : ".claude";
|
|
52
|
+
files.push(await linkSkill(path.join(root, dir, "skills", "code2knowledge"), source, dryRun));
|
|
53
|
+
if (agent === "codex") invocation = "$code2knowledge";
|
|
54
|
+
} else if (agent === "cursor") {
|
|
55
|
+
files.push(await writeManaged(path.join(root, ".cursor", "commands", "code2knowledge.md"),
|
|
56
|
+
`<!-- ${marker} -->\n# Code2Knowledge\n\n${instruction}\n`, dryRun));
|
|
57
|
+
} else if (agent === "gemini") {
|
|
58
|
+
files.push(await writeManaged(path.join(root, ".gemini", "commands", "code2knowledge.toml"),
|
|
59
|
+
`# ${marker}\ndescription = "启动代码业务知识库问答向导"\nprompt = ${JSON.stringify(instruction)}\n`, dryRun));
|
|
60
|
+
} else {
|
|
61
|
+
if (scope === "user") throw new Error("Copilot 入口按项目注册,请使用 --scope project --project <目录>");
|
|
62
|
+
files.push(await writeManaged(path.join(root, ".github", "prompts", "code2knowledge.prompt.md"),
|
|
63
|
+
`---\ndescription: "启动代码业务知识库问答向导"\nagent: "agent"\n---\n<!-- ${marker} -->\n${instruction}\n`, dryRun));
|
|
64
|
+
files.push(await linkSkill(path.join(root, ".github", "skills", "code2knowledge"), source, dryRun));
|
|
65
|
+
}
|
|
66
|
+
const configPath = await ensureConfig({ userHome, dryRun });
|
|
67
|
+
return { agent, scope, files, invocation, configPath, naturalLanguage: "code2knowledge", dryRun };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
71
|
+
const options = {};
|
|
72
|
+
for (let i = 0; i < argv.length; i++) {
|
|
73
|
+
const arg = argv[i];
|
|
74
|
+
if (arg === "--dry-run") options.dryRun = true;
|
|
75
|
+
else if (["--agent", "--scope", "--project"].includes(arg) && argv[i + 1] && !argv[i + 1].startsWith("--")) options[arg.slice(2)] = argv[++i];
|
|
76
|
+
else if (arg === "--help") { console.log("code2knowledge --agent codex|claude|cursor|gemini|copilot|all [--scope user|project] [--project DIR] [--dry-run]"); return; }
|
|
77
|
+
else throw new Error(`参数无效:${arg}`);
|
|
78
|
+
}
|
|
79
|
+
if (!options.agent) {
|
|
80
|
+
if (!process.stdin.isTTY) throw new Error("非交互环境请指定 --agent codex|claude|cursor|gemini|copilot|all");
|
|
81
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
82
|
+
try {
|
|
83
|
+
console.log("选择 Agent:1 Codex(默认) 2 Claude Code 3 Cursor 4 Gemini CLI 5 Copilot 6 全部");
|
|
84
|
+
const answer = (await rl.question("输入编号:")).trim() || "1";
|
|
85
|
+
options.agent = [...supportedAgents, "all"][Number(answer) - 1];
|
|
86
|
+
if (!options.agent) throw new Error("请选择 1 至 6");
|
|
87
|
+
if (!options.scope) {
|
|
88
|
+
const answer = (await rl.question("注册范围:1 当前用户(默认,Copilot 使用当前项目) 2 当前项目:")).trim() || "1";
|
|
89
|
+
if (!["1", "2"].includes(answer)) throw new Error("请选择 1 或 2");
|
|
90
|
+
options.scope = answer === "2" ? "project" : "user";
|
|
91
|
+
}
|
|
92
|
+
} finally { rl.close(); }
|
|
93
|
+
}
|
|
94
|
+
options.scope ||= "user";
|
|
95
|
+
const agents = options.agent === "all" ? supportedAgents : [options.agent];
|
|
96
|
+
const results = [];
|
|
97
|
+
for (const agent of agents) {
|
|
98
|
+
results.push(await register({ agent, scope: agent === "copilot" && options.agent === "all" ? "project" : options.scope,
|
|
99
|
+
projectDir: options.project, dryRun: options.dryRun }));
|
|
100
|
+
}
|
|
101
|
+
console.log(JSON.stringify(results, null, 2));
|
|
102
|
+
console.log("注册完成后重新打开 Agent。在 Codex 输入 $code2knowledge,其他 Agent 使用 /code2knowledge。Codex/Claude 也可输入 code2knowledge 自然触发;未匹配时使用原生入口。按提问回答即可。");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (process.argv[1] && await fs.realpath(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
106
|
+
main().catch((error) => { console.error(error.message); process.exitCode = 1; });
|
|
107
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npm bin wrapper: execute through global-bin symlinks as well as direct paths.
|
|
3
|
+
import { main } from "./register.mjs";
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
if (args[0] === "register") args.shift();
|
|
6
|
+
main(args).catch((error) => {
|
|
7
|
+
console.error(error.message);
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
});
|