xitto-kernel 0.1.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/xitto-kernel.js +3 -0
  4. package/docs/01-architecture.md +105 -0
  5. package/docs/02-domain-pack-spec.md +109 -0
  6. package/docs/03-kernel-contract.md +79 -0
  7. package/docs/04-migration-from-xitto-code.md +70 -0
  8. package/docs/05-example-packs.md +95 -0
  9. package/docs/06-authoring-a-pack.md +86 -0
  10. package/package.json +55 -0
  11. package/src/app/cli.js +243 -0
  12. package/src/app/index.js +5 -0
  13. package/src/app/main.js +87 -0
  14. package/src/app/markdown.js +36 -0
  15. package/src/app/providers.js +39 -0
  16. package/src/app/scaffold.js +40 -0
  17. package/src/app/templates/README.md.tmpl +32 -0
  18. package/src/app/templates/gitignore.tmpl +4 -0
  19. package/src/app/templates/index.js.tmpl +7 -0
  20. package/src/app/templates/pack.js.tmpl +32 -0
  21. package/src/app/templates/package.json.tmpl +12 -0
  22. package/src/index.js +15 -0
  23. package/src/kernel/agent-loop.js +285 -0
  24. package/src/kernel/compaction.js +65 -0
  25. package/src/kernel/guard-chain.js +42 -0
  26. package/src/kernel/hooks.js +47 -0
  27. package/src/kernel/index.js +291 -0
  28. package/src/kernel/mcp.js +54 -0
  29. package/src/kernel/memory.js +45 -0
  30. package/src/kernel/pack-loader.js +45 -0
  31. package/src/kernel/provider.js +20 -0
  32. package/src/kernel/security/allow.js +41 -0
  33. package/src/kernel/security/danger.js +37 -0
  34. package/src/kernel/security/permission-step.js +63 -0
  35. package/src/kernel/security/sandbox.js +111 -0
  36. package/src/kernel/session.js +36 -0
  37. package/src/kernel/skills.js +37 -0
  38. package/src/kernel/subagent.js +46 -0
  39. package/src/kernel/tool-registry.js +45 -0
  40. package/src/packs/coding/index.js +157 -0
  41. package/src/packs/data-query/index.js +67 -0
  42. package/src/packs/notes/index.js +79 -0
  43. package/src/types.js +79 -0
@@ -0,0 +1,111 @@
1
+ // bash 沙箱 — 兩層防護:
2
+ // (1) 靜態策略(sandboxViolation):執行前正則分析命令字串,命中網路/提權/越界寫入即阻止。
3
+ // 快速、跨平台、給清楚理由;但靠字串分析,可被混淆/間接執行繞過(如 base64 解碼後執行)。
4
+ // (2) OS 級真隔離(wrapWithSeatbelt):macOS 用 sandbox-exec(Seatbelt) 把命令關進 OS 沙箱,
5
+ // 即使命令再怎麼混淆,越界寫入與網路都被核心擋死。非 macOS 自動降級為僅(1)。
6
+ // 兩層並存(defense in depth):(1) 先擋明顯違規給好理由,(2) 兜住 (1) 漏網的混淆手法。
7
+ import { existsSync, realpathSync } from 'node:fs';
8
+
9
+ // 網路相關命令
10
+ const NET_RE = /\b(curl|wget|nc|ncat|netcat|ssh|scp|sftp|telnet|rsync|ftp|svn)\b/;
11
+
12
+ // 預設允許寫入的絕對路徑前綴(其餘絕對路徑寫入視為違規;相對路徑視為工作目錄內,放行)
13
+ export const DEFAULT_SANDBOX = { enabled: false, blockNetwork: true, allowWritePrefixes: ['/tmp', '/private/tmp', '/var/folders'] };
14
+
15
+ function underAny(p, prefixes) {
16
+ return prefixes.some((pre) => p === pre || p.startsWith(pre.endsWith('/') ? pre : pre + '/'));
17
+ }
18
+
19
+ // 回傳違規原因字串,無違規回 null。opts: { blockNetwork, allowWritePrefixes }
20
+ export function sandboxViolation(command, opts = {}) {
21
+ const { blockNetwork = true, allowWritePrefixes = DEFAULT_SANDBOX.allowWritePrefixes } = opts;
22
+ const cmd = String(command || '');
23
+ if (!cmd.trim()) return null;
24
+ const lc = cmd.toLowerCase();
25
+
26
+ if (/(^|[\s;|&(])(sudo|doas)\s/.test(' ' + lc)) return '沙箱模式禁止 sudo/提權';
27
+ if (blockNetwork && NET_RE.test(lc)) return '沙箱模式禁止網路命令(curl/wget/ssh/nc 等)';
28
+
29
+ // 重定向寫入絕對路徑( > /etc/x、>> /usr/y )——不在允許前綴內即違規
30
+ for (const m of cmd.matchAll(/>>?\s*"?(\/[^\s"'<>;|&]*)/g)) {
31
+ if (!underAny(m[1], allowWritePrefixes)) return `沙箱模式禁止寫入工作目錄外:${m[1]}`;
32
+ }
33
+ // tee 寫入絕對路徑
34
+ const tee = cmd.match(/\btee\b\s+(?:-a\s+)?"?(\/[^\s"'<>;|&]*)/);
35
+ if (tee && !underAny(tee[1], allowWritePrefixes)) return `沙箱模式禁止 tee 寫入:${tee[1]}`;
36
+ // dd of= 絕對路徑
37
+ const dd = cmd.match(/\bof=\s*"?(\/[^\s"'<>;|&]*)/);
38
+ if (dd && !underAny(dd[1], allowWritePrefixes)) return `沙箱模式禁止 dd 寫入:${dd[1]}`;
39
+
40
+ return null;
41
+ }
42
+
43
+ // 把 settings.json 的 permissions.sandbox(boolean | 物件)正規化成 { enabled, blockNetwork, allowWritePrefixes }
44
+ export function normalizeSandbox(raw) {
45
+ if (raw === true) return { ...DEFAULT_SANDBOX, enabled: true };
46
+ if (raw && typeof raw === 'object') {
47
+ return {
48
+ enabled: raw.enabled !== false,
49
+ blockNetwork: raw.blockNetwork !== false,
50
+ allowWritePrefixes: Array.isArray(raw.allowWritePrefixes) ? raw.allowWritePrefixes : DEFAULT_SANDBOX.allowWritePrefixes,
51
+ };
52
+ }
53
+ return { ...DEFAULT_SANDBOX };
54
+ }
55
+
56
+ // ── OS 級真隔離(macOS Seatbelt / sandbox-exec)────────────────────────────
57
+ const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
58
+
59
+ // 此平台是否能做 OS 級沙箱(目前僅 macOS 的 sandbox-exec)。非 macOS → false(降級為靜態策略)。
60
+ export function seatbeltAvailable() {
61
+ return process.platform === 'darwin' && existsSync(SANDBOX_EXEC);
62
+ }
63
+
64
+ // Seatbelt profile 字串字面值轉義(profile 用 "..." 包路徑,需轉義 " 與 \)
65
+ const sbStr = (p) => '"' + String(p).replace(/(["\\])/g, '\\$1') + '"';
66
+ // bash 單引號轉義:把 ' 換成 '\''(讓任意內容能安全塞進 '...' )
67
+ const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`;
68
+
69
+ // 把路徑展開成「原值 + 符號連結解析後的真實路徑」兩者(去重)。
70
+ // macOS 上 /tmp→/private/tmp、/var→/private/var;Seatbelt 以核心解析後的真實路徑比對,
71
+ // 故 profile 必須含真實路徑,否則 cwd 在 /var/folders 下的寫入會被誤擋。
72
+ function canonicalPaths(paths) {
73
+ const out = new Set();
74
+ for (const p of paths) {
75
+ if (!p) continue;
76
+ out.add(p);
77
+ try { out.add(realpathSync(p)); } catch { /* 路徑不存在 → 只保留原值 */ }
78
+ }
79
+ return [...out];
80
+ }
81
+
82
+ // 產生 Seatbelt profile:預設允許一切,再「減去」網路與工作目錄外的寫入。
83
+ // 讀取/執行不限制(建置常需讀系統庫);寫入只放行 cwd + allowWritePrefixes + /dev(/dev/null 等)。
84
+ export function seatbeltProfile({ cwd, allowWritePrefixes = DEFAULT_SANDBOX.allowWritePrefixes, blockNetwork = true } = {}) {
85
+ const prefixes = canonicalPaths([cwd, ...(allowWritePrefixes || []), '/dev']);
86
+ const writeRules = prefixes.map((p) => ` (subpath ${sbStr(p)})`).join('\n');
87
+ return [
88
+ '(version 1)',
89
+ '(allow default)', // 基準放行,後面的 deny 覆蓋之(規則後者優先)
90
+ ...(blockNetwork ? ['(deny network*)'] : []),
91
+ '(deny file-write*)', // 先擋所有寫入
92
+ '(allow file-write*', // 再放行 cwd / 暫存 / /dev 內的寫入
93
+ writeRules,
94
+ ')',
95
+ '',
96
+ ].join('\n');
97
+ }
98
+
99
+ // 把命令包進 Seatbelt(sandbox-exec -p,profile 內聯、不留暫存檔)。
100
+ // 回傳改寫後可直接交給 shell 執行的命令字串;非 macOS / 無 sandbox-exec / 空命令 / 無 cwd → 回 null
101
+ // (呼叫端據此跑原命令,行為不變)。profile 與 sandboxViolation 共用同一組策略欄位,兩層一致。
102
+ export function wrapWithSeatbelt(command, { cwd, cfg = {} } = {}) {
103
+ const cmd = String(command || '');
104
+ if (!cmd.trim() || !cwd || !seatbeltAvailable()) return null;
105
+ const profile = seatbeltProfile({
106
+ cwd,
107
+ allowWritePrefixes: cfg.allowWritePrefixes || DEFAULT_SANDBOX.allowWritePrefixes,
108
+ blockNetwork: cfg.blockNetwork !== false,
109
+ });
110
+ return `${SANDBOX_EXEC} -p ${shq(profile)} /bin/bash -c ${shq(cmd)}`;
111
+ }
@@ -0,0 +1,36 @@
1
+ // 對話持久化 / resume — kernel 內建。每輪結束存 messages 到 <dir>/<id>.json,可續接。
2
+ // 對標 xitto-code session.js。id 為 YYYYMMDD-HHMMSS。
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ const pad = (n) => String(n).padStart(2, '0');
7
+
8
+ export function newSessionId(now = new Date()) {
9
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
10
+ }
11
+
12
+ export function saveSession(dir, id, data) {
13
+ mkdirSync(dir, { recursive: true });
14
+ writeFileSync(join(dir, `${id}.json`), JSON.stringify({ id, ...data, savedAt: Date.now() }, null, 2));
15
+ }
16
+
17
+ export function loadSession(dir, id) {
18
+ const p = join(dir, `${id}.json`);
19
+ if (!existsSync(p)) return null;
20
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
21
+ }
22
+
23
+ export function listSessions(dir) {
24
+ if (!existsSync(dir)) return [];
25
+ return readdirSync(dir)
26
+ .filter((f) => f.endsWith('.json'))
27
+ .map((f) => loadSession(dir, f.replace(/\.json$/, '')))
28
+ .filter(Boolean)
29
+ .map((d) => ({ id: d.id, count: d.messages?.length || 0, model: d.model?.id, savedAt: d.savedAt || 0 }))
30
+ // savedAt 新→舊;同毫秒時用 id 遞減(id 為時間戳,字典序=時間序)打破平手,確保 latest 確定性
31
+ .sort((a, b) => (b.savedAt - a.savedAt) || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
32
+ }
33
+
34
+ export function latestSession(dir) {
35
+ return listSessions(dir)[0] || null;
36
+ }
@@ -0,0 +1,37 @@
1
+ // Skills(漸進揭露)— kernel 內建。.xitto-kernel/<pack>/skills/*.md 每檔一個技能。
2
+ // system prompt 只列「名稱 + 簡述」;agent 用 skill 工具按名載入完整步驟。對標 xitto-code skills。
3
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
7
+
8
+ const firstDesc = (body) => {
9
+ const fm = body.match(/^description:\s*(.+)$/mi);
10
+ if (fm) return fm[1].trim();
11
+ return (body.split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean)) || '';
12
+ };
13
+
14
+ export function createSkills(dir) {
15
+ const skills = [];
16
+ if (existsSync(dir)) {
17
+ for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
18
+ try { const body = readFileSync(join(dir, f), 'utf8'); skills.push({ name: f.replace(/\.md$/, ''), desc: firstDesc(body), body }); } catch { /* 略 */ }
19
+ }
20
+ }
21
+
22
+ const promptSection = () => (skills.length
23
+ ? '\n\n# 可用技能(需要時用 skill 工具按名載入完整步驟)\n' + skills.map((s) => `- ${s.name}:${s.desc}`).join('\n')
24
+ : '');
25
+
26
+ const tool = skills.length ? {
27
+ name: 'skill', label: '載入技能', readOnly: true,
28
+ description: '按名載入一個技能的完整步驟(漸進揭露:prompt 只列名稱+簡述,需要時才載全文)。',
29
+ parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
30
+ execute: async (_id, { name }) => {
31
+ const s = skills.find((x) => x.name === name);
32
+ return txt(s ? s.body : { error: '找不到技能', name, available: skills.map((x) => x.name) });
33
+ },
34
+ } : null;
35
+
36
+ return { skills, promptSection, tool };
37
+ }
@@ -0,0 +1,46 @@
1
+ // 子 agent(spawn)— kernel 內建能力。派一個「唯讀」子 agent 做聚焦調查,回傳結論文字,
2
+ // 不把中間工具呼叫污染主對話。對標 Claude Code 的 Task / xitto-code 的 spawn_agent。
3
+ // 子 agent 只拿唯讀工具,故不需守衛/沙箱(無副作用)。
4
+ const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
5
+
6
+ const SUB_PROMPT =
7
+ '你是唯讀調查子 agent。只用提供的唯讀工具(讀檔/搜尋/列目錄/記憶)查證,不臆測、不杜撰;' +
8
+ '查完後用簡潔文字把結論總結給主 agent(含關鍵檔案/行號/事實),不要長篇大論。';
9
+
10
+ /**
11
+ * @param {Object} o
12
+ * @param {() => object} o.getModel
13
+ * @param {(provider: string) => string|Promise<string>} o.getApiKey
14
+ * @param {() => import('../types.js').Tool[]} o.getReadOnlyTools 子 agent 可用的唯讀工具集(不含 spawn_agent 自己)
15
+ */
16
+ export function createSpawnTool({ getModel, getApiKey, getReadOnlyTools }) {
17
+ return {
18
+ name: 'spawn_agent', label: '子 agent', readOnly: true,
19
+ description: '派一個唯讀子 agent 做聚焦調查(讀檔/搜尋/分析),回傳結論文字。'
20
+ + '適合:需要深入查證一個子問題、但不想讓中間步驟塞滿主對話時。子 agent 不能改檔或跑命令。',
21
+ parameters: { type: 'object', properties: { task: { type: 'string', description: '要子 agent 調查的具體任務' } }, required: ['task'] },
22
+ execute: async (_id, { task }) => {
23
+ const model = getModel?.();
24
+ if (!model || !getApiKey) return txt({ error: '無 model/apiKey,無法派子 agent' });
25
+ const { Agent } = await import('./agent-loop.js');
26
+ const { defaultStreamFn } = await import('./provider.js');
27
+ const sub = new Agent({
28
+ initialState: {
29
+ systemPrompt: SUB_PROMPT,
30
+ model,
31
+ tools: getReadOnlyTools(),
32
+ thinkingLevel: model.reasoning ? 'low' : 'off',
33
+ },
34
+ getApiKey,
35
+ streamFn: defaultStreamFn(),
36
+ toolExecution: 'sequential',
37
+ });
38
+ try {
39
+ await sub.prompt(String(task || ''));
40
+ const last = [...sub.state.messages].reverse().find((m) => m.role === 'assistant');
41
+ const text = (last?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
42
+ return txt(text || '(子 agent 無輸出)');
43
+ } catch (e) { return txt({ error: e?.message || String(e) }); }
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,45 @@
1
+ // 工具註冊表 — metadata 驅動,取代 xitto-code 寫死的 MUTATING/READ_ONLY 領域名單。
2
+ // kernel 只認工具自帶的 mutating/readOnly/sandboxable,不認識任何具體領域。
3
+ // 對應 docs/03-kernel-contract.md「D. 工具 metadata 驅動」。
4
+
5
+ /** @param {import('../types.js').Tool} t */
6
+ export const isReadOnly = (t) => t?.readOnly === true;
7
+ /** @param {import('../types.js').Tool} t */
8
+ export const isMutating = (t) => t?.mutating === true;
9
+ /** @param {import('../types.js').Tool} t */
10
+ export const isSandboxable = (t) => t?.sandboxable === true;
11
+
12
+ /**
13
+ * 推導「會改動狀態」的工具名集合:pack 顯式給 mutatingTools 就用,否則從工具 metadata 推。
14
+ * @param {import('../types.js').DomainPack} pack
15
+ * @param {import('../types.js').Tool[]} tools
16
+ * @returns {string[]}
17
+ */
18
+ export function deriveMutatingTools(pack, tools) {
19
+ if (Array.isArray(pack.mutatingTools)) return [...new Set(pack.mutatingTools)];
20
+ return [...new Set(tools.filter(isMutating).map((t) => t.name))];
21
+ }
22
+
23
+ /**
24
+ * 建立工具註冊表(名稱唯一)。
25
+ * @param {import('../types.js').Tool[]} tools
26
+ */
27
+ export function createToolRegistry(tools) {
28
+ if (!Array.isArray(tools)) throw new Error('tools 必須是陣列');
29
+ const byName = new Map();
30
+ for (const t of tools) {
31
+ if (!t || typeof t.name !== 'string' || !t.name) throw new Error('工具缺少有效的 name');
32
+ if (typeof t.execute !== 'function') throw new Error(`工具「${t.name}」缺少 execute 函數`);
33
+ if (byName.has(t.name)) throw new Error(`工具名重複:${t.name}`);
34
+ byName.set(t.name, t);
35
+ }
36
+ return {
37
+ all: () => [...byName.values()],
38
+ get: (name) => byName.get(name),
39
+ has: (name) => byName.has(name),
40
+ names: () => [...byName.keys()],
41
+ readOnlyNames: () => [...byName.values()].filter(isReadOnly).map((t) => t.name),
42
+ mutatingNames: () => [...byName.values()].filter(isMutating).map((t) => t.name),
43
+ sandboxableNames: () => [...byName.values()].filter(isSandboxable).map((t) => t.name),
44
+ };
45
+ }
@@ -0,0 +1,157 @@
1
+ // coding pack — 參考 DomainPack(對標 xitto-code 的編碼能力)。
2
+ // 工具以輕量真實實作示範(read/ls/write/edit 真的動檔案);bash 走 shell。
3
+ // read-before-edit 守衛與 read 工具透過閉包共享 readFiles 狀態,故能真實生效。
4
+ // 對應 docs/05-example-packs.md「A. coding pack」。
5
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
6
+ import { isAbsolute, join, relative } from 'node:path';
7
+ import { execSync } from 'node:child_process';
8
+
9
+ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
10
+
11
+ const SYSTEM_PROMPT = [
12
+ '你是嚴謹的編碼 agent。準則:',
13
+ '- 編輯既有檔案前必先 read 它的當前內容,不基於臆測修改。',
14
+ '- 一次做一件事,改動後說明你做了什麼、如何驗證。',
15
+ '- 破壞性操作先確認。',
16
+ '- 要提交時:先 git_diff 看變更,再用 git_commit 寫一則簡潔、說明「為何」的 commit 訊息。',
17
+ ].join('\n');
18
+
19
+ /**
20
+ * 建立一個帶獨立 readFiles 狀態的 coding pack。
21
+ * @param {{ cwd?: string }} [opts]
22
+ * @returns {import('../../types.js').DomainPack}
23
+ */
24
+ export function createCodingPack({ cwd = process.cwd() } = {}) {
25
+ const readFiles = new Set(); // 已 read 過的絕對路徑(read 工具寫入、read-before-edit 守衛讀取)
26
+ const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
27
+
28
+ const readTool = {
29
+ name: 'read', label: '讀檔', description: '讀取檔案內容', readOnly: true,
30
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
31
+ execute: async (_id, { path }) => {
32
+ const p = abs(path);
33
+ if (!existsSync(p)) return txt({ error: '檔案不存在', path });
34
+ readFiles.add(p);
35
+ return txt(readFileSync(p, 'utf8'));
36
+ },
37
+ };
38
+
39
+ const lsTool = {
40
+ name: 'ls', label: '列目錄', description: '列出目錄內容', readOnly: true,
41
+ parameters: { type: 'object', properties: { path: { type: 'string' } } },
42
+ execute: async (_id, { path = '.' }) => {
43
+ const p = abs(path);
44
+ if (!existsSync(p)) return txt({ error: '目錄不存在', path });
45
+ return txt(readdirSync(p).map((n) => n + (statSync(join(p, n)).isDirectory() ? '/' : '')).join('\n'));
46
+ },
47
+ };
48
+
49
+ const writeTool = {
50
+ name: 'write', label: '寫檔', description: '建立或覆寫檔案', mutating: true,
51
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
52
+ execute: async (_id, { path, content }) => {
53
+ const p = abs(path);
54
+ writeFileSync(p, content ?? '', 'utf8');
55
+ readFiles.add(p); // 寫過即視為已知內容
56
+ return txt({ written: path, bytes: Buffer.byteLength(content ?? '') });
57
+ },
58
+ };
59
+
60
+ const editTool = {
61
+ name: 'edit', label: '編輯', description: '把檔案中的 oldText 換成 newText', mutating: true,
62
+ parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
63
+ execute: async (_id, { path, oldText, newText }) => {
64
+ const p = abs(path);
65
+ if (!existsSync(p)) return txt({ error: '檔案不存在', path });
66
+ const before = readFileSync(p, 'utf8');
67
+ if (!before.includes(oldText)) return txt({ error: 'oldText 未找到', path });
68
+ writeFileSync(p, before.replace(oldText, newText), 'utf8');
69
+ return txt({ edited: path });
70
+ },
71
+ };
72
+
73
+ const bashTool = {
74
+ name: 'bash', label: 'bash', description: '執行 shell 命令', mutating: true, sandboxable: true,
75
+ parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
76
+ execute: async (_id, { command }) => {
77
+ // 註:真實的串流/逾時/沙箱包裹由移植 xitto-code 的 bash 工具取得(見 docs/04 第 3 項)。
78
+ try { return txt(execSync(command, { cwd, encoding: 'utf8', timeout: 120000 }) || '(no output)'); }
79
+ catch (e) { return txt({ error: e.message, stdout: e.stdout, stderr: e.stderr }); }
80
+ },
81
+ };
82
+
83
+ // ── git 工具(編碼領域):kernel 不認識 git,這些是 coding pack 提供的領域能力 ──
84
+ const git = (a) => { try { return execSync(`git ${a}`, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); } catch { return null; } };
85
+ const isRepo = () => { try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore' }); return true; } catch { return false; } };
86
+
87
+ const gitStatus = {
88
+ name: 'git_status', label: 'git 狀態', readOnly: true, description: '顯示目前分支與工作區變更(git status)',
89
+ parameters: { type: 'object', properties: {} },
90
+ execute: async () => { if (!isRepo()) return txt({ error: '非 git 倉庫' }); return txt({ branch: (git('rev-parse --abbrev-ref HEAD') || '').trim(), changes: (git('status --short') || '').trim() || '(乾淨)' }); },
91
+ };
92
+ const gitDiff = {
93
+ name: 'git_diff', label: 'git diff', readOnly: true, description: '顯示未提交變更的 diff(staged=true 看已暫存)',
94
+ parameters: { type: 'object', properties: { staged: { type: 'boolean' } } },
95
+ execute: async (_id, { staged } = {}) => { if (!isRepo()) return txt({ error: '非 git 倉庫' }); return txt((git(`diff ${staged ? '--cached' : ''}`) || '').trim() || '(無變更)'); },
96
+ };
97
+ const gitLog = {
98
+ name: 'git_log', label: 'git log', readOnly: true, description: '最近的提交記錄',
99
+ parameters: { type: 'object', properties: { n: { type: 'number' } } },
100
+ execute: async (_id, { n = 10 } = {}) => { if (!isRepo()) return txt({ error: '非 git 倉庫' }); return txt(git(`log --oneline -${Math.min(50, Math.max(1, n || 10))}`) || '(無提交)'); },
101
+ };
102
+ const gitCommit = {
103
+ name: 'git_commit', label: 'git commit', mutating: true,
104
+ description: '提交變更。message 由你依 git_diff 撰寫(簡潔說明做了什麼與為何);all=true 會先 git add -A。',
105
+ parameters: { type: 'object', properties: { message: { type: 'string' }, all: { type: 'boolean' } }, required: ['message'] },
106
+ execute: async (_id, { message, all }) => {
107
+ if (!isRepo()) return txt({ error: '非 git 倉庫' });
108
+ if (all) git('add -A');
109
+ try { return txt((execSync(`git commit -m ${JSON.stringify(String(message))}`, { cwd, encoding: 'utf8' }) || '').trim()); }
110
+ catch (e) { return txt({ error: (e.stdout || e.message || '').toString().trim() }); }
111
+ },
112
+ };
113
+
114
+ return {
115
+ name: 'coding',
116
+ tools: () => [readTool, lsTool, writeTool, editTool, bashTool, gitStatus, gitDiff, gitLog, gitCommit],
117
+ systemPrompt: SYSTEM_PROMPT,
118
+ contextFiles: ['CLAUDE.md', 'AGENTS.md', 'XITTO.md', '.xitto-code.md'],
119
+ // mutatingTools 省略 → kernel 從工具 metadata 推導(write/edit/bash)
120
+ verify: {
121
+ shouldRun: (ctx) => ctx.turnModified,
122
+ run: async () => {
123
+ const cmd = detectVerifyCmd(cwd);
124
+ if (!cmd) return { ok: true };
125
+ try { execSync(cmd, { cwd, stdio: 'pipe' }); return { ok: true }; }
126
+ catch (e) { return { ok: false, output: String(e.stdout || e.message).slice(0, 4000) }; }
127
+ },
128
+ maxRounds: 2,
129
+ },
130
+ preToolPolicy: {
131
+ // read-before-edit:編輯已存在但未讀過的檔 → 擋下要求先 read
132
+ check: (ctx) => {
133
+ if ((ctx.name === 'edit' || ctx.name === 'write') && ctx.args?.path) {
134
+ const p = abs(ctx.args.path);
135
+ if (existsSync(p) && !readFiles.has(p)) {
136
+ return { block: true, reason: `請先用 read 讀取 ${ctx.args.path} 的當前內容,再進行編輯(read-before-edit)。` };
137
+ }
138
+ }
139
+ return undefined;
140
+ },
141
+ },
142
+ permissionPolicy: { sandbox: { enabled: false }, defaultMode: 'default' },
143
+ };
144
+ }
145
+
146
+ // 偵測專案的型別/lint 驗證指令(簡化版;真實版見 xitto-code util.detectVerifyCmd)
147
+ function detectVerifyCmd(cwd) {
148
+ try {
149
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
150
+ if (pkg.scripts?.typecheck) return 'npm run typecheck';
151
+ if (pkg.scripts?.lint) return 'npm run lint';
152
+ } catch { /* 無 package.json → 無驗證指令 */ }
153
+ return null;
154
+ }
155
+
156
+ export const codingPack = createCodingPack();
157
+ export { relative }; // 供示範引用
@@ -0,0 +1,67 @@
1
+ // data-query pack — 第二個範例領域,用來證明「同介面、kernel 零改動」。
2
+ // 工具為示意 stub(回傳假資料),重點是六個插槽用法與 coding 完全一樣,只是內容換了。
3
+ // 對照:schema-before-query 之於資料查詢 == read-before-edit 之於編碼(同 preToolPolicy 插槽)。
4
+ // 對應 docs/05-example-packs.md「B. data-query pack」。
5
+
6
+ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
7
+
8
+ const SYSTEM_PROMPT = [
9
+ '你是資料分析 agent。準則:',
10
+ '- 下查詢前先用 list_tables / describe_table 了解結構。',
11
+ '- 破壞性 SQL(DROP/TRUNCATE/DELETE 無 WHERE)一律先確認。',
12
+ ].join('\n');
13
+
14
+ /**
15
+ * @param {{ schema?: Record<string,string[]> }} [opts]
16
+ * @returns {import('../../types.js').DomainPack}
17
+ */
18
+ export function createDataQueryPack({ schema = { orders: ['id', 'amount', 'user_id'], users: ['id', 'name'] } } = {}) {
19
+ let schemaLoaded = false; // describe/list 後設為 true,schema-before-query 守衛據此放行
20
+
21
+ const listTables = {
22
+ name: 'list_tables', label: '列表', description: '列出所有資料表', readOnly: true,
23
+ parameters: { type: 'object', properties: {} },
24
+ execute: async () => { schemaLoaded = true; return txt(Object.keys(schema)); },
25
+ };
26
+ const describeTable = {
27
+ name: 'describe_table', label: '表結構', description: '看某表欄位', readOnly: true,
28
+ parameters: { type: 'object', properties: { table: { type: 'string' } }, required: ['table'] },
29
+ execute: async (_id, { table }) => { schemaLoaded = true; return txt(schema[table] || { error: '無此表' }); },
30
+ };
31
+ const sqlQuery = {
32
+ name: 'sql_query', label: '查詢', description: '執行唯讀 SQL 查詢', readOnly: true,
33
+ parameters: { type: 'object', properties: { sql: { type: 'string' } }, required: ['sql'] },
34
+ execute: async (_id, { sql }) => txt({ note: '(示意)查詢結果', sql, rows: [] }),
35
+ };
36
+ const sqlExec = {
37
+ name: 'sql_exec', label: '寫入', description: '執行寫入型 SQL(INSERT/UPDATE/DELETE)', mutating: true,
38
+ parameters: { type: 'object', properties: { sql: { type: 'string' } }, required: ['sql'] },
39
+ execute: async (_id, { sql }) => txt({ note: '(示意)已執行', sql }),
40
+ };
41
+ const chartRender = {
42
+ name: 'chart_render', label: '畫圖', description: '把查詢結果渲染成圖表', readOnly: true,
43
+ parameters: { type: 'object', properties: { spec: { type: 'object' } }, required: ['spec'] },
44
+ execute: async (_id, { spec }) => txt({ note: '(示意)已渲染', spec }),
45
+ };
46
+
47
+ return {
48
+ name: 'data-query',
49
+ tools: () => [listTables, describeTable, sqlQuery, sqlExec, chartRender],
50
+ systemPrompt: SYSTEM_PROMPT,
51
+ contextFiles: ['SCHEMA.md', 'METRICS.md'],
52
+ // sql_query 唯讀、sql_exec 才 mutating → 從 metadata 自動推導 mutatingTools=['sql_exec']
53
+ preToolPolicy: {
54
+ // schema-before-query:沒先看過 schema 就下查詢 → 擋
55
+ check: (ctx) => {
56
+ if ((ctx.name === 'sql_query' || ctx.name === 'sql_exec') && !schemaLoaded) {
57
+ return { block: true, reason: '請先用 list_tables / describe_table 了解結構,再下 SQL。' };
58
+ }
59
+ return undefined;
60
+ },
61
+ },
62
+ permissionPolicy: { deny: ['bash:DROP', 'bash:TRUNCATE'], defaultMode: 'default' },
63
+ // verify 省略 → 查詢無「自我驗收」概念
64
+ };
65
+ }
66
+
67
+ export const dataQueryPack = createDataQueryPack();
@@ -0,0 +1,79 @@
1
+ // notes pack — 第三個範例領域:知識庫 / 筆記 agent。
2
+ // 用來示範「怎麼從零做一個新領域 agent」(見 docs/06-authoring-a-pack.md)。
3
+ // 工具操作 <cwd>/.notes/*.md;preToolPolicy 用 search-before-add(對照 read-before-edit)。
4
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
8
+ const slug = (t) => String(t).trim().toLowerCase().replace(/[^\w一-鿿]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'note';
9
+
10
+ const SYSTEM_PROMPT = [
11
+ '你是知識庫助手,管理使用者的筆記。準則:',
12
+ '- 新增筆記前,先 search_notes 確認沒有重複或相關條目。',
13
+ '- 回答問題時優先 search_notes / read_note 找既有筆記,不要憑空編造。',
14
+ ].join('\n');
15
+
16
+ /**
17
+ * @param {{ cwd?: string }} [opts]
18
+ * @returns {import('../../types.js').DomainPack}
19
+ */
20
+ export function createNotesPack({ cwd = process.cwd() } = {}) {
21
+ const dir = join(cwd, '.notes');
22
+ const searched = new Set(); // 已 search/list 過(search-before-add 守衛用)
23
+ const ensure = () => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); };
24
+ const all = () => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')) : []);
25
+
26
+ const listNotes = {
27
+ name: 'list_notes', label: '列筆記', description: '列出所有筆記標題', readOnly: true,
28
+ parameters: { type: 'object', properties: {} },
29
+ execute: async () => { searched.add('*'); return txt(all().map((f) => f.replace(/\.md$/, '')) || []); },
30
+ };
31
+ const searchNotes = {
32
+ name: 'search_notes', label: '搜尋筆記', description: '依關鍵字搜尋筆記標題與內容', readOnly: true,
33
+ parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
34
+ execute: async (_id, { query }) => {
35
+ searched.add('*');
36
+ const q = String(query || '').toLowerCase();
37
+ const hits = all().filter((f) => (f + readFileSync(join(dir, f), 'utf8')).toLowerCase().includes(q));
38
+ return txt({ query, hits: hits.map((f) => f.replace(/\.md$/, '')) });
39
+ },
40
+ };
41
+ const readNote = {
42
+ name: 'read_note', label: '讀筆記', description: '讀取某篇筆記內容', readOnly: true,
43
+ parameters: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
44
+ execute: async (_id, { title }) => {
45
+ const p = join(dir, slug(title) + '.md');
46
+ return existsSync(p) ? txt(readFileSync(p, 'utf8')) : txt({ error: '找不到筆記', title });
47
+ },
48
+ };
49
+ const addNote = {
50
+ name: 'add_note', label: '新增筆記', description: '新增一篇筆記(標題 + 內容)', mutating: true,
51
+ parameters: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title', 'body'] },
52
+ execute: async (_id, { title, body }) => {
53
+ ensure();
54
+ const p = join(dir, slug(title) + '.md');
55
+ writeFileSync(p, `# ${title}\n\n${body}\n`, 'utf8');
56
+ return txt({ saved: title, file: p });
57
+ },
58
+ };
59
+
60
+ return {
61
+ name: 'notes',
62
+ tools: () => [listNotes, searchNotes, readNote, addNote],
63
+ systemPrompt: SYSTEM_PROMPT,
64
+ contextFiles: ['NOTES.md'],
65
+ // mutatingTools 省略 → 從 metadata 推導(add_note)
66
+ preToolPolicy: {
67
+ // search-before-add:沒先 search/list 就新增 → 擋(避免重複,對照 read-before-edit)
68
+ check: (ctx) => {
69
+ if (ctx.name === 'add_note' && !searched.has('*')) {
70
+ return { block: true, reason: '新增前請先 search_notes 或 list_notes 確認沒有重複。' };
71
+ }
72
+ return undefined;
73
+ },
74
+ },
75
+ permissionPolicy: { defaultMode: 'default' },
76
+ };
77
+ }
78
+
79
+ export const notesPack = createNotesPack();