# dsh-mneme 工作流（M.O.）——本体住项目，证据住记忆库

- **日期**：2026-10-15
- **状态**：🚧 第一刀（项目级读侧 + 条目清单；执行侧未做）
- **范围**：把「做法」从记忆库里的一行文字，变成项目里可提交、可 review、可被 CI 读的一份文件
- **相关文档**：[配置参考](CONFIGURATION.md) · [蒸馏与溯源](DISTILL.md) · [存储与回收](STORAGE.md)

## 1. 问题

记忆库解决的是「这件事**为什么**这么办」——一条教训、一个决定、一次否决，都带着出处。
但它答不了另一个问题：**这件事到底怎么跑。**

「怎么跑」现在活在三个地方，哪一个都不是它该在的地方：宿主会话里的临时提示、某个 agent
的系统提示词、以及人的记忆里。三者有同一个毛病——**改不动，也不留痕**：改了没人知道你改了什么，
没改也没人知道它已经过时了。

## 2. 住址：为什么是**项目**的仓库，不是插件包

工作流是要**跑**的东西，所以它得住在一个能跑它的地方：

| 要求 | 记忆库 | 项目仓库 |
|---|---|---|
| 能提交、能 diff、能整版回退 | ✗（表里改一行，没有 diff） | ✓ |
| 能 PR、能 review、能拦在合并前 | ✗ | ✓ |
| 能被 CI 读 | ✗ | ✓ |
| 记「为什么这么定」 | ✓ | ✗（注释装不下证据） |
| 记「跑得怎么样」 | ✓ | ✗ |

结论是一句话：**记忆是「为什么」，仓库是「怎么跑」，图是「看起来怎么样」。**
三者不下同一张桌子——把工作流塞进记忆库，它会长成一份死文档；把证据塞进仓库，
它会变成一摊没人维护的注释。

**第二句话是「哪个仓库」。** 第一版把住址写成了 `dsh-mneme/pipelines/`，也就是**插件包自己**
的目录，于是不管在哪个项目、哪个会话打开，读到的都是同一套——「项目级」这个词当时根本没落地。
插件是**工具**，它的目录跟着人走遍所有项目；工作流是**这个项目的做法**，不该跟着人走。
所以住址是项目，插件包那份降级为兜底的「全局层」。

这条不是口味问题。本机 7,647 条记忆里带 `workspace_scope` 的只有 227 条（≈3%），活跃的
2,629 条里 2,517 条（95.7%）为空——「全局」实际上变成了谁都不看的杂物堆。默认全局等于
把一条本地规约推给所有项目，会原样重演这一幕。

## 3. 形态：一条工作流一个 JSON，两层来源

```
<项目>/.mneme/pipelines/<id>.json     ← 项目层（默认；跟着项目提交、能 review）
<插件包>/pipelines/<id>.json          ← 全局层（兜底；今天包里没有这个目录，这是有意的）
```

读的时候**项目层在前、全局层在后，顺序即优先级**。同一条 `id` 两处都有，项目层赢，
被挡住的那份进 `shadowed` 报出来——不静默丢弃，否则以后有人往全局层加一条同名工作流，
会去翻文件系统而不是查这条遮蔽记录。

跨项目复用是一个**要留痕的显式动作**（把文件放进那个项目的 `.mneme/pipelines/`），不是默认行为。

为什么不是 YAML：本包运行时依赖只有 `morphicons` 一个，为一个配置文件引 YAML 解析器不划算；
手写子集解析器则是给未来埋一个「读起来对、跑起来错」的坑。人读的形态交给面板渲染，
不靠文件格式。

```jsonc
{
  "id": "dsh-mneme-release",        // 小写 kebab
  "name": "dsh-mneme 发版",
  "project": "slow-stack/mneme",    // 作者声明的归属；与「从哪一层读出来」是两件事
  "version": 0,                     // 从 0 起；回写是新版本，不是改旧版本
  "status": "active",               // draft / active / retired（退役不删文件）
  "done": "整条怎么算完成",
  "trigger": [{ "kind": "phrase", "value": "发版" }],
  "inputs": ["版本号"],
  "steps": [
    {
      "id": "P1", "phase": "准备", "title": "确认在哪个分支上",
      "when": "开始发版之前", "do": "git branch --show-current",
      "who": "main",                // main / subagent / human / ci
      "tool": "git",
      "done": "分支名与预期一致",
      "risk": "R1",                 // R0–R3，一步跨多档按最高档
      "needs": [],                  // 依赖的步骤 id
      "why": "为什么要有这一步",
      "evidence": ["反事实对照：没有它，这次会不会失败"],
      "gate": false                 // true = 闸门，图上会显出来
    }
  ]
}
```

校验很硬：**任何一处不合规就整条读不出来**，不做「跳过坏文件继续」。半个工作流比没有
工作流更危险，因为它会被当成完整的跑。

一处例外：**项目层的坏文件不拖死全局层**。它进 `unreadable`（带 `scope` / `dir` / `detail`，
`detail` 里点名是哪个文件），其余照读；全局层是自家发出去的包，坏了照抛。

## 4. 对外接口

```
GET /api/dsh-mneme/pipeline                       # 全局层；包里没有 pipelines/，所以通常为空
GET /api/dsh-mneme/pipeline?project=<绝对路径>     # 该项目的清单（项目层优先）
GET /api/dsh-mneme/pipeline?project=…&id=<id>     # 指定一条

GET /api/dsh-mneme/candidates?project=<绝对路径>   # 该项目的**候选**（见 §9）

GET  /api/dsh-mneme/nudge?project=<绝对路径>       # 沉淀预览：只看不写（见 §10）
POST /api/dsh-mneme/nudge?project=<绝对路径>       # 真跑一次：落提议（要 token）
```

三条路由读的是**三处不同的东西**，别混：`/pipeline` 读定稿（住仓库），
`/candidates` 读候选（住本地），`/nudge` 是**产生**候选的那台机器（见 §10）。
分开是刻意的，理由分别见 §9 与 §10。

**`project` 必须是绝对路径**，否则回 `400 {"error":"project-not-absolute","detail":"…"}`。
忽略了它就会静默退成「全局」，而全局看起来一切正常——那正是这一版要修掉的那类错。

项目根由**客户端从会话里读出来带过去**（`ctx.get("sessions")` 的 `header.cwd`），不是服务端猜的：
插件侧的 `ctx` 上只有 `logger` / `llm` / `reflect` / `webServer` / `inject` / `commands`，
没有 cwd。取不到时**返回 null 并如实标「项目未知」，绝不回退 `process.cwd()`**——回退会把
「读不到」变成「读到别处」，而后者在界面上看不出来。

只读，刻意不做功能旗标闸门：旗标管的是**客户端座位**（关 = 一个座位都不注册），不是数据面；
多一个只读端点不会让任何人看见它，而给它加闸门只会让「为什么面板打不开」多一种成因。

整个目录读不出来时回 `500` + `{"error":"pipeline-unreadable","detail":"…"}`。工作流是人手写的
JSON，报错本身就是给人看的：`detail` 里是校验器**一次列全**的问题清单，说清哪一条、坏在哪，
而不是回一句 internal 让人去翻日志。

答复的契约是 `mneme.pipeline/v1`——**前端渲染与 agent 读的是同一份数据**，画面只是它的投影。
只画不存，agent 就什么也读不到。

```
{ "contract": "mneme.pipeline/v1",
  "pipeline": { "id", "title", "scope", "scopeLabel", "project", "file", "status",
                "version", "done", "trigger", "inputs", "updatedAt" },
  "nodes": [ { "id", "kind", "title", "phase", "situation", "action", "why",
               "who", "tool", "done", "risk", "state", "credit",
               "counterfactual", "source" } ],
  "edges": [ { "id", "from", "to", "kind", "label" } ],
  "runs": [],
  "sources": [ { "scope": "project", "label": "mneme", "dir": "…", "present": true },
               { "scope": "global",  "label": null,     "dir": "…", "present": false } ],
  "available": [ { "id", "title", "scope", "scopeLabel", "project", "status", "file",
                   "done", "trigger", "steps", "gates", "contrast", "states" } ],
  "shadowed": [ { "id", "hidden", "by" } ],
  "unreadable": [ { "scope", "dir", "detail" } ] }
```

`scope` 是**从哪儿读出来的**，不是作者写的：同一条文件放进项目层就是项目级。
`scopeLabel` 只取项目目录名——绝对路径里有本机用户名，而面板是会被截图的那一面。

`available` 是清单页的**全部输入**：够渲染「名字 + 一句话 + 状态 + 作用域 + 规模」，
不必先把整张图读出来。其中 `contrast` 是带反事实对照的步骤数——19/19 与 6/19 是两条
完全不同的工作流，第一屏就该看得出来。

`kind` 是画法，不是第二套数据：`gate`（闸门，这里会停下）／`branch`（有多个上游汇进来）／
`step`。三者都由步骤自己的 `gate` 与 `needs` 推出来，没有额外字段。

## 5. 状态是推出来的，不是写上去的

节点状态四值，全部由**运行记录**推：

| 状态 | 什么时候 |
|---|---|
| `refuted` | 被推翻过（有一次算一次，最扎眼） |
| `trusted` | 用过、没砸，且距今不超过 90 天 |
| `stale` | 有记录但久未使用（> 90 天），或被跳过 ≥3 次 |
| `candidate` | 没有任何记录 |

**没有运行记录时，全体是 `candidate`。** 这是实话，也正是面板上那句 beta 免责必须摆在
脸上的原因：一张全是候选的图不说明这套做法不管用，只说明它还没被对照过。

每一步的处置有**三种**，计数各自独立（谁都不冒充谁）：

| 处置 | 含义 | 推动什么 |
|---|---|---|
| 走了 | 照图执行 | 信用曲线的分子 |
| **跳过** | 这一步**这次**不用（图上还在） | 跳过 ≥3 次 → 提议删掉**这一步** |
| **绕开** | 从这一步岔出去走了图上没有的路 | 绕开 ≥3 次 → 提议重看**这条路径** |

「跳过」与「绕开」的区别是这一版的核心：**跳过 = 这次不用；绕开 = 这条路整个不对。**
只有绕开才动「路径」那一层。两者混在一起数，两种提议就都发不出来。

**绕行不给节点染色。** 被绕开不等于这一步坏了（可能只是这次不需要），所以它只累加计数、
不改状态。染色留给真正被推翻过的 `refuted`——那是唯一有硬判据的信号。

`counterfactual` 装的是**准入判据**（「没有它，这次会不会失败」），不是备注：答不出对照的步骤，
在图上要显得比答得出的虚。

## 6. 内部文件

| 文件 | 干什么 |
|---|---|
| `src/pipeline.js` | 两层来源 → 读盘 → 校验 → 投影，零运行时依赖 |
| `<项目>/.mneme/pipelines/*.json` | 工作流本体（**不进包**，见 §7） |
| `lib/client.js` 的 M.O. 段 | 清单 + 图：会话头图标、会话头浮层、右栏 tab、对话行结算卡，四个座位读同一份投影 |

## 7. 已知坑

- **`pipelines/` 不许进 `package.json` 的 `files`**。第一版写反了：那时住址是插件包，
  必须进包才读得到；现在住址是项目，进包就意味着**装完之后每个项目都能读到它**，
  「项目级」又悄悄退回全局级——而这一幕在任何一次本地测试里都看不出来，因为测试读的是
  仓库目录（`test/pipeline.test.js` 两头都锁）。
- **`project` 参数被忽略比被拒绝危险得多**。忽略 → 静默读全局 → 面板看着一切正常；
  拒绝 → 一条 400，谁都得面对。所以路由写死「不是绝对路径就 400」。
- **`needs` 指向不存在的步骤、图里有环，都在校验期报错。** 环的提示是「回写是新版本，
  不是回到自己」——这是最容易犯的一个：想把「发现要改」连回前面某一步。
- **`lib/client.js` 是唯一手写处，没有 `src/` 对应物**，所以它不进 `npm run sync`；
  改完直接 `node --check`。
- **四个座位读的必须是一份缓存**（按项目 + 按工作流分桶）。分家的事故长这样——会话头说有
  3 条、右栏说有 5 条，两个数字都对，没人查得出来；不分桶则是把 A 项目的清单端给 B 项目看，
  而画面同样看不出错（`test/client.test.js` 有锁）。
- **清单与图必须是两个组件**，不能在一个组件里按 `openId` 分支：hook 不能条件调用，
  那样写会在点开那一下炸成「Rendered more hooks than during the previous render」，
  运行期才看得见。
- **面板第一版是硬编码假数据**（`PIPELINE_MOCK`）。接上真数据之后那份常量已经删掉，
  测试锁的就是「它不再出现」：留着它，面板随时可能退回展示一份并不存在的工作流。

---

## 8. 还没做的

- **执行侧**：今天只有读侧。触发、拆子 agent、接进 CI/CD 都还没做。
  所以 §9 的绕行**只有判定与落账的代码，没有触发它的地方**——提议与批准的动作也还没有落点。
- **运行记录的写入**：「哪一次跑走过了哪几步」还没有落点，所以状态目前恒为 `candidate`。
- **画布观感**：清单这一屏先落地；图那部分还是能看、不好看。
- **记忆侧的对照表**：`source.memoryIds` 是留给本机的一个口子（记忆 id 是本机私有数据，
  不进仓库文件），今天还没有人填它。

---

## 9. 绕行与候选分层：图上没有的路，留一个出口

### 9.1 为什么要有出口

一张只会被照着走的图，会让 agent 失去自主性——它要么**沉默地服从**（照着走，做错了也不知道
错在哪），要么**沉默地偏离**（觉得不适用就跳了，不记账，没人知道这次执行了几成）。

一条规矩如果没留出口，走不通的人不会遵守它，会**自己凿一个，而且不告诉你**。
所以图上必须有一扇**合法的逃生门**：agent 可以判断「这条路这次可能不对」，然后
按风险档决定是记账先走，还是停下问人。

分界是一句话：**护栏，不是轨道**——允许偏离，但偏离要**响**。

### 9.2 分档（自主性住在 R0/R1）

| 情形 | 谁决定 | 行为 |
|---|---|---|
| 自报 R0/R1 **且**不跨闸门 **且**兜底清单没命中 | `model` | **记账先走** |
| 其余 | `human` | **停下等点头** |

三条各自独立、都要过：

1. **风险档**——这是模型自报的，单靠它不够。
2. **兜底清单**（`IRREVERSIBLE_PATTERNS`）——机械匹配，**不看自报**。自报说 R0 但命令里有
   `git push`，照样拦。这是「模型在自己上下文里给自己打分不可靠」的直接应用：
   不是不信任，是**不给单点判断**。清单可审计、可 diff、可追责；让模型再判一次
   只是把同一个错误问两遍。
3. **闸门保护**——这条绕行如果走下去，图上某个 `gate` 会不会永远不被经过？会就拦住。
   **与风险档无关**：闸门本来就放在「错了很难撤」的地方，绕开它等于把安全阀绕掉，
   哪怕这条绕行本身全是只读动作。

### 9.3 住址：候选不进仓库

```
<项目>/.mneme/pipelines/<id>.json              定稿（进仓库、可 review、可回退）
<memoryDir>/candidates/<项目键>/<id>.json       候选（只在本地，永不进仓库）
```

候选是草稿，一天可能长十条、死掉九条；进仓库就是垃圾，也会被当成规程引用。
**候选是定稿的唯一入口，而定稿只放人亲手批过的东西。**

`<项目键>` 取「目录名 + 路径哈希前 8 位」：同一个项目每次算到同一个键，
但**不把绝对路径写进文件名**（那里面是本机用户名）。

### 9.4 升格：跑通一次 ≠ 够格

两条都要满足：

1. **走过两次**（至少一次 `worked`）——「复用第二次才算类型，第一次只是实例」；
2. **写得出反事实**（「没有它，这次会不会失败」）——写不出来，它就还是实例。

够格之后系统**提议**，不自己写进仓库：提交是 R1、推送是 R2，分开两档，人始终握着一道。

`promoteCandidate()` 只**返回内容、不写盘**，而且补出来的东西照样要过
`validatePipeline`——**升格不是降低标准，是补齐标准**。

### 9.5 怎么死

- 只走过 1 次、30 天内没再被走到 → 归档（`status: dropped`，**文件留着**当负清单的邻居）
- 走不通的（`outcome: failed`）→ 直接进「走不通」那一栏，仍是知识，留痕
- 人否掉 → `status: dropped` + 记否决理由

**候选不需要人维护。** 它只增不减地长，死掉的是「没被再走到」的那些，
不是「没人管」的那些。

### 9.6 这一节**不做**的事

写在契约的 `boundaries` 里，免得以后有人以为忘了做：

```jsonc
{ "autoPromote": false, "autoApprove": false, "injectIntoMemory": false }
```

- **不做自动升格**：跑通两次只是「够格被提议」，不是「自动进图」
- **不做绕行的自动批准**：一律 R0/R1 记账先走、R2/R3 停下问人，没有「全都可以先走」
- **不把候选塞进注入路径**：它是草稿，不是知识
- **不做「图自动重写」**：图的变化一律经过提议 + 人批；回写是新版本，不是改旧版本

## 10. 自动沉淀：工作流从哪长出来

§1 说过「一条新工作流从哪来」在那之前一直是零代码。这一节就是那个来源。

```
turn/end  →  这个项目自己的会话桶  →  挖重复动作  →  memoryDir/proposals/<项目键>/
```

### 10.1 它写什么、不写什么

| 产物 | 住哪 | 谁批准 |
|---|---|---|
| **提议**（proposal） | `<memoryDir>/proposals/<项目键>/nudge-*.json` | 不需要批准，但**没人看就等于没有** |
| **草稿**（draft 工作流） | `<项目>/.mneme/pipelines/<id>.json` | 人显式要（`materialize: true`），自动跑**永不**落 |
| **定稿**（active） | 同上，换 `status` | 人批，机器永远不给 |

边界写在答复的 `boundaries` 里，跳过与跑完用的是**同一个常量**（`NUDGE_BOUNDARIES`）
——两条出口说两句不同的话，读的人就会以为其中一条忘了做：

```jsonc
{ "autoActivate": false, "autoAnswerCounterfactual": false,
  "writesOnlyDrafts": true, "riskIsFloor": true, "crossProjectSupport": false }
```

### 10.2 归属：为什么不能拿全机会话去挖

会话日志在 DSH 侧按**工作区**分桶（`<DSH_HOME>/sessions/<桶>`），而桶名是路径的
**有损**变形、规则不在本仓。所以「这个项目的工作流」只有在拿到**这个项目自己的桶**
时才成立；归属靠会话头里的 `cwd` 反查（`resolveProjectSessionDir`）。

拿不到归属时这一跑**整跑跳过**（`skipped: true`）而不是降级成全局普查：后者会把
A 项目的动作算到 B 头上，而结果看起来完全正常（复核报告 §2.6 实测过）。

`reason` 的四种取值里，**`unreadable` 必须与 `no-bucket` 分开**：前者是「读不到」
（故障），后者是「确实没有」（关于这个项目的答案）。混起来等于把故障报成空结果。
端点层因此有两种状态码：`no-sessions-dir` / `unreadable` → **503**，其余 → **200**。

### 10.3 触发：为什么不是「读一次就沉淀一次」

| 控制 | 值 | 为什么是这个值 |
|---|---|---|
| 触发点 | `turn/end` | 一轮结束才算一个观测窗口，中途记的是半截 |
| 冷却 | `NUDGE_AUTO_MIN_INTERVAL_MS` = 10 分钟／项目 | **观测新鲜度**参数，不是性能参数：日志十分钟内长不出新重复模式 |
| 单次预算 | `NUDGE_AUTO_BUDGET_MS` = 4 秒 | 超了带着已读到的部分收工并标 `budgetExceeded`，**不重试、不补跑** |
| 执行时机 | `setTimeout(0)` | 解压会话日志是同步 I/O（本机 118 份实测 16.2 秒），压在 `turn/end` 那一帧上等于让回合收尾卡住 |
| 排队 | 一次只跑一个项目 | 同上，别把一帧拉长 |

**开与不开**：`actionNudgeEnabled`（默认关）。它与 `actionPipelineEnabled`（看图）、
`actionRunEnabled`（记账）是**三件独立能力**，开任何一个都不会顺带打开另一个。
它在 `LIGHT_MODE_OFF` 里——三个 M.O. 键里它最重（挂 `session/event` + 同步解压）。

### 10.4 为什么提议落盘要单独一个开关

`nudgeFromSessions` 原本只要算得出项目键就**无条件**往 memoryDir 里写提议。面板每刷
一次就 GET 一次，那样每刷一次就往记忆库里塞一批文件。所以有了 `persistProposals`
（默认 `true` 保持既有语义），预览路由显式传 `false`：

- `GET` / `HEAD` → `persistProposals: false`，**一个文件都不写**；
- `POST` / `PUT` → `persistProposals: true`，落提议。

这也是 `/nudge` 的 `policy` 是 `token-write` 而不是 `open` 的原因：GET 与
`/pipeline`、`/candidates` 同级（只读预览，免 token），POST 会写盘，写盘面一律受门控。
