xitto-kernel 0.3.3 → 0.3.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.7
4
+
5
+ - **技能結晶政策閘門(驗證才算數)**:每個自寫技能新增時必須有明確目標 + 通過的驗證,否則不落地。
6
+ - `skill_save` 新增必填 `goal`(明確目標)與 `verify`(驗證指令)
7
+ - **verify 在沙箱實際執行,exit 0 才新增**;未過則拒絕並回傳輸出供 agent 修正
8
+ - 危險驗證指令一律擋(複用 `dangerousReason`/`sandboxViolation`);開沙箱時 Seatbelt 包執行
9
+ - 落地檔記 `goal`/`verified: true`/`verifiedAt` + `## 驗證(已通過)` 區塊(為日後重驗/衰減鋪路)
10
+ - kernel 新增 `runVerify`(注入 createSkills);確保結晶的是「已驗證的成功」而非「宣稱的成功」
11
+ - 7 個測試 + 真實 runVerify 端到端(通過→新增 / 失敗→拒絕 / 危險→擋 / 缺 goal→拒)
12
+
13
+ ## 0.3.6
14
+
15
+ - **自我結晶技能(結晶層)**:agent 摸出可重複流程時自己寫成技能,跨任務/跨 session 複用。
16
+ - 新增 kernel 內建工具 `skill_save`(把流程結晶成 `.xitto-kernel/<pack>/skills/<name>.md`,含 frontmatter description)
17
+ - `skill` 載入改為**熱掃描**:本 session 剛結晶的技能即時可載;未來 session 自動列入「可用技能」
18
+ - `skill`/`skill_save` **永遠可用**(即使尚無技能,才能結晶第一個);name slug 化防路徑穿越,同名覆蓋
19
+ - 漸進揭露不變:prompt 只列名稱+簡述,需要時才載全文
20
+ - `/skills`(查看)、`/skills forget <名>`;`api.skills.{list,remove,reload,path}`
21
+ - 6 個測試 + 真實 model 端到端閉環(結晶 → 落地 → 新 session 列出並載入全文)
22
+ - 至此「執行中沉澱經驗」反射/程序/結晶三層皆落地(事實/情節層待後續)
23
+
24
+ ## 0.3.5
25
+
26
+ - **執行中沉澱經驗 — 專案手冊(程序層)**:agent 摸清專案「做事方法」時自己記下來,跨 session 自動載入。
27
+ - 新增 kernel 內建工具 `playbook_update`(按 topic 記/更新,同 topic 覆蓋去重)、`playbook_remove`
28
+ - 落地 `.xitto-kernel/<pack>/playbook.md`;綁 cwd → 天然只對該專案生效(自帶相關性範圍)
29
+ - 開場自動注入 system prompt(`# 專案手冊`)+ 引導語;與 `memory`(事實層) 分工明確
30
+ - `/playbook`(查看)、`/playbook forget <主題>`、`/playbook clear`;`api.playbook.{list,update,remove,clear,load,path}`
31
+ - 5 個測試(topic 去重/多條/落地重載/多行 note/kernel 注入)+ 真實 model 端到端閉環驗證
32
+ (agent 呼叫 playbook_update → 落地 → 新 session 自動載入)
33
+
34
+ ## 0.3.4
35
+
36
+ - **漸進式放權(per-pattern 記住批准)**:把「事事都問的煩」和「全自動的怕」同時解掉。
37
+ - 批准工具時可選 `[a]` 信任整個工具、或 `[c]` 只信任「命令簽章類」(`git status` ≠ `npm install`)
38
+ - 信任**落地到 `.xitto-kernel/<pack>/allow.json`,跨 session 累積**;重啟後同類自動放行
39
+ - 自動放行時標示「✓ 已信任」(`onTrusted` 回呼,維持可理解性)
40
+ - **危險命令永不寫入信任**,即使選 always 也只放行這次
41
+ - `/trust`(查看)、`/trust forget <項>`、`/trust clear`;`api.permissions.{list,forget,clear,path}`
42
+ - 新增 `allow-store.js`(`memoryAllowStore` / `fileAllowStore`);接通既有 `parseAllowFile`/`commandSignature`
43
+ - 6 個測試 + 跨 kernel 實例(模擬重啟)整合驗證
44
+
3
45
  ## 0.3.3
4
46
 
5
47
  - **背景任務 + 完成通知(非同步交互)**:server 新增「派任務→通知」形態,把 agent 當同事用。
package/README.md CHANGED
@@ -48,7 +48,13 @@ xitto-kernel --pack data-query
48
48
  xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
49
49
  ```
50
50
 
51
- **CLI 內操作**:直接打需求(模型會自己呼叫工具);指令 `/help` `/goal <目標>` `/sandbox` `/plan` `/undo` `/tools` `/memory` `/sessions` `/resume` `/exit`;`Ctrl+C` 中斷該輪、閒置時再按一次離開。
51
+ **CLI 內操作**:直接打需求(模型會自己呼叫工具);指令 `/help` `/goal <目標>` `/sandbox` `/plan` `/undo` `/tools` `/trust` `/memory` `/sessions` `/resume` `/exit`;`Ctrl+C` 中斷該輪、閒置時再按一次離開。
52
+
53
+ **漸進式放權(trust 隨用累積)**:mutating/危險工具執行前會確認;批准時可選 `[a]` 信任整個工具、或 `[c]` 只信任「該命令簽章類」(如 `git status`、`npm test`——細粒度,`npm install` 仍會問)。選擇會**落地到 `.xitto-kernel/<pack>/allow.json`,跨 session 記得**,下次同類自動放行並標示「✓ 已信任」。`/trust` 查看、`/trust forget <項>` 撤銷、`/trust clear` 全清。一開始謹慎、用著用著越來越順手——危險命令永不寫入信任,每次都把關。
54
+
55
+ **執行中沉澱經驗(專案手冊)**:agent 摸清「這個專案怎麼做事」(建置/測試/部署指令、慣例、必經步驟、踩過的坑與修法)時,會用 `playbook_update` 按 topic 記進 `.xitto-kernel/<pack>/playbook.md`(同 topic 覆蓋,天然去重);**下次 session 自動載入系統提示,不必重新摸索**。因檔案綁 cwd,手冊天然只對這個專案生效。`/playbook` 查看、`/playbook forget <主題>`、`/playbook clear`。分工:`memory` 存事實/偏好/決策(扁平),`playbook` 存可重複的程序知識(按主題)。
56
+
57
+ **自我結晶技能(結晶層,須驗證)**:摸出一套可重複的操作流程/SOP 時,agent 用 `skill_save` 把它**寫成新技能**(markdown)存進 `.xitto-kernel/<pack>/skills/`。**政策閘門:每個技能新增時必須附 (1) `goal` 明確目標 (2) `verify` 一條驗證指令——verify 會在沙箱實際執行,通過(exit 0)才落地**,否則拒絕並回傳輸出讓 agent 修正(危險指令一律擋下)。確保結晶的是「已驗證的成功」而非「宣稱的成功」。**本 session 立即可用 `skill` 按名載入(熱掃描),未來 session 自動列入「可用技能」**(漸進揭露:prompt 只列名稱+簡述,需要時才載全文)。`/skills` 查看、`/skills forget <名>` 移除。分工:`playbook` 是專案事實性 know-how,`skill` 是可跨任務複用且**已驗證**的操作流程。這層讓 agent 像 Voyager 一樣**長出自己的技能庫**——但每條都經驗證,且跑在 kernel 的沙箱 + 漸進信任治理裡。
52
58
 
53
59
  **通用自主 agent(給目標、自己做到完成)**
54
60
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.3.3",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/cli.js CHANGED
@@ -36,6 +36,10 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
36
36
  getSandbox: () => sandboxOn, // on/off 由 CLI 即時切換
37
37
  getPlanMode: () => planMode, // 計劃模式:守衛擋 mutating 工具
38
38
  confirm: askConfirm, // 互動權限確認(mutating/危險工具執行前)
39
+ onTrusted: ({ name, signature, scope }) => { // 漸進放權:自動放行時標示「已信任」(維持可理解)
40
+ endStream();
41
+ out(c.gray(` ✓ 已信任 ${scope === 'command' ? `「${signature}」類` : name},自動放行\n`));
42
+ },
39
43
  });
40
44
 
41
45
  let history = [];
@@ -63,19 +67,25 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
63
67
  };
64
68
 
65
69
  // 互動權限確認:守衛鏈第 5 格對 mutating/危險工具呼叫此函數。autoApprove → 一律放行。
66
- // 回 'yes'(允許一次)/ 'always'(此工具全部)/ 'no'(拒絕)。
67
- async function askConfirm(name, args, danger) {
70
+ // 回 'yes'(允許一次)/ 'command'(信任此命令簽章類,跨 session)/ 'always'(信任此工具全部)/ 'no'(拒絕)。
71
+ async function askConfirm(name, args, danger, meta = {}) {
68
72
  if (autoApprove && !danger) return 'yes'; // 自動模式仍對危險命令把關
69
73
  endStream();
74
+ const sig = meta.signature; // 有簽章(bash 類)才提供細粒度「信任這類命令」
70
75
  return new Promise((res) => {
71
76
  const warn = danger ? c.red(` ⛔ 危險:${danger}\n`) : '';
72
77
  out(warn + c.yellow(' 需要許可 ') + c.bold(name) + c.gray('(' + summarize(args) + ')') + '\n');
73
- const hint = danger ? '[y]允許一次 [n]拒絕' : '[y]允許 [a]此工具全部 [n]拒絕';
78
+ const hint = danger ? '[y]允許一次 [n]拒絕'
79
+ : sig ? `[y]允許 [c]信任「${sig}」類 [a]信任 ${name} 全部 [n]拒絕`
80
+ : `[y]允許 [a]信任 ${name} 全部 [n]拒絕`;
81
+ out(c.gray(' (c/a 會記住,下次自動放行;/trust 查看與撤銷)\n'));
74
82
  try {
75
83
  rl.question(c.yellow(` ${hint} › `), (ans) => {
76
84
  const a = (ans || '').trim().toLowerCase();
77
85
  if (danger) return res(a === 'y' || a === 'yes' ? 'yes' : 'no');
78
- res(a === 'y' || a === 'yes' ? 'yes' : a === 'a' ? 'always' : 'no');
86
+ if (a === 'a') return res('always');
87
+ if (a === 'c' && sig) return res('command');
88
+ res(a === 'y' || a === 'yes' ? 'yes' : 'no');
79
89
  });
80
90
  } catch { res('no'); }
81
91
  });
@@ -163,7 +173,10 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
163
173
  ' /goal <目標> 目標驅動自主循環(反覆做到完成)',
164
174
  ' /undo 撤銷上一次檔案改動(write/edit)',
165
175
  ' /tools 列出此 pack 的工具',
176
+ ' /trust [forget <項>|clear] 已信任的工具/命令(漸進放權,跨 session)',
166
177
  ' /memory 顯示跨 session 記憶',
178
+ ' /playbook [forget <主題>|clear] 專案手冊(agent 沉澱的程序知識,跨 session)',
179
+ ' /skills [forget <名>] 已結晶的技能(agent 自寫的可複用流程)',
167
180
  ' /sessions 列出已保存的對話',
168
181
  ' /resume [id] 續接對話(不給 id=最近一次)',
169
182
  ' /clear 清除歷史(開新 session)',
@@ -175,6 +188,50 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
175
188
  out(mems.length ? c.gray(mems.map((m) => ' • ' + m).join('\n') + '\n') : c.gray('(尚無記憶)\n'));
176
189
  return true;
177
190
  }
191
+ case '/playbook': {
192
+ const rest = input.trim().slice(cmd.length).trim();
193
+ if (rest === 'clear') { const { cleared } = kernel.playbook.clear(); out(c.gray(`(已清空專案手冊,移除 ${cleared} 條)\n`)); return true; }
194
+ if (rest.startsWith('forget ')) {
195
+ const t = rest.slice('forget '.length).trim();
196
+ const r = kernel.playbook.remove(t);
197
+ out(r.removed ? c.gray(`(已移除「${t}」)\n`) : c.yellow(`找不到主題「${t}」\n`));
198
+ return true;
199
+ }
200
+ const entries = kernel.playbook.list();
201
+ if (!entries.length) { out(c.gray('(尚無專案手冊;agent 摸清做法時會用 playbook_update 累積)\n')); return true; }
202
+ out(entries.map((e) => c.cyan(` ## ${e.topic}\n`) + c.gray(e.note.split('\n').map((l) => ' ' + l).join('\n'))).join('\n') + '\n');
203
+ if (kernel.playbook.path) out(c.gray(` ↳ ${kernel.playbook.path}(清除:/playbook forget <主題>)\n`));
204
+ return true;
205
+ }
206
+ case '/skills': {
207
+ const rest = input.trim().slice(cmd.length).trim();
208
+ if (rest.startsWith('forget ')) {
209
+ const n = rest.slice('forget '.length).trim();
210
+ const r = kernel.skills.remove(n);
211
+ out(r.removed ? c.gray(`(已移除技能「${r.removed}」)\n`) : c.yellow(`找不到技能「${n}」\n`));
212
+ return true;
213
+ }
214
+ const sk = kernel.skills.list();
215
+ if (!sk.length) { out(c.gray('(尚無技能;agent 摸出可重複流程時會用 skill_save 結晶)\n')); return true; }
216
+ out(sk.map((s) => c.cyan(` • ${s.name}`) + c.gray(`:${s.desc}`)).join('\n') + '\n');
217
+ if (kernel.skills.path) out(c.gray(` ↳ ${kernel.skills.path}(移除:/skills forget <名>)\n`));
218
+ return true;
219
+ }
220
+ case '/trust': {
221
+ const rest = input.trim().slice(cmd.length).trim();
222
+ if (rest === 'clear') { kernel.permissions.clear(); out(c.gray('(已清除全部信任)\n')); return true; }
223
+ if (rest.startsWith('forget ')) {
224
+ const entry = rest.slice('forget '.length).trim();
225
+ out(kernel.permissions.forget(entry) ? c.gray(`(已撤銷信任:${entry})\n`) : c.yellow(`找不到信任項「${entry}」\n`));
226
+ return true;
227
+ }
228
+ const { tools, bash } = kernel.permissions.list();
229
+ if (!tools.length && !bash.length) { out(c.gray('(尚無已信任項;批准工具時選 a/c 即可記住)\n')); return true; }
230
+ if (tools.length) out(c.gray(' 工具(全部放行):') + tools.join('、') + '\n');
231
+ if (bash.length) out(c.gray(' 命令(簽章類):') + bash.map((s) => `「${s}」`).join('、') + '\n');
232
+ if (kernel.permissions.path) out(c.gray(` ↳ ${kernel.permissions.path}(撤銷:/trust forget <項>)\n`));
233
+ return true;
234
+ }
178
235
  case '/sessions': {
179
236
  const ss = kernel.session.list();
180
237
  out(ss.length
@@ -225,7 +282,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
225
282
  };
226
283
 
227
284
  // 斜線指令 tab 補全
228
- const SLASH = ['/help', '/goal ', '/sandbox', '/auto', '/plan', '/undo', '/tools', '/memory', '/sessions', '/resume', '/clear', '/exit'];
285
+ const SLASH = ['/help', '/goal ', '/sandbox', '/auto', '/plan', '/undo', '/tools', '/trust', '/memory', '/playbook', '/skills', '/sessions', '/resume', '/clear', '/exit'];
229
286
  const completer = (line) => {
230
287
  if (!line.startsWith('/')) return [[], line];
231
288
  const hits = SLASH.filter((s) => s.startsWith(line));
@@ -8,8 +8,12 @@ import { loadPack } from './pack-loader.js';
8
8
  import { createToolRegistry, deriveMutatingTools, isSandboxable } from './tool-registry.js';
9
9
  import { composeGuards } from './guard-chain.js';
10
10
  import { createPermissionStep } from './security/permission-step.js';
11
- import { normalizeSandbox, wrapWithSeatbelt } from './security/sandbox.js';
11
+ import { fileAllowStore, memoryAllowStore } from './security/allow-store.js';
12
+ import { normalizeSandbox, wrapWithSeatbelt, sandboxViolation } from './security/sandbox.js';
13
+ import { dangerousReason } from './security/danger.js';
14
+ import { spawnSync } from 'node:child_process';
12
15
  import { createMemory } from './memory.js';
16
+ import { createPlaybook } from './playbook.js';
13
17
  import { createTodo } from './todo.js';
14
18
  import { createSpawnTool } from './subagent.js';
15
19
  import { createSkills } from './skills.js';
@@ -39,7 +43,10 @@ function loadContextFiles(cwd, names) {
39
43
  }
40
44
 
41
45
  const DEFAULT_MEMORY_GUIDE =
42
- '遇到值得跨 session 記住的事實(使用者偏好、建置/測試指令、踩過的坑、專案決策)時,當下就存一條。';
46
+ '遇到值得跨 session 記住的事實(使用者偏好、踩過的坑、專案決策)時,當下就存一條。';
47
+
48
+ const DEFAULT_PLAYBOOK_GUIDE =
49
+ '摸清這個專案的「做事方法」(如何建置/測試/執行/部署、慣例、必經步驟、坑與修法)時,用 playbook_update 按 topic 記下來(同 topic 覆蓋);過時就用 playbook_remove 清掉。下次自動載入,不必重新摸索。分工:memory 存事實/偏好/決策,playbook 存可重複的程序步驟。';
43
50
 
44
51
  // 把 sandboxable 工具的命令在執行期包進 Seatbelt(macOS OS 級隔離)。
45
52
  // 非 macOS / 沙箱關閉 / 無 command → wrapWithSeatbelt 回 null,跑原命令(仍受第 5 格靜態策略保護)。
@@ -100,23 +107,43 @@ export function createKernel(pack, config = {}) {
100
107
  // 每個 pack 在 cwd 下有獨立資料夾(記憶、session 分領域存放,互不混)
101
108
  const dataDir = join(cwd, '.xitto-kernel', pack.name);
102
109
  const memory = createMemory(join(dataDir, 'memory.md'));
110
+ const playbook = createPlaybook(join(dataDir, 'playbook.md'));
103
111
  const todo = createTodo();
104
112
  const sessionsDir = join(dataDir, 'sessions');
105
113
  const hooks = loadHooks(join(dataDir, 'settings.json')); // PreToolUse/PostToolUse
106
- const skills = createSkills(join(dataDir, 'skills')); // 漸進揭露技能
107
114
 
108
115
  // 沙箱策略:config.sandbox > pack.permissionPolicy.sandbox > 預設(關)。
109
116
  const sandboxCfg = normalizeSandbox(config.sandbox ?? pack.permissionPolicy?.sandbox);
110
117
  const getSandbox = config.getSandbox || (() => !!sandboxCfg.enabled);
111
118
  const getSandboxConfig = () => sandboxCfg;
112
119
 
120
+ // 技能驗證器:結晶新技能前,先在沙箱跑一條驗證指令,須 exit 0 才准新增(結晶=已驗證的成功)。
121
+ // 靜態安全:危險指令一律擋;開沙箱時併查靜態策略,再用 Seatbelt 包執行。
122
+ const runVerify = (command, { timeoutMs = 60000 } = {}) => {
123
+ const cmd = String(command || '').trim();
124
+ if (!cmd) return { ok: false, blocked: true, reason: 'verify 指令為空' };
125
+ const danger = dangerousReason(cmd);
126
+ if (danger) return { ok: false, blocked: true, reason: `驗證指令危險(${danger}),拒絕執行` };
127
+ if (getSandbox()) {
128
+ const v = sandboxViolation(cmd, getSandboxConfig());
129
+ if (v) return { ok: false, blocked: true, reason: `驗證指令違反沙箱策略(${v})` };
130
+ }
131
+ const finalCmd = (getSandbox() ? wrapWithSeatbelt(cmd, { cwd, cfg: getSandboxConfig() }) : null) || cmd;
132
+ const r = spawnSync(finalCmd, { shell: true, cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 });
133
+ const output = ((r.stdout || '') + (r.stderr || '')).trim().slice(0, 4000);
134
+ if (r.error) return { ok: false, code: null, output: (output + ' ' + r.error.message).trim() };
135
+ return { ok: r.status === 0, code: r.status, output: output || '(no output)' };
136
+ };
137
+ const skills = createSkills(join(dataDir, 'skills'), { verifyRunner: runVerify }); // 漸進揭露 + 結晶(須驗證)
138
+
113
139
  // 工具:pack 工具(sandboxable 包 Seatbelt、mutating+path 加 undo 快照)+ kernel 內建記憶工具 + spawn_agent。
114
140
  const undoStack = [];
115
141
  const baseTools = [
116
142
  ...pack.tools().map((t) => wrapUndo(wrapSandboxable(t, { cwd, getSandbox, getSandboxConfig }), { cwd, undoStack })),
117
143
  ...memory.tools,
144
+ ...playbook.tools,
118
145
  todo.tool,
119
- ...(skills.tool ? [skills.tool] : []),
146
+ ...skills.tools,
120
147
  ...(config.extraTools || []), // 外部注入(MCP 工具等):由 app 層先 async 載入再傳入
121
148
  ];
122
149
  // spawn_agent:派唯讀子 agent。其可用工具 = 所有唯讀工具(不含 spawn_agent 自己,避免遞迴)。
@@ -138,20 +165,29 @@ export function createKernel(pack, config = {}) {
138
165
  };
139
166
 
140
167
  const memText = memory.load();
168
+ const pbText = playbook.load();
141
169
  const systemPrompt =
142
170
  pack.systemPrompt +
143
171
  loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
144
- '\n\n# 記憶\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) +
172
+ '\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE +
145
173
  (memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
174
+ (pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
146
175
  skills.promptSection();
147
176
 
148
177
  const getPlanMode = config.getPlanMode || (() => false);
149
178
 
179
+ // 漸進式放權:已信任的工具/命令簽章跨 session 累積。
180
+ // 預設落地到 .xitto-kernel/<pack>/allow.json;config.allowStore=false → 不持久化(記憶體版);給字串 → 自訂路徑。
181
+ const allowStore = config.allowStore === false ? memoryAllowStore()
182
+ : fileAllowStore(typeof config.allowStore === 'string' ? config.allowStore : join(dataDir, 'allow.json'));
183
+
150
184
  // 守衛鏈第 5 格:真實權限/沙箱(A 半部:靜態策略 deny/網路/提權/越界寫入 + 危險命令)。
151
185
  const permission = createPermissionStep({
152
186
  registry, getSandbox, getSandboxConfig,
153
187
  deny: pack.permissionPolicy?.deny || [],
154
188
  confirm: config.confirm,
189
+ store: allowStore,
190
+ onTrusted: config.onTrusted,
155
191
  });
156
192
 
157
193
  const guard = composeGuards({
@@ -172,8 +208,19 @@ export function createKernel(pack, config = {}) {
172
208
  systemPrompt,
173
209
  services,
174
210
  permissionPolicy: pack.permissionPolicy || {},
211
+ // 已信任清單(漸進放權):列出 / 移除 / 全清;path 為落地檔(記憶體版為 null)。
212
+ permissions: {
213
+ list: () => allowStore.list(),
214
+ forget: (entry) => allowStore.remove(entry),
215
+ clear: () => allowStore.clear(),
216
+ path: allowStore.path,
217
+ },
175
218
  sandbox: { isOn: () => getSandbox(), config: () => getSandboxConfig() },
176
219
  memory,
220
+ // 專案手冊(程序層沉澱):列出 / 更新 / 移除 / 全清;path 為落地檔。
221
+ playbook: { list: playbook.list, update: playbook.update, remove: playbook.remove, clear: playbook.clear, load: playbook.load, path: join(dataDir, 'playbook.md') },
222
+ // 技能(結晶層):列出 / 移除 / 重掃;path 為技能資料夾。
223
+ skills: { list: skills.list, remove: skills.remove, reload: skills.reload, path: join(dataDir, 'skills') },
177
224
  todo: { get: todo.get },
178
225
  /** 撤銷上一次檔案改動(write/edit):還原內容,新建的檔則刪除。 */
179
226
  undo: () => {
@@ -0,0 +1,89 @@
1
+ // 專案手冊(程序層沉澱)— kernel 內建。agent 執行中把「這個專案怎麼做事」沉澱下來,跨 session 自動載入。
2
+ // 與 memory 的分工:memory 存「事實/偏好/決策」(扁平一行一條);playbook 存「可重複的程序知識」
3
+ // (建置/測試/部署指令、慣例、必經步驟、踩過的坑與修法),按 topic 組織、同 topic 覆蓋(天然去重)。
4
+ // 落地到 <cwd>/.xitto-kernel/<pack>/playbook.md → 因綁 cwd,天然只對「這個專案」生效(自帶相關性範圍)。
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
7
+
8
+ const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
9
+ const HEADER = '# 專案手冊';
10
+
11
+ // 解析:以 `## <topic>` 分節,標題下方至下一個 `##` 為 note。
12
+ function parse(md) {
13
+ if (!md) return [];
14
+ const entries = [];
15
+ for (const block of md.split(/\n(?=##\s)/)) {
16
+ const m = block.match(/^##\s+(.+?)(?:\n([\s\S]*))?$/);
17
+ if (!m) continue;
18
+ const topic = m[1].trim();
19
+ const note = (m[2] || '').trim();
20
+ if (topic) entries.push({ topic, note });
21
+ }
22
+ return entries;
23
+ }
24
+
25
+ function serialize(entries) {
26
+ return HEADER + '\n\n' + entries.map((e) => `## ${e.topic}\n${e.note}`).join('\n\n') + '\n';
27
+ }
28
+
29
+ /**
30
+ * @param {string} file 手冊檔路徑(如 <cwd>/.xitto-kernel/<pack>/playbook.md)
31
+ */
32
+ export function createPlaybook(file) {
33
+ const read = () => (existsSync(file) ? readFileSync(file, 'utf8') : '');
34
+ const list = () => parse(read());
35
+ const load = () => read().trim();
36
+
37
+ const writeEntries = (entries) => {
38
+ if (!entries.length) { try { if (existsSync(file)) unlinkSync(file); } catch { /* 略 */ } return; }
39
+ mkdirSync(dirname(file), { recursive: true });
40
+ writeFileSync(file, serialize(entries));
41
+ };
42
+
43
+ const update = (topic, note) => {
44
+ const t = String(topic || '').trim();
45
+ const n = String(note || '').trim();
46
+ if (!t) return { error: 'topic 不可為空' };
47
+ if (!n) return { error: 'note 不可為空' };
48
+ const entries = list();
49
+ const i = entries.findIndex((e) => e.topic.toLowerCase() === t.toLowerCase());
50
+ if (i >= 0) {
51
+ if (entries[i].note === n) return { skipped: true, topic: entries[i].topic };
52
+ entries[i] = { topic: entries[i].topic, note: n }; // 保留原始大小寫,只換內容
53
+ writeEntries(entries);
54
+ return { updated: entries[i].topic };
55
+ }
56
+ entries.push({ topic: t, note: n });
57
+ writeEntries(entries);
58
+ return { added: t };
59
+ };
60
+
61
+ const remove = (topic) => {
62
+ const t = String(topic || '').trim().toLowerCase();
63
+ const entries = list();
64
+ const kept = entries.filter((e) => e.topic.toLowerCase() !== t);
65
+ if (kept.length === entries.length) return { error: '找不到主題', topic };
66
+ writeEntries(kept);
67
+ return { removed: topic };
68
+ };
69
+
70
+ const clear = () => { const n = list().length; writeEntries([]); return { cleared: n }; };
71
+
72
+ // playbook_* 只動 kernel 自己的手冊檔(agent 簿記),標 readOnly → 守衛鏈自動放行
73
+ const tools = [
74
+ {
75
+ name: 'playbook_update', label: '記專案手冊', readOnly: true,
76
+ description: '把這個專案的「做事方法」(程序知識)記下來或更新:建置/測試/執行/部署指令、專案慣例、必經步驟、踩過的坑與修法。按 topic 組織,同 topic 會覆蓋(避免重複)。下次 session 自動載入,省得重新摸索。與 memory 的差別:memory 存事實/偏好/決策,playbook 存可重複的程序步驟。',
77
+ parameters: { type: 'object', properties: { topic: { type: 'string', description: '主題,如「測試」「建置」「部署地雷」' }, note: { type: 'string', description: '具體做法/步驟/注意事項' } }, required: ['topic', 'note'] },
78
+ execute: async (_id, { topic, note }) => txt(update(topic, note)),
79
+ },
80
+ {
81
+ name: 'playbook_remove', label: '清專案手冊', readOnly: true,
82
+ description: '移除一條過時的專案手冊條目(按 topic)。手冊內容過時或錯誤時清掉,避免誤導未來的 session。',
83
+ parameters: { type: 'object', properties: { topic: { type: 'string' } }, required: ['topic'] },
84
+ execute: async (_id, { topic }) => txt(remove(topic)),
85
+ },
86
+ ];
87
+
88
+ return { load, list, update, remove, clear, tools };
89
+ }
@@ -0,0 +1,40 @@
1
+ // 漸進式放權的「已信任」儲存:記住使用者批准過的工具/命令簽章,跨 session 累積。
2
+ // 兩種實作:memory(headless/測試,不落地)與 file(落地到 .xitto-kernel/<pack>/allow.json)。
3
+ // 格式與細粒度簽章見 allow.js(parseAllowFile / serializeAllow / commandSignature)。
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+ import { parseAllowFile, serializeAllow } from './allow.js';
7
+
8
+ function makeStore(seed, flush, path) {
9
+ const tools = new Set(seed.tools || []);
10
+ const bash = new Set(seed.bash || []);
11
+ const save = () => flush?.(tools, bash);
12
+ return {
13
+ path: path || null,
14
+ hasTool: (n) => tools.has(n),
15
+ hasSig: (s) => bash.has(s),
16
+ addTool: (n) => { if (!tools.has(n)) { tools.add(n); save(); } },
17
+ addSig: (s) => { if (s && !bash.has(s)) { bash.add(s); save(); } },
18
+ // forget:可傳工具名或 bash 簽章;回傳是否真的移除了
19
+ remove: (entry) => { const a = tools.delete(entry); const b = bash.delete(entry); if (a || b) save(); return a || b; },
20
+ clear: () => { const had = tools.size + bash.size > 0; tools.clear(); bash.clear(); if (had) save(); return had; },
21
+ list: () => ({ tools: [...tools], bash: [...bash] }),
22
+ size: () => tools.size + bash.size,
23
+ };
24
+ }
25
+
26
+ // 記憶體版:session 內有效,重啟即忘(headless / 測試 / 明確關閉持久化時)。
27
+ export function memoryAllowStore(seed = {}) {
28
+ return makeStore(seed, null, null);
29
+ }
30
+
31
+ // 檔案版:啟動時載入既有信任,每次變更立即落地。重啟後信任仍在 → 漸進累積。
32
+ export function fileAllowStore(path) {
33
+ let parsed = { tools: [], bash: [] };
34
+ try { if (existsSync(path)) parsed = parseAllowFile(JSON.parse(readFileSync(path, 'utf8'))); } catch { /* 壞檔忽略,當空 */ }
35
+ const flush = (tools, bash) => {
36
+ try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, serializeAllow(tools, bash) + '\n'); }
37
+ catch { /* 落地失敗不影響本回合放行 */ }
38
+ };
39
+ return makeStore(parsed, flush, path);
40
+ }
@@ -5,6 +5,7 @@
5
5
  import { sandboxViolation } from './sandbox.js';
6
6
  import { dangerousReason } from './danger.js';
7
7
  import { commandSignature } from './allow.js';
8
+ import { memoryAllowStore } from './allow-store.js';
8
9
 
9
10
  /**
10
11
  * @param {Object} o
@@ -12,14 +13,15 @@ import { commandSignature } from './allow.js';
12
13
  * @param {() => boolean} [o.getSandbox] 沙箱是否開啟
13
14
  * @param {() => object} [o.getSandboxConfig] 沙箱策略(blockNetwork/allowWritePrefixes)
14
15
  * @param {string[]} [o.deny] 禁止的工具名 / "bash:<簽章>"
15
- * @param {(name: string, args: object, danger: string|null) => Promise<'yes'|'no'|'always'|'command'>} [o.confirm]
16
+ * @param {object} [o.store] 已信任儲存(漸進放權,跨 session);預設記憶體版
17
+ * @param {(name: string, args: object, danger: string|null, meta: {signature?: string}) => Promise<'yes'|'no'|'always'|'command'>} [o.confirm]
16
18
  * 互動確認;不提供(headless)時:危險命令一律擋、其餘放行(沙箱靜態違規仍先擋)。
19
+ * @param {(info: {name: string, signature: string|null, scope: 'tool'|'command'}) => void} [o.onTrusted]
20
+ * 從已信任儲存自動放行時通知(讓 app 顯示「已信任」,維持可理解性)。
17
21
  * @returns {(ctx: { name: string, args: object }) => Promise<import('../../types.js').PolicyDecision>}
18
22
  */
19
- export function createPermissionStep({ registry, getSandbox, getSandboxConfig, deny = [], confirm }) {
23
+ export function createPermissionStep({ registry, getSandbox, getSandboxConfig, deny = [], confirm, store = memoryAllowStore(), onTrusted }) {
20
24
  const denySet = new Set(deny);
21
- const allowedSignatures = new Set(); // session 內「允許此命令簽章全部」
22
- const alwaysTools = new Set(); // session 內「允許此工具全部」(使用者選 always)
23
25
 
24
26
  return async function permission(ctx) {
25
27
  const name = ctx.name;
@@ -45,18 +47,19 @@ export function createPermissionStep({ registry, getSandbox, getSandboxConfig, d
45
47
  // 3) 危險命令:即使 always-allow / 無 confirm 也強制把關(headless 直接擋)
46
48
  const danger = isShell ? dangerousReason(cmd) : null;
47
49
 
48
- // 4) 非危險:本工具/命令簽章已 always-allow 直接過;headless(無 confirm)→ 放行
50
+ // 4) 非危險:已信任(本工具 or 命令簽章)→ 直接過;headless(無 confirm)→ 放行
49
51
  if (!danger) {
50
- if (alwaysTools.has(name) || (sig && allowedSignatures.has(sig))) return undefined;
52
+ if (store.hasTool(name)) { onTrusted?.({ name, signature: null, scope: 'tool' }); return undefined; }
53
+ if (sig && store.hasSig(sig)) { onTrusted?.({ name, signature: sig, scope: 'command' }); return undefined; }
51
54
  if (!confirm) return undefined;
52
55
  } else if (!confirm) {
53
56
  return { block: true, reason: `偵測到危險命令(${danger}),headless 模式下拒絕執行。` };
54
57
  }
55
58
 
56
- // 5) 互動確認(危險命令即使選 always 也只放行這次,不永久放行)
57
- const decision = await confirm(name, ctx.args, danger);
58
- if (decision === 'always' && !danger) { alwaysTools.add(name); return undefined; }
59
- if (decision === 'command' && sig && !danger) { allowedSignatures.add(sig); return undefined; }
59
+ // 5) 互動確認(危險命令即使選 always 也只放行這次,不寫入信任)
60
+ const decision = await confirm(name, ctx.args, danger, { signature: sig || null });
61
+ if (decision === 'always' && !danger) { store.addTool(name); return undefined; }
62
+ if (decision === 'command' && sig && !danger) { store.addSig(sig); return undefined; }
60
63
  if (decision === 'yes') return undefined;
61
64
  return { block: true, reason: `使用者拒絕執行 ${name}。` };
62
65
  };
@@ -1,6 +1,8 @@
1
- // Skills(漸進揭露)— kernel 內建。.xitto-kernel/<pack>/skills/*.md 每檔一個技能。
1
+ // Skills(漸進揭露 + 結晶層)— kernel 內建。.xitto-kernel/<pack>/skills/*.md 每檔一個技能。
2
2
  // system prompt 只列「名稱 + 簡述」;agent 用 skill 工具按名載入完整步驟。對標 xitto-code skills。
3
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ // 結晶層:agent 把重複出現的流程用 skill_save 寫成新技能(熱掃描,當下即可載入;下次 session 自動列出)。
4
+ // 技能是 markdown 指令(非可執行碼),自寫安全——名稱 slug 化防路徑穿越,內容只是日後注入的提示文字。
5
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
4
6
  import { join } from 'node:path';
5
7
 
6
8
  const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
@@ -11,27 +13,96 @@ const firstDesc = (body) => {
11
13
  return (body.split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean)) || '';
12
14
  };
13
15
 
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 { /* 略 */ }
16
+ // 技能名 安全檔名 slug(防 ../ 穿越;保留中英數與連字號)
17
+ const slug = (s) => String(s || '').trim().toLowerCase()
18
+ .replace(/[^a-z0-9一-龥_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
19
+
20
+ export function createSkills(dir, { verifyRunner } = {}) {
21
+ const readAll = () => {
22
+ const out = [];
23
+ if (existsSync(dir)) {
24
+ for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
25
+ try { const body = readFileSync(join(dir, f), 'utf8'); out.push({ name: f.replace(/\.md$/, ''), desc: firstDesc(body), body }); } catch { /* 略 */ }
26
+ }
19
27
  }
20
- }
28
+ return out;
29
+ };
30
+
31
+ let skills = readAll(); // 啟動快照(供 system prompt 的 promptSection 列名用)
21
32
 
22
33
  const promptSection = () => (skills.length
23
- ? '\n\n# 可用技能(需要時用 skill 工具按名載入完整步驟)\n' + skills.map((s) => `- ${s.name}:${s.desc}`).join('\n')
24
- : '');
34
+ ? '\n\n# 可用技能(需要時用 skill 工具按名載入完整步驟;摸出可重複流程可用 skill_save 結晶成新技能)\n' + skills.map((s) => `- ${s.name}:${s.desc}`).join('\n')
35
+ : '\n\n# 技能\n尚無已存技能。摸出一套可重複的流程時,用 skill_save 把它結晶成技能,之後(與未來 session)即可按名複用。');
25
36
 
26
- const tool = skills.length ? {
37
+ // 載入技能:每次 rescan 找得到本 session 剛 skill_save 的技能
38
+ const loadTool = {
27
39
  name: 'skill', label: '載入技能', readOnly: true,
28
40
  description: '按名載入一個技能的完整步驟(漸進揭露:prompt 只列名稱+簡述,需要時才載全文)。',
29
41
  parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
30
42
  execute: async (_id, { name }) => {
31
- const s = skills.find((x) => x.name === name);
43
+ skills = readAll();
44
+ const s = skills.find((x) => x.name === name) || skills.find((x) => x.name === slug(name));
32
45
  return txt(s ? s.body : { error: '找不到技能', name, available: skills.map((x) => x.name) });
33
46
  },
34
- } : null;
47
+ };
48
+
49
+ // 結晶層:把可重複流程寫成新技能。政策——每個技能新增時必須有「明確目標」+「通過的驗證」才算數。
50
+ // verify 指令會在沙箱實跑,exit 0 才落地(結晶=已驗證的成功,不是宣稱的成功)。下次 session 自動列入。
51
+ const saveTool = {
52
+ name: 'skill_save', label: '結晶技能', readOnly: true,
53
+ description: '把一套你摸出來、會重複用到的流程「結晶」成可複用技能。政策:每個技能必須附 (1) goal 明確目標 (2) verify 一條可驗證它有效的指令——verify 會被實際執行,通過(exit 0)才會新增,否則拒絕並回傳輸出讓你修正。確保結晶的是「已驗證的成功」。給 name、goal、body(步驟)、verify(驗證指令)。與 playbook 的差別:playbook 是專案事實性 know-how,skill 是可複用且已驗證的操作流程/SOP。',
54
+ parameters: {
55
+ type: 'object',
56
+ properties: {
57
+ name: { type: 'string', description: '技能短名(會 slug 化為檔名)' },
58
+ goal: { type: 'string', description: '這個技能要達成的明確目標(一句話)' },
59
+ body: { type: 'string', description: '完整步驟內容(markdown)' },
60
+ verify: { type: 'string', description: '一條能驗證此技能/成果有效的 shell 指令(須 exit 0;如 `npm test`、`test -f dist/app.js`)。會被實際執行。' },
61
+ description: { type: 'string', description: '可選;省略則用 goal 當簡述' },
62
+ },
63
+ required: ['name', 'goal', 'body', 'verify'],
64
+ },
65
+ execute: async (_id, { name, goal, body, verify, description }) => {
66
+ const nm = slug(name);
67
+ if (!nm) return txt({ error: 'name 不合法(需含中英數)' });
68
+ if (!goal || !String(goal).trim()) return txt({ error: '缺 goal:每個技能必須有明確目標' });
69
+ if (!body || !String(body).trim()) return txt({ error: 'body 不可為空' });
70
+ if (!verify || !String(verify).trim()) return txt({ error: '缺 verify:必須提供可驗證有效的指令(測試完成才能新增)' });
71
+ if (typeof verifyRunner !== 'function') return txt({ error: '此環境不支援技能驗證,無法新增(須在 kernel 內執行)' });
72
+
73
+ // 政策閘門:先實跑驗證,通過才落地
74
+ const vr = await verifyRunner(String(verify).trim());
75
+ if (vr.blocked) return txt({ error: '驗證被安全策略擋下,未新增', reason: vr.reason, verify });
76
+ if (!vr.ok) return txt({ error: '驗證未通過,未新增技能。請修正步驟或指令後重試。', exitCode: vr.code, output: vr.output, verify });
77
+
78
+ const now = new Date().toISOString();
79
+ const desc = (String(description || goal)).replace(/\s+/g, ' ').trim().slice(0, 120);
80
+ const content =
81
+ `---\ndescription: ${desc}\ngoal: ${String(goal).replace(/\s+/g, ' ').trim()}\nverified: true\nverifiedAt: ${now}\n---\n\n` +
82
+ `## 目標\n${String(goal).trim()}\n\n${String(body).trim()}\n\n## 驗證(已通過 exit 0)\n\`\`\`sh\n${String(verify).trim()}\n\`\`\`\n`;
83
+ try {
84
+ mkdirSync(dir, { recursive: true });
85
+ const existed = existsSync(join(dir, `${nm}.md`));
86
+ writeFileSync(join(dir, `${nm}.md`), content);
87
+ skills = readAll();
88
+ return txt({ [existed ? 'updated' : 'saved']: nm, verified: true, verifyOutput: vr.output, hint: '驗證通過,已結晶為技能;本 session 可用 skill 按名載入,下次 session 自動列入。' });
89
+ } catch (e) { return txt({ error: e.message }); }
90
+ },
91
+ };
92
+
93
+ const remove = (name) => {
94
+ const nm = slug(name);
95
+ const file = join(dir, `${nm}.md`);
96
+ if (!existsSync(file)) return { error: '找不到技能', name };
97
+ try { unlinkSync(file); skills = readAll(); return { removed: nm }; } catch (e) { return { error: e.message }; }
98
+ };
35
99
 
36
- return { skills, promptSection, tool };
100
+ return {
101
+ skills, promptSection,
102
+ tool: loadTool, // 向後相容(舊呼叫點)
103
+ tools: [loadTool, saveTool], // kernel 注入用
104
+ list: () => readAll().map(({ name, desc }) => ({ name, desc })),
105
+ remove,
106
+ reload: () => { skills = readAll(); return skills; },
107
+ };
37
108
  }