dsh-engram 0.7.4 → 0.8.6

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 (48) hide show
  1. package/README.md +93 -6
  2. package/README.zh.md +6 -2
  3. package/docs/CONTEXT-GC-GUIDE.zh.md +15 -1
  4. package/docs/G6-quality-ab.zh.md +540 -0
  5. package/docs/GATES.zh.md +41 -0
  6. package/docs/PROPOSAL-evolution.zh.md +12 -0
  7. package/docs/RELEASE-NOTES-v0.7.10.md +49 -0
  8. package/docs/RELEASE-NOTES-v0.7.11.md +31 -0
  9. package/docs/RELEASE-NOTES-v0.7.12.md +22 -0
  10. package/docs/RELEASE-NOTES-v0.7.13.md +41 -0
  11. package/docs/RELEASE-NOTES-v0.7.14.md +37 -0
  12. package/docs/RELEASE-NOTES-v0.7.15.md +36 -0
  13. package/docs/RELEASE-NOTES-v0.7.16.md +42 -0
  14. package/docs/RELEASE-NOTES-v0.7.5.md +134 -0
  15. package/docs/RELEASE-NOTES-v0.7.6.md +74 -0
  16. package/docs/RELEASE-NOTES-v0.7.7.md +60 -0
  17. package/docs/RELEASE-NOTES-v0.7.8.md +56 -0
  18. package/docs/RELEASE-NOTES-v0.7.9.md +46 -0
  19. package/docs/RELEASE-NOTES-v0.8.0.md +59 -0
  20. package/docs/RELEASE-NOTES-v0.8.1.md +70 -0
  21. package/docs/RELEASE-NOTES-v0.8.2.md +62 -0
  22. package/docs/RELEASE-NOTES-v0.8.3.md +65 -0
  23. package/docs/RELEASE-NOTES-v0.8.4.md +56 -0
  24. package/docs/RELEASE-NOTES-v0.8.5.md +60 -0
  25. package/docs/RELEASE-NOTES-v0.8.6.md +71 -0
  26. package/eval/gate-audit.mjs +538 -0
  27. package/lib/api.js +70 -0
  28. package/lib/approvals.js +84 -0
  29. package/lib/boundary-prune.js +71 -5
  30. package/lib/capsule.js +134 -0
  31. package/lib/capture.js +83 -5
  32. package/lib/client.js +66 -0
  33. package/lib/compaction.js +4 -11
  34. package/lib/context-gc.js +63 -2
  35. package/lib/index-block.js +79 -4
  36. package/lib/index.js +91 -18
  37. package/lib/ledger.js +200 -10
  38. package/lib/pack-telemetry.js +10 -3
  39. package/lib/prune.js +89 -0
  40. package/lib/session-events.js +44 -2
  41. package/lib/skill-export.js +289 -0
  42. package/lib/status-insights.js +5 -0
  43. package/lib/status.js +138 -0
  44. package/lib/store.js +34 -0
  45. package/lib/usage.js +32 -3
  46. package/lib/web-provision.js +59 -14
  47. package/package.json +15 -8
  48. package/scripts/dsh-engram.mjs +229 -4
@@ -50,6 +50,9 @@ import { dirname, join } from "node:path";
50
50
  /** The engine package subpath a preset row mounts dsh-engram's engine under. */
51
51
  export const ENGRAM_ROW_NAME = "dsh-engram/compaction";
52
52
 
53
+ /** The subpath the realm-side BoundaryPrune row mounts. */
54
+ export const ENGRAM_PRUNE_ROW_NAME = "dsh-engram/prune";
55
+
53
56
  /** The stock row name the shipped presets mount inside the `compaction` group. */
54
57
  export const STOCK_ROW_NAME = "'@deepseek-ai/dsh-compaction-basic'";
55
58
 
@@ -59,6 +62,9 @@ const STOCK_NAME = "@deepseek-ai/dsh-compaction-basic";
59
62
  /** Row ids the provisioner looks for (list-item `- id:` values). */
60
63
  const STOCK_ROW_ID = "compaction-basic";
61
64
  const ENGRAM_ROW_ID = "engram-compaction";
65
+ /** BoundaryPrune (L1) row: it must live in the SAME realm as the sessions it
66
+ * prunes, otherwise its listener never sees a `todo/write` boundary. */
67
+ const ENGRAM_PRUNE_ROW_ID = "engram-prune";
62
68
  const COMPACTION_GROUP_ID = "compaction";
63
69
 
64
70
  /** Engine config the plugin writes into each wired preset row (settings-driven). */
@@ -102,6 +108,12 @@ export function engramCompactionRowText(config = {}, indent = 4) {
102
108
  ].join("\n");
103
109
  }
104
110
 
111
+ /** The realm-side BoundaryPrune row (no config: it reads the plugin defaults). */
112
+ export function engramPruneRowText(indent = 4) {
113
+ const pad = " ".repeat(indent);
114
+ return [`${pad}- id: ${ENGRAM_PRUNE_ROW_ID}`, `${pad} name: ${ENGRAM_PRUNE_ROW_NAME}`].join("\n");
115
+ }
116
+
105
117
  /** The replacement row at default settings (back-compat export). */
106
118
  export const ENGRAM_COMPACTION_ROW = engramCompactionRowText(DEFAULT_ROW_CONFIG);
107
119
 
@@ -193,7 +205,7 @@ function locateRows(text) {
193
205
  const lines = text.split("\n");
194
206
  const groups = compactionGroupSpans(lines);
195
207
  // Two compaction groups in one composition: refuse to guess which one runs.
196
- if (groups.length > 1) return { lines, stock: [], engram: [], ambiguous: true };
208
+ if (groups.length > 1) return { lines, stock: [], engram: [], prune: [], ambiguous: true };
197
209
  const span = groups[0] ?? { start: 0, end: lines.length, fallback: true };
198
210
  const valid = (rowId, name) => {
199
211
  const hits = [];
@@ -210,6 +222,7 @@ function locateRows(text) {
210
222
  lines,
211
223
  stock: valid(STOCK_ROW_ID, STOCK_NAME),
212
224
  engram: valid(ENGRAM_ROW_ID, ENGRAM_ROW_NAME),
225
+ prune: valid(ENGRAM_PRUNE_ROW_ID, ENGRAM_PRUNE_ROW_NAME),
213
226
  ambiguous: false,
214
227
  };
215
228
  }
@@ -244,6 +257,7 @@ function invariantsOk(text) {
244
257
  !text.includes(STOCK_NAME) &&
245
258
  text.includes("command-compact") &&
246
259
  text.includes("tool-result-pruner") &&
260
+ text.includes(ENGRAM_PRUNE_ROW_NAME) &&
247
261
  text.includes("cordis:group") &&
248
262
  text.includes("isolate:")
249
263
  );
@@ -260,29 +274,50 @@ function invariantsOk(text) {
260
274
  * @returns {string | null}
261
275
  */
262
276
  export function swapCompactionRow(text, rowConfig = {}) {
263
- const { lines, stock, engram, ambiguous } = locateRows(text);
277
+ const { lines, stock, engram, prune, ambiguous } = locateRows(text);
264
278
  if (ambiguous) return null;
265
279
  if (engram.length > 0) {
266
280
  if (engram.length > 1 || stock.length > 0) return null;
267
281
  const index = engram[0];
268
282
  const end = blockEnd(lines, index);
269
- const rowText = engramCompactionRowText(rowConfig, indentOf(lines[index]));
270
- if (lines.slice(index, end).join("\n") === rowText) return text; // already this config
271
- const next = replaceBlock(lines, index, end, rowText);
283
+ const indent = indentOf(lines[index]);
284
+ const rowText = engramCompactionRowText(rowConfig, indent);
285
+ let next = lines.slice(index, end).join("\n") === rowText ? text : replaceBlock(lines, index, end, rowText);
286
+ // An older wiring has the engine row but no prune row: add it in the same
287
+ // realm so L1 can finally see a boundary.
288
+ next = ensurePruneRow(next, indent);
272
289
  return invariantsOk(next) ? next : null;
273
290
  }
274
291
  if (stock.length !== 1) return null;
275
292
  const index = stock[0];
276
293
  const end = blockEnd(lines, index);
277
- const next = replaceBlock(
278
- lines,
279
- index,
280
- end,
281
- engramCompactionRowText(rowConfig, indentOf(lines[index])),
294
+ const indent = indentOf(lines[index]);
295
+ const next = ensurePruneRow(
296
+ replaceBlock(lines, index, end, engramCompactionRowText(rowConfig, indent)),
297
+ indent,
282
298
  );
283
299
  return invariantsOk(next) ? next : null;
284
300
  }
285
301
 
302
+ /**
303
+ * Insert the BoundaryPrune row directly after the engine row when the
304
+ * composition does not already carry one. Idempotent.
305
+ * @param {string} text
306
+ * @param {number} indent - indentation of the row being inserted next to.
307
+ * @returns {string}
308
+ */
309
+ export function ensurePruneRow(text, indent = 4) {
310
+ const { lines, engram, prune } = locateRows(text);
311
+ if (prune !== undefined && prune.length > 0) return text;
312
+ if (engram === undefined || engram.length !== 1) return text;
313
+ const end = blockEnd(lines, engram[0]);
314
+ return [
315
+ ...lines.slice(0, end),
316
+ ...engramPruneRowText(indent).split("\n"),
317
+ ...lines.slice(end),
318
+ ].join("\n");
319
+ }
320
+
286
321
  /**
287
322
  * Reverse {@link swapCompactionRow}: restore the stock `compaction-basic` row
288
323
  * from a wired composition (any engram row block — engine config included).
@@ -291,11 +326,21 @@ export function swapCompactionRow(text, rowConfig = {}) {
291
326
  * @returns {string | null}
292
327
  */
293
328
  export function restoreCompactionRow(text) {
294
- const { lines, engram, ambiguous } = locateRows(text);
329
+ const { lines, engram, prune, ambiguous } = locateRows(text);
295
330
  if (ambiguous || engram.length !== 1) return null;
296
- const index = engram[0];
297
- const end = blockEnd(lines, index);
298
- const next = replaceBlock(lines, index, end, stockCompactionRowText(indentOf(lines[index])));
331
+ // Remove the realm prune row first (it is not stock), then restore the engine row.
332
+ let working = text;
333
+ if (prune !== undefined && prune.length === 1) {
334
+ const pIndex = prune[0];
335
+ // Splice the row out entirely — leaving a blank line behind would break the
336
+ // byte-identical round trip back to the stock text.
337
+ working = [...lines.slice(0, pIndex), ...lines.slice(blockEnd(lines, pIndex))].join("\n");
338
+ }
339
+ const after = locateRows(working);
340
+ if (after.engram.length !== 1) return null;
341
+ const index = after.engram[0];
342
+ const end = blockEnd(after.lines, index);
343
+ const next = replaceBlock(after.lines, index, end, stockCompactionRowText(indentOf(after.lines[index])));
299
344
  return compactionWireStatus(next) === "stock" ? next : null;
300
345
  }
301
346
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-engram",
3
3
  "description": "Minimalist long-term memory for DeepSeek Harness, distilling symbolic-index + pi-esr ideas: zero-LLM auto-capture, a symbolic [ENGRAM] index with progressive disclosure, and an ESR-lite evidence-closure protocol (esr_task / esr_node / esr_close / esr_link). Real agent-behaviour telemetry (usage rollup + /stats) and a deterministic offline recall benchmark (npm run eval). Hot path is model-free; storage on ctx.storageDomain; web memory viewer, observability panel and config card through DSH's native settings slots.",
4
- "version": "0.7.4",
4
+ "version": "0.8.6",
5
5
  "packageManager": "pnpm@11.22.0",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -20,6 +20,7 @@
20
20
  "exports": {
21
21
  ".": "./lib/index.js",
22
22
  "./compaction": "./lib/compaction.js",
23
+ "./prune": "./lib/prune.js",
23
24
  "./client": "./lib/client.js",
24
25
  "./package.json": "./package.json",
25
26
  "./cordis.patch.yml": "./cordis.patch.yml"
@@ -28,6 +29,7 @@
28
29
  "lib",
29
30
  "scripts",
30
31
  "docs",
32
+ "eval/gate-audit.mjs",
31
33
  "cordis.patch.yml",
32
34
  "README.md",
33
35
  "README.zh.md",
@@ -38,19 +40,24 @@
38
40
  "dsh": ">=0.1.2-alpha.2 <0.3.0-0"
39
41
  },
40
42
  "scripts": {
41
- "setup": "node scripts/setup-links.mjs",
42
- "prepublishOnly": "node scripts/prepublish-check.mjs",
43
+ "bench:ledger": "node eval/ledger-bench.mjs",
43
44
  "build:client": "node client/build.mjs && node client/build-model.mjs",
44
- "test": "node --test \"test/**/*.test.mjs\"",
45
+ "dsh-engram": "node scripts/dsh-engram.mjs",
46
+ "dsh-engram:doctor": "node scripts/dsh-engram.mjs doctor",
45
47
  "eval": "node eval/recall-bench.mjs",
48
+ "gate:audit": "node eval/gate-audit.mjs",
49
+ "playbook:audit": "node eval/playbook-audit.mjs",
50
+ "prepublishOnly": "node scripts/prepublish-check.mjs",
51
+ "quality:ab": "node eval/quality-ab.mjs",
52
+ "release-notes": "node scripts/gen-release-notes.mjs",
53
+ "setup": "node scripts/setup-links.mjs",
54
+ "skill:check": "node eval/skill-export-check.mjs",
55
+ "test": "node --test \"test/**/*.test.mjs\"",
46
56
  "typecheck": "node client/typecheck.mjs",
47
57
  "web-compaction": "node scripts/dsh-engram.mjs",
48
- "web-compaction:status": "node scripts/dsh-engram.mjs status",
49
58
  "web-compaction:enable": "node scripts/dsh-engram.mjs enable",
50
59
  "web-compaction:revert": "node scripts/dsh-engram.mjs revert",
51
- "dsh-engram": "node scripts/dsh-engram.mjs",
52
- "dsh-engram:doctor": "node scripts/dsh-engram.mjs doctor",
53
- "release-notes": "node scripts/gen-release-notes.mjs"
60
+ "web-compaction:status": "node scripts/dsh-engram.mjs status"
54
61
  },
55
62
  "publishConfig": {
56
63
  "access": "public"
@@ -7,6 +7,9 @@
7
7
  * npx dsh-engram enable # re-run the boot-time web auto-wiring (usually a no-op)
8
8
  * npx dsh-engram revert # restore the stock compaction rows everywhere —
9
9
  * # run BEFORE uninstalling dsh-engram
10
+ * npx dsh-engram skill export [--workspace <dir>] [--dir <skills>] [--dry-run]
11
+ * # export evidence-backed procedures as DSH Skills
12
+ * # (default: <workspace>/.dsh/skills)
10
13
  *
11
14
  * `status`/`doctor` read the durable snapshot the plugin writes at boot
12
15
  * (`$DSH_HOME/engram/context-gc.status.json`) and cross-check the live preset
@@ -28,7 +31,13 @@ import {
28
31
  provisionWebCompaction,
29
32
  revertWebCompaction,
30
33
  } from "../lib/web-provision.js";
31
- import { contextGcStatusPath, readContextGcStatus } from "../lib/status.js";
34
+ import { contextGcStatusPath, readContextGcStatus, readPruneAttempts } from "../lib/status.js";
35
+ import { readLedger } from "../lib/ledger.js";
36
+ import { defaultSkillDir, exportSkills, skillDescription, skillName, planSkillExport } from "../lib/skill-export.js";
37
+ import { approvedIds, isApproved, loadApprovals, recordApproval, revokeApproval } from "../lib/approvals.js";
38
+ import { isProvenProcedure, invalidatedProcedure } from "../lib/status-insights.js";
39
+ import { workspaceKey } from "../lib/util.js";
40
+ import { ENGRAM_PRUNE_ROW_NAME } from "../lib/web-provision.js";
32
41
 
33
42
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
34
43
 
@@ -193,6 +202,40 @@ async function printStatus(presetsDirFlag) {
193
202
  const presets = await discoverPresets(status, presetsDirFlag);
194
203
 
195
204
  log("── dsh-engram Context GC 状态 ──────────────────────────────");
205
+ // The two ledger legs decide whether L1 is worth turning on at all: if the
206
+ // host prunes far more than engram does, enabling it only repeats that work.
207
+ // Printed regardless of the status snapshot (it is ledger data, not plugin
208
+ // startup state).
209
+ try {
210
+ // Honour DSH_HOME like every other read in this CLI: without it the legs
211
+ // line silently reports the real machine's ledger even under a sandbox.
212
+ const ledger = readLedger({ home: join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "engram") });
213
+ const rows = ledger.sessions ?? [];
214
+ if (rows.length > 0) {
215
+ const engram = rows.reduce((sum, row) => sum + (row.prunedChars ?? 0), 0);
216
+ const host = ledger.hostPrunedChars ?? 0;
217
+ const verdict =
218
+ host > 0 && engram === 0
219
+ ? "(宿主全干了,engram 一条没剪 → L1 开着只是重复劳动)"
220
+ : host > engram * 5
221
+ ? "(宿主腿远大于 engram 腿 → 先确认边界源有没有覆盖到你的用法)"
222
+ : "";
223
+ log(` 账本两条腿:engram 剪枝 ${engram} 字符 · 宿主自剪 ${host} 字符${verdict}`);
224
+ log("");
225
+ const insights = await readPlaybookInsights();
226
+ if (insights !== null) {
227
+ log(` playbook(当前 workspace):${insights.total} 条 procedure · 过门 ${insights.promoted} · 未过门 ${insights.unproven} · 失效 ${insights.invalidated}`);
228
+ if (insights.capsules > 0) {
229
+ log(` subagent 胶囊:${insights.capsules} 条 · ${insights.capsuleSource} → ${insights.capsuleChars} 字符(只做留档,父代理仍收到全文)`);
230
+ }
231
+ if (insights.promoted > 0) {
232
+ log(" 可导出为 DSH Skill:npx dsh-engram skill export(写入 <workspace>/.dsh/skills)");
233
+ }
234
+ log("");
235
+ }
236
+ }
237
+ } catch {}
238
+
196
239
  if (status === undefined) {
197
240
  log(" 尚无状态文件(插件还没写过,或本机没跑过 dsh web)。");
198
241
  } else {
@@ -204,9 +247,28 @@ async function printStatus(presetsDirFlag) {
204
247
  const bp = status.boundaryPrune;
205
248
  log(` L1 边界剪枝:${bp.armed ? "已武装" : "未启用"}${bp.reason ? `(${bp.reason})` : ""}`);
206
249
  if (!bp.armed) {
207
- log(" ⚠ L1 未武装时「节省 · 剪枝/压缩」不会入账(账本净 token 只会是负值);0.2.0 web profile 需要 bundle patch 重新启用 host 平面的 tool-result-pruner 行。");
250
+ const reason = String(bp.reason ?? "");
251
+ if (/boundaryPrune:false|config/i.test(reason)) {
252
+ // Default off since v0.7.11 — not a misconfiguration.
253
+ log(" 说明:L1 自 0.7.11 起默认关闭(宿主会自己剪超阈值结果,边界源只在用 todo/goal 的规划型会话里有收益)。");
254
+ log(" 需要时在设置卡或 profile 里设 boundaryPrune: true;打开前先看上面那行「账本两条腿」。");
255
+ } else {
256
+ log(" ⚠ L1 未武装(服务缺失/装配问题):此时账本只有注入支出,没有剪枝入账。");
257
+ }
208
258
  }
209
259
  }
260
+ const attempts = await readPruneAttempts(20).catch(() => []);
261
+ if (attempts.length === 0) {
262
+ log(" L1 边界尝试:0 次(还没有任何 todo/goal 边界出现过——注意「已武装」不等于「会开火」)");
263
+ } else {
264
+ const byPlane = new Map();
265
+ const fired = attempts.filter((a) => a.kind === "fired" && a.pruned > 0);
266
+ for (const a of attempts) byPlane.set(a.plane ?? "?", (byPlane.get(a.plane ?? "?") ?? 0) + 1);
267
+ const planeLine = [...byPlane].map(([plane, n]) => `${plane} ${n}`).join(" · ");
268
+ const last = attempts[attempts.length - 1];
269
+ log(` L1 边界尝试:${attempts.length} 次见过的边界(${planeLine})· 真剪到东西的 ${fired.length} 次`);
270
+ log(` 最近一次:${last.at ?? "?"} · ${last.kind}${last.boundary?.kind ? ` · ${last.boundary.kind}` : ""}${last.pruned !== undefined ? ` · 剪了 ${last.pruned} 条 / ${last.charsRemoved ?? 0} 字符` : ""}`);
271
+ }
210
272
  if (status.configSource?.autoWebCompaction === false) {
211
273
  log(" ⚠ autoWebCompaction=false:web 平面不会自动接管(设置卡「记忆 GC」里打开后重启 dsh web)");
212
274
  }
@@ -233,8 +295,9 @@ async function printStatus(presetsDirFlag) {
233
295
  continue;
234
296
  }
235
297
  const cs = compactionWireStatus(text);
298
+ const hasPrune = text.includes(ENGRAM_PRUNE_ROW_NAME);
236
299
  const note = cs === "wired"
237
- ? "已接管(dsh-engram/compaction 行)"
300
+ ? `已接管(dsh-engram/compaction 行${hasPrune ? " + dsh-engram/prune 行" : " · 缺 prune 行:L1 在这个预设里看不见边界,重跑 enable 补齐"})`
238
301
  : cs === "stock"
239
302
  ? "出厂布局,重启 dsh web 后自动接管"
240
303
  : "自定义/无 compaction,插件不碰";
@@ -267,6 +330,20 @@ async function printDoctor(presetsDirFlag) {
267
330
  }
268
331
  }
269
332
 
333
+ // v0.8-D: an approval prompt nobody sees is a gate that silently blocks. The
334
+ // doctor line is where a user finds out there is something to approve.
335
+ try {
336
+ const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
337
+ const { key, rows } = await loadExportable(process.cwd(), home);
338
+ const approved = new Set(approvedIds(loadApprovals(home), key));
339
+ const pending = exportSkills(rows, { config: SKILL_GATE, dir: defaultSkillDir(key), dryRun: true, approved, requireApproval: true }).pending;
340
+ if (pending.length > 0) {
341
+ notes.push(`有 ${pending.length} 条过门的 auto procedure 等批准才能导出成 skill:npx dsh-engram skill pending`);
342
+ }
343
+ } catch {
344
+ // no store yet — nothing to approve
345
+ }
346
+
270
347
  const stockLeft = presets.filter((p) => {
271
348
  try {
272
349
  return compactionWireStatus(readFileSyncSafe(p.path)) === "stock";
@@ -387,6 +464,127 @@ async function runEnableRevert(command, fileFlag, presetsDirFlag) {
387
464
  if (command === "revert" && changed > 0) log(" 已全部还原为出厂 compaction 行——可以安全卸载 dsh-engram。");
388
465
  }
389
466
 
467
+ /** Evidence gate defaults; mirrors lib/index.js (kept here so the CLI stays DSH-free). */
468
+ const SKILL_GATE = { procedureMinEvidence: 3, procedureMaxAgeDays: 14, promoteHits: 3 };
469
+
470
+ /** Rows the exporter would consider (proof gate passed, auto-captured). */
471
+ async function loadExportable(workspace, home) {
472
+ const storage = join(home, "storages", "dsh_engram.json");
473
+ if (!(await exists(storage))) throw new Error(`找不到记忆库:${storage}(DSH 还没在这个机器上写过记忆?)`);
474
+ const unit = JSON.parse(await readFile(storage, "utf8"));
475
+ const table = unit?.tables?.memories ?? unit?.memories ?? {};
476
+ const all = Array.isArray(table) ? table : Object.values(table);
477
+ const key = workspaceKey(workspace);
478
+ return { key, rows: all.filter((m) => m?.workspace === key) };
479
+ }
480
+
481
+ async function runSkillApprove({ workspace, home, id, note, revoke }) {
482
+ const { key, rows } = await loadExportable(workspace, home);
483
+ const row = rows.find((m) => m?.id === id || String(m?.id ?? "").startsWith(id));
484
+ if (row === undefined) throw new Error(`workspace ${key} 里没有 id 以 "${id}" 开头的记忆`);
485
+ if (revoke) {
486
+ revokeApproval(home, key, row.id);
487
+ log(`已撤销批准:${row.id}(${skillName(row)})——下次 export 会删掉它的 SKILL.md`);
488
+ return;
489
+ }
490
+ const state = recordApproval(home, key, row.id, { note: note ?? null });
491
+ const plan = planSkillExport(rows, { config: SKILL_GATE, dir: defaultSkillDir(key), approved: new Set(approvedIds(state, key)), requireApproval: true });
492
+ log(`已批准:${row.id} → ${skillName(row)}${note ? `(${note})` : ""}`);
493
+ log(` 证据:${row.evidenceCount ?? 0} 次验证 · 最近 ${row.lastVerifiedAt ? new Date(row.lastVerifiedAt).toISOString().slice(0, 10) : "未知"}`);
494
+ log(plan.length > 0 ? " 现在可以运行:npx dsh-engram skill export" : " 但它还没通过证据门(≥3 次验证 / 14 天内 / 未失效),批准不替代证据。");
495
+ }
496
+
497
+ async function runSkillPending({ workspace, home }) {
498
+ const { key, rows } = await loadExportable(workspace, home);
499
+ const state = loadApprovals(home);
500
+ const approved = new Set(approvedIds(state, key));
501
+ const report = exportSkills(rows, { config: SKILL_GATE, dir: defaultSkillDir(key), dryRun: true, approved, requireApproval: true });
502
+ const ready = planSkillExport(rows, { config: SKILL_GATE, dir: defaultSkillDir(key), approved, requireApproval: true });
503
+ log(`skill pending · workspace ${key}`);
504
+ if (ready.length > 0) {
505
+ log(` 已批准待导出 ${ready.length}:`);
506
+ for (const skill of ready) log(` ${skill.name} (${skill.memoryId})`);
507
+ }
508
+ if (report.pending.length > 0) {
509
+ log(` 等批准 ${report.pending.length}(批准后才写文件):`);
510
+ for (const item of report.pending) log(` ${item.name} ${item.id.slice(0, 8)} ${item.description}`);
511
+ log(" 批准:npx dsh-engram skill approve <id> [--note \"…\"]");
512
+ }
513
+ if (ready.length === 0 && report.pending.length === 0) log(" 没有过门的 auto procedure —— 无事可批。");
514
+ }
515
+
516
+ async function runSkillExport({ workspace, dir, home, dryRun, force }) {
517
+ const storage = join(home, "storages", "dsh_engram.json");
518
+ if (!(await exists(storage))) throw new Error(`找不到记忆库:${storage}(DSH 还没在这个机器上写过记忆?)`);
519
+ const unit = JSON.parse(await readFile(storage, "utf8"));
520
+ const table = unit?.tables?.memories ?? unit?.memories ?? {};
521
+ const all = Array.isArray(table) ? table : Object.values(table);
522
+ const key = workspaceKey(workspace);
523
+ const rows = all.filter((m) => m?.workspace === key);
524
+ const target = dir ?? defaultSkillDir(key);
525
+ // v0.8-D: evidence gets a procedure into the index; a human yes gets it into
526
+ // a file the host advertises. `--force` is the audited escape hatch.
527
+ const approved = new Set(approvedIds(loadApprovals(home), key));
528
+ const requireApproval = !force;
529
+ const report = exportSkills(rows, { config: SKILL_GATE, dir: target, dryRun, approved, requireApproval });
530
+ log(`skill export · workspace ${key} · ${rows.length} 条记忆 · 目标 ${target}${dryRun ? "(dry-run)" : ""}${force ? " · --force(跳过批准门)" : ""}`);
531
+ if (report.written.length > 0) log(` 写入 ${report.written.length}: ${report.written.join(", ")}`);
532
+ if (report.unchanged.length > 0) log(` 未变 ${report.unchanged.length}: ${report.unchanged.join(", ")}`);
533
+ if (report.removed.length > 0) log(` 移除(证据不再成立)${report.removed.length}: ${report.removed.join(", ")}`);
534
+ for (const skip of report.skipped) log(` 跳过 ${skip}`);
535
+ if (report.pending.length > 0) {
536
+ log(` 等批准 ${report.pending.length}(证据已够,但写文件需要你点头):`);
537
+ for (const item of report.pending) log(` ${item.name} ${item.id.slice(0, 8)}`);
538
+ log(" 批准:npx dsh-engram skill approve <id>;或本次强制:--force");
539
+ }
540
+ for (const error of report.errors) log(` 失败 ${error}`);
541
+ if (report.written.length + report.unchanged.length + report.removed.length === 0 && report.skipped.length === 0 && report.pending.length === 0) {
542
+ log(" 没有 procedure 通过证据门(≥3 次验证、14 天内、未失效)——无导出。");
543
+ }
544
+ log(" 下一步:DSH 从项目根的 .dsh/skills 发现技能,catalog 里出现名字即生效(新会话无需重启)。");
545
+ }
546
+
547
+ /** Playbook + capsule counters for `status` (best effort; missing store → null). */
548
+ async function readPlaybookInsights(workspaceArg) {
549
+ try {
550
+ const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
551
+ const storage = join(home, "storages", "dsh_engram.json");
552
+ if (!(await exists(storage))) return null;
553
+ const unit = JSON.parse(await readFile(storage, "utf8"));
554
+ const table = unit?.tables?.memories ?? unit?.memories ?? {};
555
+ const all = Array.isArray(table) ? table : Object.values(table);
556
+ const key = workspaceKey(workspaceArg ?? process.cwd());
557
+ const rows = all.filter((m) => m?.workspace === key);
558
+ const config = { ...SKILL_GATE };
559
+ const procedures = rows.filter((m) => m?.kind === "procedure" && m.status !== "archived");
560
+ const capsules = rows.filter((m) => (m.tags ?? []).includes("subagent-capsule"));
561
+ const size = (m) => {
562
+ const match = /胶囊:.*? (\d+) → (\d+) 字符/.exec(String(m.text ?? ""));
563
+ return match === null ? null : [Number(match[1]), Number(match[2])];
564
+ };
565
+ let capsuleSource = 0;
566
+ let capsuleChars = 0;
567
+ for (const capsule of capsules) {
568
+ const parsed = size(capsule);
569
+ if (parsed !== null) {
570
+ capsuleSource += parsed[0];
571
+ capsuleChars += parsed[1];
572
+ }
573
+ }
574
+ return {
575
+ total: procedures.length,
576
+ promoted: procedures.filter((m) => isProvenProcedure(m, config)).length,
577
+ unproven: procedures.filter((m) => !isProvenProcedure(m, config) && !invalidatedProcedure(m)).length,
578
+ invalidated: procedures.filter((m) => invalidatedProcedure(m)).length,
579
+ capsules: capsules.length,
580
+ capsuleSource,
581
+ capsuleChars,
582
+ };
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+
390
588
  async function main() {
391
589
  const [, , rawCommand, ...rest] = process.argv;
392
590
  const command = rawCommand ?? "status";
@@ -394,7 +592,34 @@ async function main() {
394
592
  const fileFlag = fileFlagIndex >= 0 ? rest[fileFlagIndex + 1] : undefined;
395
593
  const presetsDirIndex = rest.indexOf("--presets-dir");
396
594
  const presetsDirFlag = presetsDirIndex >= 0 ? rest[presetsDirIndex + 1] : undefined;
397
- if (command === "status") {
595
+ if (command === "skill") {
596
+ const sub = rest[0];
597
+ if (!["export", "approve", "revoke", "pending"].includes(sub ?? "")) {
598
+ throw new Error(`未知子命令 "skill ${sub ?? ""}"(用 skill export|pending|approve|revoke)`);
599
+ }
600
+ const flag = (name) => {
601
+ const at = rest.indexOf(name);
602
+ return at >= 0 ? rest[at + 1] : undefined;
603
+ };
604
+ const shared = {
605
+ workspace: flag("--workspace") ?? process.cwd(),
606
+ home: flag("--home") ?? process.env.DSH_HOME ?? join(homedir(), ".dsh"),
607
+ };
608
+ if (sub === "export") {
609
+ await runSkillExport({
610
+ ...shared,
611
+ dir: flag("--dir"),
612
+ dryRun: rest.includes("--dry-run"),
613
+ force: rest.includes("--force"),
614
+ });
615
+ } else if (sub === "pending") {
616
+ await runSkillPending(shared);
617
+ } else {
618
+ const id = rest[1];
619
+ if (id === undefined || id.startsWith("--")) throw new Error(`skill ${sub} 需要一个记忆 id`);
620
+ await runSkillApprove({ ...shared, id, note: flag("--note"), revoke: sub === "revoke" });
621
+ }
622
+ } else if (command === "status") {
398
623
  await printStatus(presetsDirFlag);
399
624
  } else if (command === "doctor") {
400
625
  await printDoctor(presetsDirFlag);