dsh-ledger-memory 0.2.0 → 0.3.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/README.md CHANGED
@@ -70,8 +70,27 @@ dsh plugin add dsh-ledger-memory
70
70
  /ledger log 查活动日志
71
71
  /ledger handoff 写一份交接单
72
72
  /ledger mode 看/切换压缩模式(steady / surgical)
73
+ /ledger lock 把本场对话锁到某个项目目录(总工作区里用)
73
74
  ```
74
75
 
76
+ ### 对话建在总工作区时:锁定到子项目
77
+
78
+ 有时对话是建在**装着多个项目的总工作区**上的。那时插件无从判断"这次说的是哪个项目",
79
+ 默认就都落在当前目录。解决办法是**锁**:
80
+
81
+ ```
82
+ /ledger lock 20-DSH插件/04-其余插件/10.项目台账
83
+ ```
84
+
85
+ 锁定后,本场对话的**所有**工具调用都落在那个目录上,不必每次再指定。
86
+ `/ledger lock` 不带参数看当前状态,`/ledger lock off` 解锁。
87
+ 会话第一轮弹的那张卡片里也有这个选项。
88
+
89
+ > ★ 这个锁**只存在内存里**:不建注册表、不在你的目录里留任何标记、**一个文件都不写**,
90
+ > 换一场对话就回到默认。它只影响"这次往哪儿记",不改你磁盘上的任何东西。
91
+ >
92
+ > ★ 若当前目录**自己就有台账**,仍以当前目录为准 —— 锁只接管"没有台账"的目录。
93
+
75
94
  ### 设置面板
76
95
 
77
96
  **设置 → 项目台账(台账 / 压缩 / 日志)**,可调十三项:**压缩模式**、回合末卡片开关、
@@ -102,7 +121,7 @@ dsh plugin add dsh-ledger-memory
102
121
 
103
122
  切换:设置面板里选,或 `/ledger mode steady` / `/ledger mode surgical`(只影响当前项目)。
104
123
 
105
- ## 十三个工具
124
+ ## 十四个工具
106
125
 
107
126
  AI 在对话里调用,你也可以让它调用:
108
127
 
@@ -119,6 +138,7 @@ AI 在对话里调用,你也可以让它调用:
119
138
  | `ledger_log_write` | 追加一条回合小结 |
120
139
  | `ledger_handoff` | 写交接单,供下一个会话接手 |
121
140
  | `ledger_triage` | 把阶段性产出分成「留 / 落 / 丢」三个桶(「反复追究」模式用) |
141
+ | `ledger_lock` | 把本场对话锁到某个项目目录(只存内存,不写任何文件) |
122
142
  | `ledger_compact` | 请求一次高保真压缩 |
123
143
  | `ledger_checkpoint` | 把这一刻的选择交给用户(记台账 / 记+压缩 / 记+交接 / 跳过) |
124
144
 
package/lib/client.js CHANGED
@@ -149,15 +149,40 @@ window.__ModuleLoader__.load({
149
149
  const n = Number(uiValue);
150
150
  return { [spec.key]: Number.isFinite(n) ? n : undefined };
151
151
  }
152
+ // ★ `choice` 就是**字符串**。走下面那条兜底本来也对 —— 但显式写出来,
153
+ // 是为了让"这个类型的值是什么形状"一眼可见(免得下一个人又按 boolean 猜)。
154
+ if (spec.kind === "choice") {
155
+ return { [spec.key]: typeof uiValue === "string" && uiValue !== "" ? uiValue : undefined };
156
+ }
152
157
  return { [spec.key]: uiValue };
153
158
  }
154
159
 
155
- /** 从当前设置里取出某个控件要显示的初值。 */
160
+ /**
161
+ * 从当前设置里取出某个控件要显示的初值。
162
+ *
163
+ * ★★★ 2026-10-10 修(用户报的真 bug):
164
+ * *"那个压缩模式为什么我选择之后不能保存,然后关掉再打开之后即使已经选择了,
165
+ * 它依旧是空白的。两个模式都是未选择状态。"*
166
+ *
167
+ * 根因就是这里的**兜底**:原来最后一行是 `return v === true;` —— 那是给
168
+ * **boolean** 写的兜底,而 `choice` 没有自己的分支 ⇒ 也掉进这一行。
169
+ * 于是已存 `"surgical"` 被读成 `false`,两个单选按钮**都不选中**。
170
+ * ★ 而且坏的不只是显示:`buildDraft` 用它造草稿 ⇒ 草稿初值是 `false` ⇒
171
+ * 用户点完选项、`diffPatch` 一比对"没变化" ⇒ **patch 是空的、什么都没提交**。
172
+ * ⇒ 一个兜底同时造成"显示不出来"与"保存不上",两半是同一个根因。
173
+ *
174
+ * 教训(比这个 bug 值钱):断言只验了"选项渲染出来了",**没验"选中值能不能往返"**
175
+ * —— 渲染断言对"选不中"完全无感。**一个新控件类型必须测它的值往返**。
176
+ */
156
177
  function settingsToUi(spec, settings) {
157
178
  const v = settings === undefined ? undefined : settings[spec.key];
158
179
  if (spec.kind === "tiers") return formatTiers(v);
159
180
  if (spec.kind === "levels") return v === undefined || v === null ? {} : v;
160
181
  if (spec.kind === "number") return v === undefined || v === null ? "" : String(v);
182
+ // ★ `choice`:原样把字符串交给控件(它自己按 options 比对谁被选中)。
183
+ // 没设过就返回 `""`(而不是 undefined)—— 与 number 的 `""` 同一条口径:
184
+ // 控件拿到的一定是**同一种类型**,不必每处再判 undefined。
185
+ if (spec.kind === "choice") return typeof v === "string" ? v : "";
161
186
  return v === true;
162
187
  }
163
188
 
package/lib/index.js CHANGED
@@ -504,6 +504,70 @@ function currentSettings() {
504
504
  return activeSettings ?? settingDefaults();
505
505
  }
506
506
 
507
+ /**
508
+ * ★★★ 2026-10-10(用户拍板):sid → **本会话锁定的项目根**。
509
+ *
510
+ * **用户原话**:*"因为总的工作区是有需要读的东西的,所以有时候会把对话建在总的
511
+ * 工作区下面,这种时候的话,这个项目的锁定就应该考虑到可以让用户和AI说之后AI调用,
512
+ * 或者说把项目注册到对应的文件夹上,以后各种工具的使用都在那个文件夹上。"*
513
+ *
514
+ * 以及他对我提出的"要不要落个注册表文件"的**否决**:
515
+ * *"让模型拥有把插件锁定在对应或者说本次项目新建的文件夹中。而不是去动工作区的东西。
516
+ * 不应该有这个权利。最开始的提示卡片就可以有这样的选择。"*
517
+ *
518
+ * ⇒ 三条硬约束(都是他给的):
519
+ * ① **只在内存里** —— 绝不往工作区(或任何地方)写"注册表"。
520
+ * 模型没有"往用户工作区里登记东西"这个权利。这与他早先那条
521
+ * 「没登记就不往项目里写任何东西」是同一条纪律的延伸。
522
+ * ② **只在这场对话内** —— 用 sid 做键,会话一换就没了。这是有意的:
523
+ * 对话建在总工作区是**这一次**的事,不该变成磁盘上的持久事实。
524
+ * ③ **首轮卡片就给这个选择** —— 见 `FIRST_TURN_OPTIONS`。
525
+ *
526
+ * ★ 为什么用**模块级 Map** 而不是放进 `state`:
527
+ * `projectRootOf(agent)` 只有 `agent` 一个入参,而它被 **9 处**调用,
528
+ * 其中 `rootFromExec` 那条链**根本拿不到 state**。把锁放进 state 就得改 9 个签名。
529
+ * 而"一个 profile 只装一份本插件"⇒ 模块级在这里是**真话**(与 `activeSettings`
530
+ * 同样的理由,见它上面的注释)。
531
+ * ★ 键是 sid ⇒ 天然满足约束②;`Map` 只在内存 ⇒ 天然满足约束①。
532
+ */
533
+ const sessionProjectLock = new Map();
534
+
535
+ /** 取某场会话锁定的项目根(没锁 ⇒ undefined)。 */
536
+ function lockedRootOf(agent) {
537
+ const sid = agent?.session?.header?.id;
538
+ if (typeof sid !== "string" || sid === "") return undefined;
539
+ return sessionProjectLock.get(sid);
540
+ }
541
+
542
+ /** 锁定 / 解锁 / 查询(`ledger_lock` 与首轮卡片共用这一处,避免两处口径分叉)。 */
543
+ function setProjectLock(agent, root) {
544
+ const sid = agent?.session?.header?.id;
545
+ if (typeof sid !== "string" || sid === "") {
546
+ return { ok: false, reason: "拿不到会话编号,锁不了(锁是按会话记的)。" };
547
+ }
548
+ if (root === undefined || root === null) {
549
+ const had = sessionProjectLock.delete(sid);
550
+ return { ok: true, locked: false, wasLocked: had };
551
+ }
552
+ const abs = path.resolve(String(root));
553
+ let isDir = false;
554
+ try {
555
+ isDir = fs.statSync(abs).isDirectory();
556
+ } catch {
557
+ isDir = false;
558
+ }
559
+ if (!isDir) {
560
+ return {
561
+ ok: false,
562
+ reason:
563
+ `\`${abs}\` 不是一个存在的目录,锁不了。` +
564
+ "★ 锁的目标**必须已经存在** —— 本插件不替你创建它(也不该由它来决定项目在哪)。",
565
+ };
566
+ }
567
+ sessionProjectLock.set(sid, abs);
568
+ return { ok: true, locked: true, root: abs };
569
+ }
570
+
507
571
 
508
572
  /** 指针清单文件名(项目根,相对路径)。 */
509
573
  const MANIFEST_REL = ".dsh-ledger.json";
@@ -2986,9 +3050,36 @@ function renderAfterCompaction(info, capsule, meta) {
2986
3050
  * (`6.会话工作区用途\lib\index.js:454-460` 用的同一条;
2987
3051
  * 上下文查看器 `7.上下文查看器\lib\index.js:998` 也是 `s.header?.cwd`)。
2988
3052
  */
3053
+ /**
3054
+ * 本会话的**项目根**。
3055
+ *
3056
+ * ★★★ 2026-10-10(用户拍板)加了「项目锁」:用户说的
3057
+ * *"把对话建在总的工作区下面…或者说把项目注册到对应的文件夹上,
3058
+ * 以后各种工具的使用都在那个文件夹上"*。
3059
+ * ⇒ 锁生效时,**所有**工具都落在那个文件夹上(这条函数是 9 处调用的唯一出口,
3060
+ * 所以在这里改一处就够 —— 这也是它值得保持"唯一出口"的理由)。
3061
+ *
3062
+ * ★★ 优先级:**cwd 自己有台账 > 锁 > cwd**(用户选的"只在这场对话内接管")。
3063
+ * 为什么不无条件以锁为准:用户说得很明确 ——
3064
+ * 锁是给"cwd 没有台账(典型:建在总工作区)"这种情况用的**接管**手段;
3065
+ * 若当前目录本来就是一个有台账的项目,那它自己就是答案,锁不该压过它。
3066
+ * ⇒ 这条判据让"锁"**只会扩大能用的情况,不会改变已有情况**(与 D49 默认 steady
3067
+ * 同一条思路:新能力不许悄悄改掉旧行为)。
3068
+ *
3069
+ * ★ 代价(如实记下):这里多了一次 `readManifest`(只读一个小 JSON)。
3070
+ * 它只在**真的锁了**的时候才走,没锁的会话行为与以前**逐字节相同**。
3071
+ *
3072
+ * @param {object} agent
3073
+ * @returns {string|undefined} 绝对路径;拿不到 cwd 且没锁 ⇒ undefined
3074
+ */
2989
3075
  function projectRootOf(agent) {
2990
3076
  const cwd = agent?.session?.header?.cwd;
2991
- return typeof cwd === "string" && cwd !== "" ? path.resolve(cwd) : undefined;
3077
+ const here = typeof cwd === "string" && cwd !== "" ? path.resolve(cwd) : undefined;
3078
+ const locked = lockedRootOf(agent);
3079
+ if (locked === undefined) return here; // 没锁 ⇒ 与以前完全一致
3080
+ // 锁了:当前目录**自己有台账**就以它为准(锁只接管"没有台账"的情况)。
3081
+ if (here !== undefined && readManifest(here) !== undefined) return here;
3082
+ return locked;
2992
3083
  }
2993
3084
 
2994
3085
  /** 读指针清单(不存在 ⇒ undefined)。 */
@@ -4719,6 +4810,19 @@ const FIRST_TURN_OPTIONS = [
4719
4810
  description: "一次性的事(排查/调研)。用 ledger_note 记一份有终点的记录。",
4720
4811
  },
4721
4812
  { label: "暂时不用记", description: "这次什么都不留,直接继续。" },
4813
+ {
4814
+ // ★★★ 2026-10-10 用户拍板加的这一项。他的原话:
4815
+ // *"因为总的工作区是有需要读的东西的,所以有时候会把对话建在总的工作区下面…
4816
+ // 把项目注册到对应的文件夹上,以后各种工具的使用都在那个文件夹上。"*
4817
+ // 以及 *"最开始的提示卡片就可以有这样的选择。"*
4818
+ // ★ 为什么这一项必须出现在**第一张**卡片上,而不是等 AI 想起来问:
4819
+ // 有 104/112 场会话的 cwd 就是容器根(实测)。也就是说"对话建在总工作区"
4820
+ // 是**常态而非边角**,而那种情况下每一条工具调用都需要知道"到底在说哪个项目"。
4821
+ label: "锁定到某个子项目",
4822
+ description:
4823
+ "这个目录是总工作区(装着多个项目)时用:指定一个子文件夹,本场对话的工具都落在那里。" +
4824
+ "只在本次对话内有效,不写任何文件。",
4825
+ },
4722
4826
  ];
4723
4827
 
4724
4828
  /**
@@ -4751,6 +4855,11 @@ function conformChoiceOf(label) {
4751
4855
  /** 「建项目 or 不建」的选项标签 → 内部动作。 */
4752
4856
  function firstTurnChoiceOf(label) {
4753
4857
  const s = typeof label === "string" ? label : "";
4858
+ // ★ 顺序要紧:`锁定` 那条的说明里**含**"建"字吗?不含 —— 但"建项目"那条含。
4859
+ // 所以先判"锁定"(更具体),再判"建项目"。反过来的话"锁定到某个子项目"
4860
+ // 不会误中,但将来若有人把标签写成"建/锁到子项目"就会串。
4861
+ // ⇒ 判据一律**先具体后笼统**(与 `conformChoiceOf` 同一条纪律)。
4862
+ if (s.includes("锁定")) return "lock";
4754
4863
  if (s.includes("建项目")) return "project";
4755
4864
  if (s.includes("问题记录")) return "note";
4756
4865
  if (s.includes("不用记")) return "none";
@@ -4784,7 +4893,9 @@ function firstTurnNext(choice, info, received) {
4784
4893
  `用户选了「建项目」,但本会话工作目录是**容器工作区**(\`${info.projectRoot}\`),` +
4785
4894
  "这里**不能**建台账。请**先问用户是哪个子项目**,然后在那个子目录里初始化:" +
4786
4895
  "要么让用户把会话工作目录切过去,要么给 `ledger_init` 传 `root` 点名它。" +
4787
- "**不要**在当前目录 `git init`。"
4896
+ "**不要**在当前目录 `git init`。" +
4897
+ "\n★ 也可以顺手用 `ledger_lock` 把本场对话锁到那个子项目上 —— 之后所有工具都落在那里," +
4898
+ "不必每次再传 `root`。"
4788
4899
  );
4789
4900
  }
4790
4901
  return (
@@ -4794,6 +4905,18 @@ function firstTurnNext(choice, info, received) {
4794
4905
  "并建议一并传 `logDir` 启用活动日志。做完向用户简报一句文件名与结构。"
4795
4906
  );
4796
4907
  }
4908
+ // ★★★ 2026-10-10 用户拍板加的这一条(首轮卡片第四项)。
4909
+ if (choice === "lock") {
4910
+ return (
4911
+ "用户选了「锁定到某个子项目」。**先问清是哪个子文件夹**(不要自己猜 —— " +
4912
+ `当前目录 \`${info.projectRoot}\` 是容器工作区,底下可能有几十个项目,猜错就是把台账写进别人的项目)。` +
4913
+ "拿到目录后调 `ledger_lock`(传 `root`)把本场对话锁过去。\n" +
4914
+ "★ 锁**只存内存、只在本场对话内有效、不写任何文件** —— 可以放心告诉用户这一点," +
4915
+ "他不会因此在自己工作区里多出东西。\n" +
4916
+ "★ 锁好之后:那个目录若还没有台账,问用户要不要 `ledger_init` 建一套;" +
4917
+ "此后本会话的所有工具调用都会落在那个目录上,**不需要再传 `root`**。"
4918
+ );
4919
+ }
4797
4920
  return `用户的选择没有对上三个选项(收到:${received ?? ""})。请按最稳妥的方式继续:**先问用户**这个目录要不要建项目台账。`;
4798
4921
  }
4799
4922
 
@@ -7078,6 +7201,112 @@ function defineHandoffTool() {
7078
7201
  };
7079
7202
  }
7080
7203
 
7204
+ /**
7205
+ * ⑦之三 `ledger_lock` —— 把**本场会话**锁到某个项目目录上。
7206
+ *
7207
+ * **用户原话(2026-10-10)**:
7208
+ * *"因为总的工作区是有需要读的东西的,所以有时候会把对话建在总的工作区下面,
7209
+ * 这种时候的话,这个项目的锁定就应该考虑到可以让用户和AI说之后AI调用,
7210
+ * 或者说把项目注册到对应的文件夹上,以后各种工具的使用都在那个文件夹上。"*
7211
+ *
7212
+ * 以及他**否决**"落一份注册表文件"时的原话:
7213
+ * *"让模型拥有把插件锁定在对应或者说本次项目新建的文件夹中。而不是去动工作区的东西。
7214
+ * 不应该有这个权利。最开始的提示卡片就可以有这样的选择。"*
7215
+ *
7216
+ * ⇒ 三条硬约束(他给的,逐条对应到实现):
7217
+ * ① **不碰工作区**:锁只存在内存里(`sessionProjectLock`),**一个字节都不落盘**。
7218
+ * 工具描述里也**明说**这一点 —— 免得 AI 以为"锁了会留痕"而不敢用。
7219
+ * ② **只在这场对话内**:键是 sid,会话一换即失效(有意的)。
7220
+ * ③ **首轮卡片也要给**:见 `FIRST_TURN_OPTIONS` 的第四项。
7221
+ *
7222
+ * ★ 为什么做成**工具**而不是让插件自己猜项目:
7223
+ * "哪个子目录是这个会话要干的活"是**人的意图**,插件无从推断(总工作区里有几十个
7224
+ * 子项目,猜错就是把台账写到别人项目里)。让 AI 明确调用、并在调用前问用户,
7225
+ * 与 `ledger_note` / `ledger_handoff` 是同一套分工:**判断归 AI,流程归插件**。
7226
+ */
7227
+ function defineLockTool() {
7228
+ return {
7229
+ name: "ledger_lock",
7230
+ description:
7231
+ "Pin THIS conversation to one project directory, so every later tool call resolves against " +
7232
+ "it instead of the session's cwd. Use it when the conversation was started in a container " +
7233
+ "workspace (a folder holding several sub-projects) and the user wants the ledger work to land " +
7234
+ "on one specific sub-project. In memory ONLY for the current conversation: it writes nothing " +
7235
+ "to disk (no registry file, no marker, nothing inside the target), and it is gone when the " +
7236
+ "conversation ends. Pass no `root` to release the lock. Precedence: if the cwd itself already " +
7237
+ "has a ledger, that wins and the lock only takes over directories that have none.",
7238
+ parameters: {
7239
+ type: "object",
7240
+ properties: {
7241
+ root: ROOT_PARAM,
7242
+ release: {
7243
+ type: "boolean",
7244
+ description: "Set true to release the lock instead of setting one. Same as omitting `root`.",
7245
+ },
7246
+ },
7247
+ additionalProperties: false,
7248
+ },
7249
+ output: { schema: OK_SCHEMA, render: renderResult },
7250
+ async execute(args, exec) {
7251
+ const agent = exec?.agent;
7252
+ const release = args?.release === true || (typeof args?.root !== "string" || args.root.trim() === "");
7253
+ const cur = lockedRootOf(agent);
7254
+
7255
+ if (release) {
7256
+ const r = setProjectLock(agent, undefined);
7257
+ return {
7258
+ ok: true,
7259
+ locked: false,
7260
+ wasLocked: cur ?? null,
7261
+ note:
7262
+ cur === undefined
7263
+ ? "本来就没有锁 —— 什么都没变(工具调用一律按会话工作目录解析)。"
7264
+ : `已解锁(原锁定:\`${cur}\`)。此后按会话工作目录解析。`,
7265
+ // ★ 明说"没有落盘",免得 AI/用户以为要清理什么残留。
7266
+ written: null,
7267
+ persists: false,
7268
+ };
7269
+ }
7270
+
7271
+ const raw = String(args.root).trim();
7272
+ const base = projectRootOf(agent);
7273
+ const abs = path.resolve(base ?? process.cwd(), raw);
7274
+ const r = setProjectLock(agent, abs);
7275
+ if (r.ok !== true) {
7276
+ return { ok: false, reason: r.reason, written: null, persists: false };
7277
+ }
7278
+ const info = inspect(abs);
7279
+ return {
7280
+ ok: true,
7281
+ locked: true,
7282
+ root: abs,
7283
+ // ★ 如实告诉它这个目录**是什么**:有台账 / 是容器 / 是空目录。
7284
+ // 锁到一个没有台账的目录**不是错误**(下一步就 `ledger_init` 建),
7285
+ // 但锁到一个**容器工作区**通常是搞错了(那是"装着项目的地方",不是项目)。
7286
+ hasLedger: info.activeRel !== undefined,
7287
+ active: info.activeRel ?? null,
7288
+ isGitRepo: info.isGitRepo,
7289
+ containerWorkspace:
7290
+ info.isGitRepo !== true && (info.containerMarker !== undefined || info.nestedRepo !== undefined)
7291
+ ? info.containerMarker ?? info.nestedRepo ?? "?"
7292
+ : null,
7293
+ note:
7294
+ info.activeRel !== undefined
7295
+ ? `已锁定到 \`${abs}\`(这个目录有台账:\`${info.activeRel}\`)。此后本会话的工具都落在这里。`
7296
+ : `已锁定到 \`${abs}\`(它还没有台账 —— 要让这个项目有台账,接着调 \`ledger_init\`)。` +
7297
+ "此后本会话的工具都落在这里。",
7298
+ // ★★★ 三句都必须说,否则用户会担心"是不是又往我工作区写东西了"。
7299
+ written: null,
7300
+ persists: false,
7301
+ scope: "仅本场对话(存内存,会话结束即失效)",
7302
+ hint:
7303
+ "★ 本次锁定**没有写任何文件**:不建注册表、不在目标目录留标记。" +
7304
+ "换一场对话就回到默认(按会话工作目录解析)。",
7305
+ };
7306
+ },
7307
+ };
7308
+ }
7309
+
7081
7310
  /**
7082
7311
  * ⑦之二 `ledger_triage` —— 「反复追究」模式下的**分流单**(用户说的"划分")。
7083
7312
  *
@@ -7466,6 +7695,14 @@ const LEDGER_SUBCOMMANDS = [
7466
7695
  "(压缩前由 AI 分流:留上下文 / 落进项目 `_stash/` / 丢掉)之间切换。" +
7467
7696
  "★ 写进**这个项目**的 `.dsh-ledger.json`,只影响本项目;不带参数则只报当前模式。",
7468
7697
  },
7698
+ {
7699
+ sub: "lock",
7700
+ label: "把本场对话锁到某个项目目录",
7701
+ description:
7702
+ "对话建在**总工作区**(装着多个项目)时用:`/ledger lock 子项目路径` ⇒ " +
7703
+ "此后本会话所有工具都落在那个目录上,不必每次传 `root`。" +
7704
+ "★ 只存**内存**、只在**本场对话**内有效、**不写任何文件**;`/ledger lock` 不带参数 = 解锁。",
7705
+ },
7469
7706
  {
7470
7707
  sub: "handoff",
7471
7708
  label: "写交接单(给下一场会话)",
@@ -7833,6 +8070,50 @@ async function runLedgerSub(ctx, invocation, state, sub, capsule, argument) {
7833
8070
  };
7834
8071
  }
7835
8072
 
8073
+ // ── `/ledger lock [子项目路径]` —— 把本场对话锁到一个项目目录(2026-10-10)──
8074
+ //
8075
+ // ★ 用户原话:*"可以让用户和AI说之后AI调用,或者说把项目注册到对应的文件夹上,
8076
+ // 以后各种工具的使用都在那个文件夹上。"* + *"最开始的提示卡片就可以有这样的选择。"*
8077
+ // ★ 与 `ledger_lock` 工具**共用** `setProjectLock` / `lockedRootOf` ——
8078
+ // 命令与工具走同一条路(本文件反复强调:两个入口必须后果一模一样)。
8079
+ // ★ 不带参数 = 报当前锁定状态(若已锁则解锁?不 —— 那太危险)。
8080
+ // 这里是**只读报告**;要解锁得明确说 `/ledger lock off` 或 `none`。
8081
+ if (sub === "lock") {
8082
+ const want = typeof argument === "string" ? argument.trim() : "";
8083
+ const cur = lockedRootOf(invocation?.agent);
8084
+ const show = (extra) =>
8085
+ ({
8086
+ kind: "success",
8087
+ text:
8088
+ extra +
8089
+ `\n**当前锁定**:${cur === undefined ? "(无 —— 按会话工作目录解析)" : `\`${cur}\``}\n\n` +
8090
+ "用法:\n" +
8091
+ "· `/ledger lock <路径>` —— 锁到那个目录(相对路径按当前工作目录解析)\n" +
8092
+ "· `/ledger lock off` —— 解锁,回到按会话工作目录解析\n\n" +
8093
+ "★ 这个锁**只存内存、只在本次对话内有效、不写任何文件** —— " +
8094
+ "不会在你的工作区里多出任何东西。",
8095
+ });
8096
+
8097
+ if (want === "") return show("**锁定状态(只读)**:这条命令不带参数时什么都不改。\n");
8098
+ if (["off", "none", "clear", "unlock", "-"].includes(want.toLowerCase())) {
8099
+ const r = setProjectLock(invocation?.agent, undefined);
8100
+ return show(r.wasLocked === true ? "已**解锁**。" : "本来就**没有锁** —— 什么都没变。");
8101
+ }
8102
+
8103
+ const base = projectRootOf(invocation?.agent);
8104
+ const abs = path.resolve(base ?? process.cwd(), want);
8105
+ const r = setProjectLock(invocation?.agent, abs);
8106
+ if (r.ok !== true) return { kind: "error", text: r.reason };
8107
+ const info = inspect(abs);
8108
+ return show(
8109
+ `已锁定到 \`${abs}\`。\n\n` +
8110
+ (info.activeRel !== undefined
8111
+ ? `这个目录有台账:\`${info.activeRel}\`。`
8112
+ : "⚠️ 这个目录**还没有台账** —— 要让它有,接着让 AI 调 `ledger_init`。") +
8113
+ "\n"
8114
+ );
8115
+ }
8116
+
7836
8117
  if (sub === "log") {
7837
8118
  const info = inspect(root);
7838
8119
  if (info.logRel === undefined) {
@@ -8654,6 +8935,7 @@ export function apply(ctx, config) {
8654
8935
  ["ledger_note", defineNoteTool],
8655
8936
  ["ledger_handoff", defineHandoffTool],
8656
8937
  ["ledger_triage", () => defineTriageTool(state)],
8938
+ ["ledger_lock", defineLockTool],
8657
8939
  ["ledger_conform", defineConformTool],
8658
8940
  ["ledger_compact", () => defineCompactTool(ctx, state, capsule)],
8659
8941
  [CHECKPOINT_TOOL, () => defineCheckpointTool(ctx, state)],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ledger-memory",
3
- "version": "0.2.0",
4
- "description": "台账记忆(Ledger Memory):为 DSH 提供工程化的项目记忆 —— 台账的检测/注入/归档/Git 配合,活动日志(L0–L4),三层上下文压缩与逐字胶囊落盘,两种压缩模式(正常推进 / 反复追究),以及跨会话的交接单闭环。可回溯、可追查。",
3
+ "version": "0.3.0",
4
+ "description": "台账记忆(Ledger Memory):为 DSH 提供工程化的项目记忆 —— 台账的检测/注入/归档/Git 配合,活动日志(L0–L4),三层上下文压缩与逐字胶囊落盘,两种压缩模式(正常推进 / 反复追究),把会话锁定到某个项目目录,以及跨会话的交接单闭环。可回溯、可追查。",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",