diff --git a/CHANGELOG.md b/CHANGELOG.md
index e455c8e..d370e41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
+## 1.3.3 - 2026-07-28
+
+- Let users bind confirmed and challenged memory references when recording a real Context outcome.
+- Surface challenged memory references in the Trust ledger as review signals without automatically mutating Claim or Judgment authority.
+- Show the confirmed and challenged reference counts on recorded Context outcomes.
+
+## 1.3.2
+
+- Make the top-level `agent-context` command support the complete preview and compile lifecycle instead of returning preview-only output.
+- Stop treating the retired Markdown card cache as the authoritative judgment health signal or injecting it into agent context.
+- Keep the advertised agent modes aligned with the modes accepted by the backend and reject unsupported modes before queueing work.
+- Point generated agent guidance and adapters at the reviewed Context Store and Judgment Store lifecycle.
+- Report the full pending-confirmation count on Home instead of presenting the eight-item display budget as the total.
+- Add real review routes to Trust and collapse long evidence categories with native disclosure controls.
+- Add an owner-only `learning-review` preview and explicitly confirmed Feishu reminder without duplicating the Claim or Judgment state machines.
+
## 1.3.1
- Move private identity aliases and categories out of public source code into local configuration, and make owner-only defaults work for every installation.
diff --git a/README.md b/README.md
index 6c92574..57e9829 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-
+



@@ -106,8 +106,9 @@ immortal-memory train --smoke --build-role --goal "writing review" --mode writer
# 看看给 agent 的入口长什么样
immortal-memory agent-entry
-# 针对一个具体任务,生成贴身的上下文包
-immortal-memory agent-context "help me review this product idea" --print
+# 先生成可审阅预览,再用返回的 ID 和 hash 编译
+immortal-memory agent-context "help me review this product idea" --mode reviewer
+immortal-memory agent-context "help me review this product idea" --mode reviewer --preview-id "
" --preview-hash "" --print
```
### 自动接入你的高质量语料
@@ -152,6 +153,29 @@ immortal-memory agent-factory
看板有七个真实模块:**首页、记忆、我、判断、使用、信任、系统**。首页回答今天新增了什么价值;「记忆」追溯原始证据;「我」展示 Living Self 的八个认知分区;「判断」管理判断卡;「使用」预览并编译 Context Pack;「信任」解释归因、隐私排除和纠正;「系统」展示采集、索引、备份、服务与版本健康。
+### 飞书学习审核提醒
+
+先在本机预览待确认内容,不会发送消息:
+
+```bash
+immortal-memory learning-review
+immortal-memory learning-review --json
+```
+
+发送前可以让飞书 CLI 只预演请求。收件人固定读取本机私有配置中的 `feishu.expected_user_open_id`,不能临时改成群或其他人:
+
+```bash
+immortal-memory learning-review --send-feishu --dry-run --json
+```
+
+只有明确确认远端写入才会以机器人身份发送本人私聊提醒:
+
+```bash
+immortal-memory learning-review --send-feishu --confirm-remote-write
+```
+
+提醒只包含经过凭证和本机路径脱敏的有限候选摘要,以及本机审核面板入口。它不会自动确认、拒绝或升级任何 Claim 和 Judgment,也不会自动加入每日调度。真实审核仍在 Home 和 Trust 中完成,所有状态变化继续使用原有事件账本。
+
### 从 v1.0 做隔离迁移演练
下面这组命令只完成隔离恢复和迁移演练,不会切换生产。不要直接在生产 vault 上试跑。先创建异盘备份并通过严格恢复校验,再把 v1.0 vault 恢复到独立 staging 目录。含无时区 Hermes 时间戳时,必须先准备经哈希绑定的私有时区合同,并把参数同时传给 staging 和 migration:
@@ -234,7 +258,7 @@ immortal-memory backup-status --verify --max-age-hours 168 --json
immortal-memory health --max-age-hours 72
immortal-memory doctor
immortal-memory preflight
-immortal-memory agent-context "release acceptance" --print
+immortal-memory agent-context "release acceptance" --mode reviewer
```
系统看板会把主流程、自动反馈和本机通知分开显示。`run` 成功不等于整条自动化成功:飞书来源部分失败或通知未送达时,反馈卡和调度器都会明确标为需要关注。
@@ -244,13 +268,13 @@ immortal-memory agent-context "release acceptance" --print
```bash
CLEAN_HOME="$(mktemp -d /tmp/immortal-clean-home.XXXXXX)"
python3 -m venv "$CLEAN_HOME/venv"
-WHEEL="$(find "$(pwd)/dist" -maxdepth 1 -name 'immortal_memory-1.3.1-*.whl' | head -n 1)"
+WHEEL="$(find "$(pwd)/dist" -maxdepth 1 -name 'immortal_memory-1.3.3-*.whl' | head -n 1)"
HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/python" -m pip install "$WHEEL"
HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/immortal-memory" init --owner-display-name "Clean Install" --alias "clean"
HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/immortal-memory" train --smoke
HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/immortal-memory" health --max-age-hours 72
HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/immortal-memory" preflight
-HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/immortal-memory" agent-context "clean install acceptance" --print
+HOME="$CLEAN_HOME" "$CLEAN_HOME/venv/bin/immortal-memory" agent-context "clean install acceptance" --mode reviewer
```
空白 vault 尚未配置外置备份或每日调度时,`health` 和 `preflight` 应明确返回待配置项,不能假装健康;clean-install 验收关注命令可运行、状态诚实、数据只写入隔离 `HOME`。生产验收则要求前述健康命令全部通过。
@@ -272,9 +296,9 @@ Windows 原生运行。
给本地 agent 一段这样的交接说明就行:
```text
-先读 ~/.immortal/agent/ENTRY.md。然后运行:
-immortal-memory agent-context "<当前任务>" --print
-把返回的内容当作任务级记忆来用,默认不要直接去读原始库。
+先读 ~/.immortal/agent/ENTRY.md。运行 agent-context --mode reviewer 生成预览。
+审阅 context_json 后,用同一任务、--preview-id 和 --preview-hash 再运行一次并加 --print。
+只有 lifecycle_status=compiled 时才把输出当作任务级记忆,默认不直接读原始库。
```
这套模式对下面这些都成立:
@@ -284,7 +308,7 @@ immortal-memory agent-context "<当前任务>" --print
| Codex | 装 `adapters/codex/skills/immortal-memory` |
| Claude Code | 装 `adapters/claude-code/skills/immortal-memory` |
| 通用 CLI agent | 直接跑 `immortal-memory agent-context` |
-| MCP / HTTP | 通过 Agent Bridge 扩展(规划中) |
+| MCP / HTTP | 通过本地 Agent Bridge |
## 项目结构
@@ -407,7 +431,7 @@ cd immortal-memory
python3 install.py --owner-display-name "Your Name" --alias "Your Alias" --install-codex-adapter
immortal-memory train --smoke --build-role --goal "writing review" --mode writer
immortal-memory agent-entry
-immortal-memory agent-context "help me review this product idea" --print
+immortal-memory agent-context "help me review this product idea" --mode reviewer
```
Open the local dashboard:
@@ -431,9 +455,9 @@ not load Python from the private vault.
Hand a local agent this:
```text
-Read ~/.immortal/agent/ENTRY.md first. Then run:
-immortal-memory agent-context "" --print
-Use the returned context as task-local memory. Do not read the raw vault by default.
+Read ~/.immortal/agent/ENTRY.md first. Preview with an explicit mode, review
+context_json, then compile the same task with --preview-id and --preview-hash.
+Use output only when lifecycle_status=compiled. Do not read the raw vault.
```
This works for Codex, Claude Code, terminal agents, and any tool that can read local files and run shell commands.
diff --git a/STATUS.md b/STATUS.md
new file mode 100644
index 0000000..dc97a63
--- /dev/null
+++ b/STATUS.md
@@ -0,0 +1,58 @@
+# Immortal Memory 当前状态
+
+更新时间:2026-07-28 15:24 CST
+当前本地版本:1.3.3
+本轮层级:本地源码已验证,本机真实安装已验证,GitHub 发布以远端 `v1.3.3` Release 为准
+
+## 已完成
+
+- 修复健康检查继续读取退役 Markdown 卡片缓存而产生的假告警,判断力健康状态现在读取当前编排状态。
+- 移除 Agent 上下文对退役、未审核卡片文件的直接注入,改为使用 Judgment Store 的当前数据和评估记录。
+- 补齐顶层 `agent-context` 命令的预览、审核、编译参数,真实跑通 `preview` 到 `compiled` 生命周期。
+- 修正 Control Center 展示的 Agent 模式与后端可接受模式不一致的问题,无效模式会在入队前返回 `400`。
+- 更新 Codex、Claude Code 适配器和使用文档,明确 Agent 只能消费已编译上下文,不能把预览当成已批准记忆。
+- Claude Desktop 的 MCP 配置已指向正式安装目录,不再指向旧版私有 skill 副本。
+- MCP 握手版本改为读取产品统一版本,当前正确报告 `1.3.3`。
+- 首页待确认数量改为报告真实总数,不再把八条展示上限误报为全部数量。
+- Trust 页增加候选理解和候选判断的真实审核入口,长证据分类改为原生折叠面板。
+- 新增 owner-only `learning-review` 命令,默认只生成脱敏预览;飞书发送固定使用本机配置中的本人 open_id,并要求显式远端写入确认。
+- Context 结果表单现在能把已编译记忆逐条标为「确认为有用」或「标记为需复核」,真实写入 `confirmed_refs` 和 `challenged_refs`。
+- Trust 账本新增「任务结果提出复核」,从 Outcome Store 读取被挑战的精确记忆引用,但不会自动改写 Claim 或 Judgment 权威。
+- 已记录的 Context 结果会展示支持与需复核的引用数量,用户能看到反馈是否真正落库。
+- 本机核心、Codex 适配器、Claude Code 适配器和控制中心已更新到同一份源码。
+
+## 验收证据
+
+- 全量测试:Python 3.11 下 `1332 passed in 78.75s`。
+- Python 3.13 临时环境先得到 `1331 passed`,仅隔离 venv 的 `ensurepip` 因解释器自身 `SIGABRT` 失败;同一测试及全量套件已在支持环境 Python 3.11 通过。
+- 学习审核、产品数据、UI、HTTP、打包和版本聚焦回归:`145 passed`。
+- 产品数据、HTTP 与 UI 聚焦回归:`168 passed`。
+- 聚焦回归:`51 passed`,新增 MCP 版本回归单测单独通过。
+- 隐私扫描:`private_scan=ok`。
+- 构建产物:`immortal_memory-1.3.3-py3-none-any.whl` 构建成功,源码和 wheel 隐私扫描均为 `private_scan=ok`。
+- CLI:`immortal 1.3.3`。
+- 真实数据健康:772,017 条记录,最近自动采集、清洗、蒸馏、画像、索引、关系和质量步骤均为当前状态,质量分 100。
+- Agent 上下文:真实任务预览成功,同一预览经 ID 和哈希确认后编译成功,生成 `compiled` 上下文。
+- MCP:正式安装路径握手成功,提供 `immortal_agent_entry`、`immortal_agent_context`、`immortal_recall` 三个工具。
+- 控制中心:`http://127.0.0.1:8765/` 正常响应,版本为 `1.3.3`;服务已重启到新核心,每日调度仍为 loaded。
+- 浏览器验收:Trust 账本真实显示「结果复核 0」和「任务结果提出复核」分类,不再把缺少字段渲染为「未知」。
+- 浏览器验收:首页显示 57 条待确认、当前展示 8 条;Trust 页高度由约 10,237 px 收敛至 1,359 px,审核按钮跳转成功,控制台无错误。
+- 学习审核真实预览:识别 57 条候选理解、0 条候选判断,默认展示 8 条;飞书 CLI `dry-run` 成功,未实际发送、未更改候选状态。
+
+## 仍需关注
+
+- 当前 7.0 GB 备份在同一磁盘。18,058 个文件的严格 SHA256 校验没有缺失或不一致,但这不等于灾难恢复保护。必须由用户指定外置盘或可信同步目录后才能完成外部备份。
+- 本机当前没有可写外置盘或系统同步盘。飞书机器人身份可用,但本机没有 GPG 私钥,尚不能安全生成、上传并演练加密恢复包。
+- 备份扫描发现 48 个凭证形态候选。系统只报告数量,不应自动改写不可变原始记忆;外发任何备份前必须先建立隔离和脱敏策略。
+- Trust 层有 57 条候选需要本人确认,Living Self 当前已确认条目为 0,Judgment Store 当前为 0。系统不能自行把推断升级为用户身份事实。
+- Claude Desktop 配置已修正,但已运行的 Claude MCP 子进程仍是旧路径。退出并重新打开 Claude Desktop 后才会切换到正式核心;本轮未强杀应用,避免打断正在进行的任务。
+- `MAINTENANCE_FREEZE_DESTRUCTIVE` 仍保留。没有经过恢复演练和外部备份前,不解除破坏性维护冻结。
+- GitHub 公开发布已获得明确授权,目标版本为 `v1.3.3`;远端是否完成以 GitHub tag、Release 和 CI 结果为最终证据。
+
+## 下一步门槛
+
+1. 指定外置盘或同步目录,生成并验证外部备份,使 `loss_protection` 从 `unprotected` 变为 `protected`。
+2. 在 Trust 看板中人工确认或拒绝候选记忆,生成第一版有依据的 Living Self。
+ 可先运行 `immortal-memory learning-review` 本地预览;需要本人飞书提醒时,再显式运行 `immortal-memory learning-review --send-feishu --confirm-remote-write`。
+3. 重启 Claude Desktop,验证实际 Claude 会话调用的是正式 MCP 核心。
+4. 发布后核对 GitHub `v1.3.3` tag、Release、wheel 附件和远端 CI,不把本地推送成功单独当作公开发布完成。
diff --git a/adapters/claude-code/CLAUDE.md b/adapters/claude-code/CLAUDE.md
index a207367..91bade6 100644
--- a/adapters/claude-code/CLAUDE.md
+++ b/adapters/claude-code/CLAUDE.md
@@ -1,10 +1,17 @@
# Immortal Memory
When a task depends on my personal preferences, history, writing style, project
-judgment, colleague relationships, or long-term memory, run:
+judgment, colleague relationships, or long-term memory, first preview the exact
+task context:
```bash
-python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --print
+python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --mode reviewer
```
-Then continue using the returned context. Do not read the full raw vault by default.
+Review the returned `context_json`, then compile the same preview:
+
+```bash
+python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --mode reviewer --preview-id "" --preview-hash "" --print
+```
+
+Continue only after `lifecycle_status=compiled`. Do not read the full raw vault.
diff --git a/adapters/claude-code/skills/immortal-memory/SKILL.md b/adapters/claude-code/skills/immortal-memory/SKILL.md
index 5f659fb..a5c9409 100644
--- a/adapters/claude-code/skills/immortal-memory/SKILL.md
+++ b/adapters/claude-code/skills/immortal-memory/SKILL.md
@@ -5,14 +5,22 @@ description: Use when a task depends on the user's personal memory, writing styl
# Immortal Memory Adapter
-Before doing a task that depends on user-specific context, run:
+Before doing a task that depends on user-specific context, create a reviewable
+preview:
```bash
-python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --print
+python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --mode reviewer
```
-Use the returned context as task-local memory. Do not read the full raw vault by
-default.
+Read the returned `context_json`. A preview is not task context. After reviewing
+its selection, compile that exact preview:
+
+```bash
+python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --mode reviewer --preview-id "" --preview-hash "" --print
+```
+
+Continue only when the command reports `lifecycle_status=compiled`. Use the
+printed pack as task-local memory. Do not read the full raw vault by default.
Useful commands:
diff --git a/adapters/codex/skills/immortal-memory/SKILL.md b/adapters/codex/skills/immortal-memory/SKILL.md
index 7357da7..dce25c8 100644
--- a/adapters/codex/skills/immortal-memory/SKILL.md
+++ b/adapters/codex/skills/immortal-memory/SKILL.md
@@ -22,21 +22,28 @@ Private data lives in:
## Default Flow
For tasks involving the user's preferences, history, writing style, relationships,
-projects, decisions, or digital agent behavior, run:
+projects, decisions, or digital agent behavior, first create a reviewable preview:
```bash
-python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --print
+python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --mode reviewer
```
-Use the returned context as task-local context. Do not read the full raw vault by
-default.
+Read the returned `context_json`. A preview is not task context. After reviewing
+its selection, compile that exact preview:
+
+```bash
+python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --mode reviewer --preview-id "" --preview-hash "" --print
+```
+
+Continue only when the command reports `lifecycle_status=compiled`. Use the
+printed pack as task-local context. Do not read the full raw vault by default.
## Commands
```bash
python3 ~/.local/share/immortal-memory/core/immortal.py health
python3 ~/.local/share/immortal-memory/core/immortal.py agent-entry
-python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --print
+python3 ~/.local/share/immortal-memory/core/immortal.py agent-context "" --mode reviewer
python3 ~/.local/share/immortal-memory/core/immortal.py recall ""
python3 ~/.local/share/immortal-memory/core/immortal.py agent-factory
```
diff --git a/core/SKILL.md b/core/SKILL.md
index 5bae944..34872e4 100644
--- a/core/SKILL.md
+++ b/core/SKILL.md
@@ -74,7 +74,8 @@ immortal-memory train
immortal-memory daily-install
immortal-memory agent-entry
immortal-memory task-compile "task" --mode auto
-immortal-memory agent-context "task" --print
+immortal-memory agent-context "task" --mode reviewer
+immortal-memory agent-context "task" --mode reviewer --preview-id "" --preview-hash "" --print
immortal-memory agent-factory
immortal-memory goal
immortal-memory recall "topic"
diff --git a/core/VERSION b/core/VERSION
index 3a3cd8c..31e5c84 100644
--- a/core/VERSION
+++ b/core/VERSION
@@ -1 +1 @@
-1.3.1
+1.3.3
diff --git a/core/agent_bridge.py b/core/agent_bridge.py
index edcc214..823d3a0 100644
--- a/core/agent_bridge.py
+++ b/core/agent_bridge.py
@@ -205,9 +205,10 @@ def bridge_metadata() -> dict[str, Any]:
"profile_compact": str(IMMORTAL_DIR / "profile_compact.md"),
"profile_nuwa": str(IMMORTAL_DIR / "profile_nuwa.md"),
"people_index": str(IMMORTAL_DIR / "people" / "people_index.md"),
- # 2026-06-14:digest/product 已停用,改注入判断力卡片盒(纠正即记忆)
- "cards": str(IMMORTAL_DIR / "cards" / "cards.md"),
- "cards_compact": str(IMMORTAL_DIR / "cards" / "cards_compact.md"),
+ "judgments_current": str(IMMORTAL_DIR / "judgment" / "current.jsonl"),
+ "judgment_evaluations": str(
+ IMMORTAL_DIR / "judgment" / "evaluations.jsonl"
+ ),
},
"commands": {
"health": cli_command("health", "--max-age-hours", "72"),
@@ -225,17 +226,28 @@ def bridge_metadata() -> dict[str, Any]:
def render_entry(meta: dict[str, Any]) -> str:
owner = meta.get("owner") or "the owner"
- command = meta["commands"]["agent_context"]
+ preview_command = cli_command(
+ "agent-context", "", "--mode", "reviewer"
+ )
+ compile_command = cli_command(
+ "agent-context",
+ "",
+ "--mode",
+ "reviewer",
+ "--preview-id",
+ "",
+ "--preview-hash",
+ "",
+ "--print",
+ )
one_sentence = (
f"请先读取 {ENTRY_MD},然后针对当前任务运行 "
- f"`{command}`,"
- f"把返回内容作为理解{owner}的长期上下文。"
+ f"`{preview_command}` 生成预览;审阅 `context_json` 后用 "
+ f"`{compile_command}` 编译,仅在 `lifecycle_status=compiled` 时消费上下文。"
)
- current_task_command = cli_command("agent-context", "本次任务", "--print")
claude_prompt = (
- f"你现在可以调用{owner}的永生知识库。开始任务前先运行:"
- f"`{current_task_command}`,"
- "再基于返回的画像、偏好、近期状态和证据继续工作。"
+ f"你现在可以调用{owner}的永生知识库。先运行预览命令,"
+ "审阅选中内容,再带 preview ID 和 hash 运行编译命令。"
)
lines = [
"# Immortal Agent Entry",
@@ -269,7 +281,8 @@ def render_entry(meta: dict[str, Any]) -> str:
"",
"## 稳定命令",
f"- health: `{meta['commands']['health']}`",
- f"- agent-context: `{command}`",
+ f"- context preview: `{preview_command}`",
+ f"- approved compile: `{compile_command}`",
f"- recall: `{meta['commands']['recall']}`",
f"- raw context: `{meta['commands']['context']}`",
"",
@@ -288,10 +301,24 @@ def command_entry(_args: argparse.Namespace) -> int:
AGENT_DIR.mkdir(parents=True, exist_ok=True)
ENTRY_MD.write_text(render_entry(meta), encoding="utf-8")
write_json(ENTRY_JSON, meta)
- current_task_command = cli_command("agent-context", "本次任务", "--print")
+ preview_command = cli_command(
+ "agent-context", "本次任务", "--mode", "reviewer"
+ )
+ compile_command = cli_command(
+ "agent-context",
+ "本次任务",
+ "--mode",
+ "reviewer",
+ "--preview-id",
+ "",
+ "--preview-hash",
+ "",
+ "--print",
+ )
claude_prompt = (
f"你现在可以调用{meta.get('owner')}的永生知识库。"
- f"开始任务前运行:{current_task_command}"
+ f"先运行预览:{preview_command}。审阅 context_json 后运行:"
+ f"{compile_command}。仅在 lifecycle_status=compiled 时继续。"
)
CLAUDE_PROMPT.write_text(claude_prompt + "\n", encoding="utf-8")
print(f"entry_md={ENTRY_MD}")
diff --git a/core/agent_bridge_server.py b/core/agent_bridge_server.py
index 561d50a..b4240a4 100644
--- a/core/agent_bridge_server.py
+++ b/core/agent_bridge_server.py
@@ -43,7 +43,7 @@
AUDIT_LOG = AGENT_DIR / "access.log"
AUDIT_LATEST = AGENT_DIR / "access_latest.json"
SERVER_NAME = "immortal-memory"
-SERVER_VERSION = "0.1.0"
+SERVER_VERSION = (SKILL_DIR / "VERSION").read_text(encoding="utf-8").strip()
DEFAULT_PROTOCOL_VERSION = "2025-06-18"
LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
diff --git a/core/control_center_ui.py b/core/control_center_ui.py
index 033ad6e..d856797 100644
--- a/core/control_center_ui.py
+++ b/core/control_center_ui.py
@@ -747,7 +747,7 @@ def control_center_page_html(title: str = "Immortal Control Center") -> str:
$('viewRoot').innerHTML = `${viewHead('agent', `ENTRY · ${data.entry.exists ? 'READY' : 'MISSING'}`)}
ENTRY.md${tag(data.entry.exists ? 'ready' : 'unknown')}
稳定 Agent 入口
${data.entry.exists ? `更新时间 ${esc(when(data.entry.updated_at))} · ${num(data.entry.bytes)} B` : '读取页面不会自动生成文件。'}
-
+
最近上下文
这里只展示元数据,不返回完整 vault。
`;
diff --git a/core/control_data.py b/core/control_data.py
index d16d78d..8f72d81 100644
--- a/core/control_data.py
+++ b/core/control_data.py
@@ -570,7 +570,15 @@ def agent_status(self) -> dict[str, Any]:
{
"id": "create_context",
"fields": ["goal", "mode"],
- "modes": ["auto", "answer", "code", "research", "plan"],
+ "modes": [
+ "auto",
+ "advisor",
+ "writer",
+ "reviewer",
+ "business",
+ "project",
+ "custom",
+ ],
}
],
}
diff --git a/core/immortal.py b/core/immortal.py
index b64ea90..79cdaaa 100755
--- a/core/immortal.py
+++ b/core/immortal.py
@@ -77,7 +77,6 @@
QUALITY_MD = IMMORTAL_DIR / "quality" / "latest.md"
DIGEST_JSON = IMMORTAL_DIR / "digests" / "latest.json"
DIGEST_MD = IMMORTAL_DIR / "digests" / "latest.md"
-CARDS_COMPACT_MD = IMMORTAL_DIR / "cards" / "cards_compact.md"
PRODUCT_GOAL_JSON = IMMORTAL_DIR / "product" / "goal.json"
PRODUCT_GOAL_MD = IMMORTAL_DIR / "product" / "goal.md"
DASHBOARD_HTML = IMMORTAL_DIR / "dashboard.html"
@@ -700,8 +699,8 @@ def add_obsidian_state(label: str, key: str):
add_file("Profile Attribution Audit", PROFILE_ATTRIBUTION_AUDIT_JSON)
add_file("Nuwa Profile JSON", PROFILE_NUWA_JSON)
add_file("Nuwa Profile MD", PROFILE_NUWA_MD)
- # 2026-06-14:Product Goal / Digest / 主看板 / 时间线 已停用,改为校验判断力卡片盒
- add_file("判断力卡片盒", CARDS_COMPACT_MD, min_size=64)
+ # v1.1 判断库是事件源;旧 cards_compact.md 只是已退役的派生缓存。
+ add_state("判断力卡片盒", "last_cards_build")
add_file("Agent Entry MD", AGENT_ENTRY_MD, min_size=1024)
add_file("Agent Entry JSON", AGENT_ENTRY_JSON)
add_file("网页收录状态", WEB_STATE_JSON)
@@ -978,13 +977,6 @@ def command_context(args) -> int:
for line in text.splitlines()[: args.nuwa_lines]:
print(line)
print()
- # 判断力卡片盒(纠正即记忆)取代旧的 digest 遥测摘要注入:高信号、低噪音
- if CARDS_COMPACT_MD.exists():
- print("Judgment cards excerpt (纠正即记忆,代表你纠正 AI 时表达的判断):")
- text = redact(CARDS_COMPACT_MD.read_text(encoding="utf-8", errors="ignore"))
- for line in text.splitlines()[: args.digest_lines]:
- print(line)
- print()
if PEOPLE_MD.exists():
print("People index entrypoint:")
print(f"- Full file: {PEOPLE_MD}")
@@ -1571,6 +1563,18 @@ def command_agent_entry(_args) -> int:
def command_agent_context(args) -> int:
bridge_args = ["context", args.query, "--since", args.since, "--timeout", str(args.timeout)]
+ bridge_args.extend(["--mode", args.mode])
+ if args.preview_only:
+ bridge_args.append("--preview-only")
+ if args.preview_id:
+ bridge_args.extend(["--preview-id", args.preview_id])
+ if args.preview_hash:
+ bridge_args.extend(["--preview-hash", args.preview_hash])
+ for item_id in args.exclude_item_id:
+ bridge_args.extend(["--exclude-item-id", item_id])
+ bridge_args.extend(["--ttl-seconds", str(args.ttl_seconds)])
+ if args.metadata_output:
+ bridge_args.extend(["--metadata-output", args.metadata_output])
if args.with_recall:
bridge_args.append("--with-recall")
if args.output:
@@ -1745,6 +1749,21 @@ def command_feedback(args) -> int:
return run_script("feedback_report.py", feedback_args)
+def command_learning_review(args) -> int:
+ review_args = ["--limit", str(args.limit)]
+ if args.vault_dir:
+ review_args[0:0] = ["--vault-dir", args.vault_dir]
+ if args.json:
+ review_args.append("--json")
+ if args.send_feishu:
+ review_args.append("--send-feishu")
+ if args.dry_run:
+ review_args.append("--dry-run")
+ if args.confirm_remote_write:
+ review_args.append("--confirm-remote-write")
+ return run_script("learning_review.py", review_args)
+
+
def command_product(_args) -> int:
code = run_script("product_brief.py")
if code == 0:
@@ -2064,7 +2083,7 @@ def build_parser() -> argparse.ArgumentParser:
init.add_argument(
"--default-mode",
default=None,
- choices=["auto", "advisor", "writer", "reviewer", "business", "project", "shadow", "custom"],
+ choices=["auto", "advisor", "writer", "reviewer", "business", "project", "custom"],
)
init.set_defaults(func=command_init)
@@ -2080,7 +2099,7 @@ def build_parser() -> argparse.ArgumentParser:
train.add_argument(
"--mode",
default=None,
- choices=["auto", "advisor", "writer", "reviewer", "business", "project", "shadow", "custom"],
+ choices=["auto", "advisor", "writer", "reviewer", "business", "project", "custom"],
)
train.set_defaults(func=command_train)
@@ -2341,6 +2360,13 @@ def build_parser() -> argparse.ArgumentParser:
agent_context.add_argument("--with-recall", action="store_true")
agent_context.add_argument("--output", default="")
agent_context.add_argument("--timeout", type=int, default=240)
+ agent_context.add_argument("--mode", default="auto", choices=("auto", "advisor", "writer", "reviewer", "business", "project", "custom"))
+ agent_context.add_argument("--preview-only", action="store_true")
+ agent_context.add_argument("--preview-id", default="")
+ agent_context.add_argument("--preview-hash", default="")
+ agent_context.add_argument("--exclude-item-id", action="append", default=[])
+ agent_context.add_argument("--ttl-seconds", type=int, default=900)
+ agent_context.add_argument("--metadata-output", default="")
agent_context.add_argument("--print", action="store_true")
agent_context.add_argument("--force", action="store_true", help="Generate a context pack even when preflight reports the vault as unavailable (debugging only)")
agent_context.set_defaults(func=command_agent_context)
@@ -2401,6 +2427,17 @@ def build_parser() -> argparse.ArgumentParser:
feedback.add_argument("--notify", action="store_true")
feedback.add_argument("--print", action="store_true")
feedback.set_defaults(func=command_feedback)
+ learning_review = sub.add_parser(
+ "learning-review",
+ help="Preview or send an owner-only review of pending learning candidates",
+ )
+ learning_review.add_argument("--vault-dir", default=None)
+ learning_review.add_argument("--limit", type=int, default=8)
+ learning_review.add_argument("--json", action="store_true")
+ learning_review.add_argument("--send-feishu", action="store_true")
+ learning_review.add_argument("--dry-run", action="store_true")
+ learning_review.add_argument("--confirm-remote-write", action="store_true")
+ learning_review.set_defaults(func=command_learning_review)
sub.add_parser("product", help="Generate the product-level operating brief").set_defaults(func=command_product)
sub.add_parser("goal", help="Alias for product; show what this system is becoming").set_defaults(func=command_product)
diff --git a/core/learning_review.py b/core/learning_review.py
new file mode 100644
index 0000000..4449095
--- /dev/null
+++ b/core/learning_review.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""Build and optionally send an owner-only learning review reminder."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Callable
+
+from agent_bridge import _canonical_system_alias_path, redact_external_text
+from claim_store import ClaimStore
+from config import configured_vault_dir, load_config
+from judgment_store import JudgmentStore
+
+
+REVIEW_URL = "http://127.0.0.1:8765/?view=home"
+OPEN_ID = re.compile(r"^ou_[A-Za-z0-9]+$")
+
+
+def _summary(value: Any) -> str:
+ text = redact_external_text(value, max_chars=180)
+ text = re.sub(r"\s+", " ", text).strip()
+ return text.replace("<", "<").replace(">", ">") or "内容为空,需在本机核对"
+
+
+def build_review(
+ vault_dir: Path,
+ *,
+ limit: int = 8,
+ clock: Callable[[], datetime] | None = None,
+) -> dict[str, Any]:
+ if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 20:
+ raise ValueError("limit must be between 1 and 20")
+ vault_dir = _canonical_system_alias_path(Path(vault_dir))
+ claims = [row for row in ClaimStore(vault_dir).list() if row.get("status") == "candidate"]
+ judgments = [row for row in JudgmentStore(vault_dir).list() if row.get("status") == "candidate"]
+ items = [
+ {
+ "kind": "claim",
+ "summary": _summary(row.get("statement")),
+ "revision": row.get("revision"),
+ "updated_at": str(row.get("updated_at") or ""),
+ }
+ for row in claims
+ ]
+ items.extend(
+ {
+ "kind": "judgment",
+ "summary": _summary(row.get("title")),
+ "revision": row.get("revision"),
+ "updated_at": str(row.get("updated_at") or ""),
+ }
+ for row in judgments
+ )
+ items.sort(key=lambda row: (row["updated_at"], row["kind"]), reverse=True)
+ now = (clock or (lambda: datetime.now(timezone.utc)))()
+ if not isinstance(now, datetime) or now.tzinfo is None or now.utcoffset() is None:
+ raise ValueError("clock must return a timezone-aware datetime")
+ return {
+ "schema_version": 1,
+ "generated_at": now.astimezone(timezone.utc).isoformat(),
+ "counts": {
+ "total": len(items),
+ "claims": len(claims),
+ "judgments": len(judgments),
+ "visible": min(len(items), limit),
+ },
+ "items": items[:limit],
+ "review_url": REVIEW_URL,
+ }
+
+
+def render_markdown(report: dict[str, Any]) -> str:
+ counts = report["counts"]
+ lines = [
+ "### Immortal 学习审核",
+ "",
+ f"当前有 {counts['total']} 条待确认:候选理解 {counts['claims']} 条,候选判断 {counts['judgments']} 条。",
+ ]
+ for index, item in enumerate(report.get("items") or [], 1):
+ label = "候选理解" if item.get("kind") == "claim" else "候选判断"
+ lines.append(f"{index}. 【{label}】{item.get('summary') or '内容为空'}")
+ if counts["total"] > counts["visible"]:
+ lines.append(f"当前展示 {counts['visible']} 条,其余内容请在本机继续审核。")
+ lines.extend(
+ [
+ "",
+ f"[在安装 Immortal 的电脑上打开审核面板]({report['review_url']})",
+ "",
+ "只有本人确认后的内容才会进入 Living Self 和 Agent Context。",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def send_to_feishu(
+ report: dict[str, Any],
+ recipient_open_id: str,
+ *,
+ dry_run: bool,
+) -> dict[str, Any]:
+ if not OPEN_ID.fullmatch(str(recipient_open_id or "")):
+ raise ValueError("configured Feishu owner open_id is invalid")
+ executable = shutil.which("lark-cli")
+ if not executable:
+ raise RuntimeError("lark-cli is not installed")
+ canonical = json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ idempotency = "immortal-review-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]
+ command = [
+ executable,
+ "im",
+ "+messages-send",
+ "--as",
+ "bot",
+ "--user-id",
+ recipient_open_id,
+ "--markdown",
+ render_markdown(report),
+ "--idempotency-key",
+ idempotency,
+ ]
+ if dry_run:
+ command.append("--dry-run")
+ environment = dict(os.environ)
+ environment["LARKSUITE_CLI_NO_UPDATE_NOTIFIER"] = "1"
+ environment["LARKSUITE_CLI_NO_SKILLS_NOTIFIER"] = "1"
+ completed = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ env=environment,
+ )
+ if completed.returncode != 0:
+ detail = redact_external_text(completed.stderr or completed.stdout, max_chars=800)
+ raise RuntimeError("Feishu delivery failed: " + detail)
+ try:
+ payload = json.loads(completed.stdout)
+ except json.JSONDecodeError as exc:
+ raise RuntimeError("Feishu delivery returned invalid JSON") from exc
+ if payload.get("ok") is not True:
+ raise RuntimeError("Feishu delivery was not acknowledged")
+ data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
+ return {
+ "dry_run": dry_run,
+ "message_id": str(data.get("message_id") or ""),
+ "chat_id": str(data.get("chat_id") or ""),
+ }
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Preview or send owner-only learning review reminders")
+ parser.add_argument("--vault-dir", default=None)
+ parser.add_argument("--limit", type=int, default=8)
+ parser.add_argument("--json", action="store_true")
+ parser.add_argument("--send-feishu", action="store_true")
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--confirm-remote-write", action="store_true")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ if args.dry_run and not args.send_feishu:
+ print("--dry-run requires --send-feishu", file=sys.stderr)
+ return 2
+ if args.send_feishu and not args.dry_run and not args.confirm_remote_write:
+ print("Feishu delivery requires --confirm-remote-write", file=sys.stderr)
+ return 2
+ try:
+ config = load_config()
+ vault_dir = Path(args.vault_dir).expanduser() if args.vault_dir else configured_vault_dir(config)
+ report = build_review(vault_dir, limit=args.limit)
+ receipt = None
+ if args.send_feishu:
+ feishu = config.get("feishu") if isinstance(config.get("feishu"), dict) else {}
+ recipient = str(feishu.get("expected_user_open_id") or "")
+ receipt = send_to_feishu(report, recipient, dry_run=args.dry_run)
+ if args.json:
+ print(json.dumps({"review": report, "delivery": receipt}, ensure_ascii=False, indent=2))
+ elif receipt is not None:
+ print("delivery=dry-run" if receipt["dry_run"] else "message_id=" + receipt["message_id"])
+ else:
+ print(render_markdown(report))
+ return 0
+ except (OSError, RuntimeError, TypeError, ValueError) as exc:
+ print(redact_external_text(exc, max_chars=800), file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/core/product_assets/views/contexts.js b/core/product_assets/views/contexts.js
index db3e358..3b04d46 100644
--- a/core/product_assets/views/contexts.js
+++ b/core/product_assets/views/contexts.js
@@ -112,6 +112,28 @@ function previewBody(preview, refresh) {
return container;
}
+function outcomeMemoryReview(detail) {
+ const fieldset = document.createElement("fieldset");
+ fieldset.className = "preview-section";
+ fieldset.append(node("legend", "这条记忆实际表现如何"));
+ const allowedKinds = new Set(["claim", "self_model", "judgment"]);
+ Object.values(detail.sections || {}).flat().forEach((item) => {
+ if (!allowedKinds.has(item?.kind) || !item.id || !Number.isInteger(item.revision)) return;
+ const label = document.createElement("label");
+ label.className = "field-label";
+ label.append(node("span", item.summary || item.title || item.id));
+ const select = document.createElement("select");
+ select.name = "memory_feedback";
+ select.dataset.kind = item.kind;
+ select.dataset.id = item.id;
+ select.dataset.revision = String(item.revision);
+ [["", "不评价"], ["confirmed", "确认为有用"], ["challenged", "标记为需复核"]].forEach(([value, text]) => select.append(new Option(text, value)));
+ label.append(select);
+ fieldset.append(label);
+ });
+ return fieldset.querySelector("select") ? fieldset : null;
+}
+
function outcomeForm(detail, refresh) {
const form = node("form", "", "action-form");
const adopted = control("采用程度", "adopted", "select");
@@ -124,15 +146,26 @@ function outcomeForm(detail, refresh) {
submit.type = "submit";
const feedback = node("p", "", "form-feedback");
const attempt = createMutationAttempt();
+ const memoryReview = outcomeMemoryReview(detail);
form.append(adopted.wrapper, result.wrapper, summary.wrapper, reason.wrapper, submit, feedback);
+ if (memoryReview) form.insertBefore(memoryReview, submit);
form.addEventListener("submit", async (event) => {
event.preventDefault();
pending(submit, true, "正在记录……", "记录结果");
try {
+ const confirmed_refs = [];
+ const challenged_refs = [];
+ form.querySelectorAll('select[name="memory_feedback"]').forEach((select) => {
+ if (!select.value) return;
+ const ref = { kind: select.dataset.kind, id: select.dataset.id, revision: Number(select.dataset.revision) };
+ (select.value === "confirmed" ? confirmed_refs : challenged_refs).push(ref);
+ });
const payload = {
adopted: adopted.input.value,
result: result.input.value,
summary: summary.input.value,
+ confirmed_refs,
+ challenged_refs,
expected_version: detail.revision,
reason: reason.input.value,
};
@@ -202,7 +235,14 @@ async function showContext(id, trigger, refresh) {
content.append(consume, feedback);
}
if (detail.lifecycle_status === "consumed") content.append(node("h3", "待记录结果"), outcomeForm(detail, refresh));
- if (detail.outcome) content.append(node("p", `${detail.outcome.result}:${detail.outcome.summary || "无摘要"}`, "outcome-summary"));
+ if (detail.outcome) {
+ const confirmed = detail.outcome.confirmed_refs?.length || 0;
+ const challenged = detail.outcome.challenged_refs?.length || 0;
+ content.append(
+ node("p", `${detail.outcome.result}:${detail.outcome.summary || "无摘要"}`, "outcome-summary"),
+ node("p", `支持 ${confirmed} 条,需复核 ${challenged} 条`, "form-feedback"),
+ );
+ }
body.replaceChildren(content);
} catch (error) {
body.replaceChildren(node("p", error.message || "Context 读取失败", "error-text"));
diff --git a/core/product_assets/views/home.js b/core/product_assets/views/home.js
index 612d916..605f37d 100644
--- a/core/product_assets/views/home.js
+++ b/core/product_assets/views/home.js
@@ -63,6 +63,8 @@ export async function renderHome(root, { signal, isCurrent, navigate, updateHeal
const newest = remembered[0] || {};
const counts = data.understanding_changes?.counts || {};
const confirmations = Array.isArray(data.needs_confirmation) ? data.needs_confirmation : [];
+ const confirmationTotal = Number(data.confirmation_summary?.total);
+ const safeConfirmationTotal = Number.isFinite(confirmationTotal) ? Math.max(0, confirmationTotal) : confirmations.length;
const context = data.latest_context_use || {};
const outcome = data.latest_outcome || {};
const health = data.system_health || {};
@@ -75,7 +77,13 @@ export async function renderHome(root, { signal, isCurrent, navigate, updateHeal
};
fact("今日记忆", String(remembered.length), newest.timestamp ? `${formatTimestamp(newest.timestamp)} · ${newest.source || "来源未知"}` : "今天尚无索引记录");
fact("理解变化", String((counts.added || 0) + (counts.changed || 0) + (counts.removed || 0)), `新增 ${counts.added || 0} · 调整 ${counts.changed || 0} · 移除 ${counts.removed || 0}`);
- fact("待确认", String(confirmations.length), confirmations[0]?.summary || "没有待确认项目");
+ fact(
+ "待确认",
+ String(safeConfirmationTotal),
+ safeConfirmationTotal > confirmations.length
+ ? `当前展示 ${confirmations.length} 条,逐条处理后继续加载`
+ : confirmations[0]?.summary || "没有待确认项目",
+ );
fact("最近 Context", context.context_id ? "已使用" : "无", context.task || context.goal || context.context_id || "暂无已使用 Context");
fact("最近 Outcome", outcome.outcome_id ? "已记录" : "无", outcome.summary || outcome.result || outcome.outcome_id || "暂无任务结果");
fact("系统连续性", health.status_label || health.status || "未知", `版本 ${health.version || "未知"} · 关注项 ${health.attention_count ?? "未知"}`);
diff --git a/core/product_assets/views/trust.js b/core/product_assets/views/trust.js
index eb86de9..8412b50 100644
--- a/core/product_assets/views/trust.js
+++ b/core/product_assets/views/trust.js
@@ -11,6 +11,7 @@ const CATEGORY_LABELS = {
privacy_exclusion: "隐私策略排除",
recent_correction: "最近纠正或替换",
model_evaluation: "自我模型评估",
+ failed_outcome: "任务结果提出复核",
};
const COVERAGE_LABELS = {
@@ -39,14 +40,19 @@ function categoryMessage(category) {
}
function trustCategory(key, category) {
- const section = document.createElement("section");
+ const section = document.createElement("details");
section.className = "system-section";
const title = CATEGORY_LABELS[key] || key.replaceAll("_", " ");
const parsed = Number(category.count);
const count = Number.isFinite(parsed) ? Math.max(0, parsed) : "未知";
- section.append(
+ section.open = typeof count === "number" && count > 0 && count <= 5;
+ const summary = document.createElement("summary");
+ summary.append(
node("p", `${COVERAGE_LABELS[category.coverage] || "覆盖状态未知"} · ${count} 项`, "kicker"),
node("h2", title),
+ );
+ section.append(
+ summary,
node("p", categoryMessage(category), category.coverage === "complete" ? "state-message" : "coverage-warning"),
);
const items = Array.isArray(category.items) ? category.items : [];
@@ -93,7 +99,23 @@ export async function renderTrust(root, { signal, isCurrent, navigate }) {
fact("等待确认", data.summary?.needs_confirmation, "候选理解与候选判断的合计");
fact("低置信度", data.summary?.low_confidence, "证据强度不足,需要谨慎使用");
fact("隐私排除", data.summary?.privacy_exclusions, "按隐私规则未进入上下文的项目");
+ fact("结果复核", data.summary?.challenged_memories, "实际任务结果挑战过的记忆,等待人工核对");
fragment.append(summary);
+ const reviewActions = document.createElement("div");
+ reviewActions.className = "honest-actions";
+ if (Number(data.summary?.candidate_claims) > 0) {
+ const claims = node("button", "审核候选理解");
+ claims.type = "button";
+ claims.addEventListener("click", () => navigate("home"));
+ reviewActions.append(claims);
+ }
+ if (Number(data.summary?.candidate_judgments) > 0) {
+ const judgments = node("button", "审核候选判断", "secondary");
+ judgments.type = "button";
+ judgments.addEventListener("click", () => navigate("judgments"));
+ reviewActions.append(judgments);
+ }
+ if (reviewActions.children.length) fragment.append(reviewActions);
const grid = document.createElement("div");
grid.className = "system-grid";
Object.entries(data.categories || {}).forEach(([key, category]) => grid.append(trustCategory(key, category || {})));
diff --git a/core/product_data.py b/core/product_data.py
index a5b2de1..53c1cea 100644
--- a/core/product_data.py
+++ b/core/product_data.py
@@ -1526,6 +1526,7 @@ def home(self) -> Dict[str, Any]:
for row in claims
if row.get("status") == "candidate"
]
+ candidate_claim_count = len(confirmations)
confirmations.extend(
{
"kind": "judgment",
@@ -1537,6 +1538,7 @@ def home(self) -> Dict[str, Any]:
for row in judgments
if row.get("status") == "candidate"
)
+ candidate_judgment_count = len(confirmations) - candidate_claim_count
used_contexts = [
row
for row in self._context_rows()
@@ -1582,12 +1584,19 @@ def home(self) -> Dict[str, Any]:
"version": _compact_text(snapshot.get("version"), 40),
"attention_count": len(snapshot.get("attention") or []),
},
+ "confirmation_summary": {
+ "total": len(confirmations),
+ "claims": candidate_claim_count,
+ "judgments": candidate_judgment_count,
+ "visible": min(len(confirmations), 8),
+ },
}
def trust(self) -> Dict[str, Any]:
claims = self._claims()
judgments = self._judgment_rows()
contexts = self._context_rows()
+ outcomes = self._outcomes()
category_coverage = {
"unknown_speaker": "complete",
"other_view_candidate": "complete",
@@ -1599,6 +1608,7 @@ def trust(self) -> Dict[str, Any]:
"privacy_exclusion": "complete",
"recent_correction": "partial",
"model_evaluation": "partial",
+ "failed_outcome": "complete",
}
category_items = {key: {} for key in category_coverage}
@@ -1676,6 +1686,23 @@ def add(kind: str, item_id: str, summary: str, severity: str) -> None:
"上下文包含按隐私策略排除的项目",
"info",
)
+ for outcome in outcomes:
+ summary = _compact_text(outcome.get("summary"), 180) or "任务结果对这条记忆提出了挑战"
+ for ref in outcome.get("challenged_refs") or []:
+ if not isinstance(ref, Mapping):
+ continue
+ add(
+ "failed_outcome",
+ str(ref.get("id") or ""),
+ "%s;%s %s(版本 %s)"
+ % (
+ summary,
+ str(ref.get("kind") or "memory"),
+ str(ref.get("id") or ""),
+ str(ref.get("revision") or "未知"),
+ ),
+ "attention",
+ )
try:
current_self = self.self_model()
except ProductDataError:
@@ -1736,8 +1763,11 @@ def add(kind: str, item_id: str, summary: str, severity: str) -> None:
return {
"summary": {
"needs_confirmation": candidate_claims + candidate_judgments,
+ "candidate_claims": candidate_claims,
+ "candidate_judgments": candidate_judgments,
"low_confidence": categories["low_confidence"]["count"],
"privacy_exclusions": categories["privacy_exclusion"]["count"],
+ "challenged_memories": categories["failed_outcome"]["count"],
},
"categories": categories,
"items": flat_items,
diff --git a/core/profile_review.py b/core/profile_review.py
index 6f26821..d2ca991 100644
--- a/core/profile_review.py
+++ b/core/profile_review.py
@@ -98,7 +98,6 @@
"reviewer",
"business",
"project",
- "shadow",
"custom",
}
@@ -2357,6 +2356,8 @@ def do_POST(self) -> None:
if not goal:
raise ValueError("goal is required")
mode = str(body.get("mode") or "auto")
+ if mode not in ROLE_MODES:
+ raise ValueError("invalid mode")
self.send_json(
self.factory.start_job(
"session",
diff --git a/core/task_compile.py b/core/task_compile.py
index f7bcc6c..0ac489c 100644
--- a/core/task_compile.py
+++ b/core/task_compile.py
@@ -43,7 +43,6 @@
"reviewer": "复核审阅",
"business": "商业判断",
"project": "项目推进",
- "shadow": "影子分身",
"custom": "自定义",
}
diff --git a/docs/ADAPTERS.md b/docs/ADAPTERS.md
index 5114272..54ab3a4 100644
--- a/docs/ADAPTERS.md
+++ b/docs/ADAPTERS.md
@@ -13,9 +13,10 @@ An adapter should only answer three questions:
## Universal Handoff
```text
-Read ~/.immortal/agent/ENTRY.md first. Then run:
-immortal-memory agent-context "" --print
-Use the returned context as task-local memory. Do not read the raw vault by default.
+Read ~/.immortal/agent/ENTRY.md first. Run agent-context with an explicit mode
+to create a preview. Review context_json, then compile the same task with
+--preview-id and --preview-hash. Continue only when lifecycle_status=compiled.
+Do not read the raw vault by default.
```
## Codex
@@ -53,9 +54,9 @@ Optional global instruction snippet:
```text
When a task depends on my personal preferences, history, writing style, project
-judgment, relationships, or long-term memory, run:
-immortal-memory agent-context "" --print
-Then continue using the returned context. Do not read the raw vault by default.
+judgment, relationships, or long-term memory, preview with an explicit mode.
+Review context_json, then compile with --preview-id and --preview-hash.
+Continue only when lifecycle_status=compiled. Do not read the raw vault.
```
## Generic Local Agent
@@ -64,11 +65,11 @@ Any local agent with shell access can call:
```bash
immortal-memory agent-entry
-immortal-memory agent-context "current task" --print
+immortal-memory agent-context "current task" --mode reviewer
immortal-memory recall "topic"
```
-## Future HTTP / MCP Mode
+## HTTP / MCP Mode
The same bridge can be exposed as a local server:
diff --git a/pyproject.toml b/pyproject.toml
index d87bd1f..0ccaa34 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "immortal-memory"
-version = "1.3.1"
+version = "1.3.3"
description = "Local-first personal memory layer for AI agents"
requires-python = ">=3.9"
license = "MIT"
diff --git a/tests/test_agent_bridge_cors.py b/tests/test_agent_bridge_cors.py
index 8c976fc..ba73247 100644
--- a/tests/test_agent_bridge_cors.py
+++ b/tests/test_agent_bridge_cors.py
@@ -10,6 +10,16 @@
class AgentBridgeCorsTest(unittest.TestCase):
+ def test_mcp_server_reports_product_version(self):
+ expected = (Path(agent_bridge_server.__file__).with_name("VERSION")).read_text(
+ encoding="utf-8"
+ ).strip()
+ response = agent_bridge_server.handle_mcp_message(
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
+ )
+
+ self.assertEqual(response["result"]["serverInfo"]["version"], expected)
+
def test_exact_loopback_origins_are_allowed(self):
self.assertTrue(agent_bridge_server.is_allowed_origin("http://localhost:8799"))
self.assertTrue(agent_bridge_server.is_allowed_origin("https://127.0.0.1:443"))
diff --git a/tests/test_control_center_http.py b/tests/test_control_center_http.py
index dc59f00..7726a7f 100644
--- a/tests/test_control_center_http.py
+++ b/tests/test_control_center_http.py
@@ -300,6 +300,10 @@ def test_agent_backup_and_diagnostic_routes_use_controlled_actions(tmp_path):
backup_status, backups = get_json(base + "/api/v1/backups")
diagnostic_status, diagnostics = get_json(base + "/api/v1/diagnostics")
context_status, context_job = post(
+ base + "/api/v1/agent/contexts",
+ {"goal": "prepare customer plan", "mode": "reviewer"},
+ )
+ invalid_mode_status, invalid_mode = post(
base + "/api/v1/agent/contexts",
{"goal": "prepare customer plan", "mode": "plan"},
)
@@ -321,9 +325,45 @@ def test_agent_backup_and_diagnostic_routes_use_controlled_actions(tmp_path):
assert diagnostics["listen_address"] == "127.0.0.1"
assert context_status == 202
assert context_job["kind"] == "session"
+ assert invalid_mode_status == 400
+ assert invalid_mode["error"]["code"] == "invalid_request"
assert injection_status == 400
assert injection["error"]["code"] == "invalid_request"
assert verify_status == 202
assert verify_job["kind"] == "backup_verify"
assert restore_status == 404
assert restore["error"]["code"] == "not_found"
+
+
+def test_agent_capability_modes_match_executable_factory_modes(tmp_path):
+ server, base = start_server(tmp_path)
+ try:
+ status, agent = get_json(base + "/api/v1/agent")
+ finally:
+ server.shutdown()
+ server.server_close()
+
+ assert status == 200
+ assert agent["supported_actions"][0]["modes"] == [
+ "auto",
+ "advisor",
+ "writer",
+ "reviewer",
+ "business",
+ "project",
+ "custom",
+ ]
+
+
+def test_control_center_context_options_use_executable_modes(tmp_path):
+ server, base = start_server(tmp_path)
+ try:
+ status, _, body = get(base + "/control-center")
+ finally:
+ server.shutdown()
+ server.server_close()
+
+ assert status == 200
+ page = body.decode("utf-8")
+ assert 'option value="reviewer"' in page
+ assert 'option value="plan"' not in page
diff --git a/tests/test_control_data.py b/tests/test_control_data.py
index 2780654..9eb2754 100644
--- a/tests/test_control_data.py
+++ b/tests/test_control_data.py
@@ -1,11 +1,12 @@
from __future__ import annotations
import json
+import os
import sqlite3
import threading
import urllib.error
import urllib.request
-from datetime import datetime
+from datetime import datetime, timezone
import pytest
@@ -226,6 +227,202 @@ def fake_run_script(script, args=None):
}
+def test_agent_context_forwards_review_lifecycle_arguments(monkeypatch):
+ captured = {}
+
+ def fake_run_script(script, args=None):
+ captured["script"] = script
+ captured["args"] = args
+ return 0
+
+ monkeypatch.setattr(immortal, "run_script", fake_run_script)
+ args = immortal.build_parser().parse_args(
+ [
+ "agent-context",
+ "review task",
+ "--mode",
+ "reviewer",
+ "--preview-id",
+ "prv_one",
+ "--preview-hash",
+ "sha256:" + "1" * 64,
+ "--exclude-item-id",
+ "clm_private",
+ "--ttl-seconds",
+ "600",
+ "--metadata-output",
+ "/tmp/immortal-context.json",
+ "--print",
+ ]
+ )
+
+ assert immortal.command_agent_context(args) == 0
+ assert captured == {
+ "script": "agent_bridge.py",
+ "args": [
+ "context",
+ "review task",
+ "--since",
+ "2026-03-01",
+ "--timeout",
+ "240",
+ "--mode",
+ "reviewer",
+ "--preview-id",
+ "prv_one",
+ "--preview-hash",
+ "sha256:" + "1" * 64,
+ "--exclude-item-id",
+ "clm_private",
+ "--ttl-seconds",
+ "600",
+ "--metadata-output",
+ "/tmp/immortal-context.json",
+ "--print",
+ ],
+ }
+
+
+def test_health_uses_judgment_build_state_not_retired_cards_file(
+ tmp_path, monkeypatch, capsys
+):
+ now = datetime.now(timezone.utc).isoformat()
+ state = {
+ "total_records": 1,
+ "errors": [],
+ "last_obsidian_sync_status": "ok",
+ "last_portable_restore_check_status": "ok",
+ "last_portable_restore_check_files": 1,
+ "last_portable_restore_check_dir": str(tmp_path / "export"),
+ }
+ for key in (
+ "last_collect",
+ "last_feishu_collect",
+ "last_feishu_clean",
+ "last_feishu_distill",
+ "last_profile",
+ "last_profile_nuwa",
+ "last_people_index",
+ "last_relationship_index",
+ "last_quality",
+ "last_profile_attribution_audit",
+ "last_feishu_mirror_inventory",
+ "last_feishu_mirror_download",
+ "last_agent_entry",
+ "last_web_collect",
+ "last_obsidian_sync",
+ "last_cards_build",
+ "last_portable_restore_check",
+ ):
+ state[key] = now
+
+ files = {
+ "STATE_FILE": ("state.json", state),
+ "QUALITY_JSON": (
+ "quality.json",
+ {"status": "ok", "score": 100, "issue_count": 0},
+ ),
+ "WEB_STATE_JSON": (
+ "web.json",
+ {"status": "ok", "totals": {"errors": 0}},
+ ),
+ "OBSIDIAN_STATUS_JSON": (
+ "obsidian.json",
+ {"status": "ok", "broken_links_count": 0},
+ ),
+ "RELATIONSHIP_JSON": (
+ "relationships.json",
+ {"summary": {"person_edges": 1, "project_edges": 1}},
+ ),
+ }
+ for name, (filename, payload) in files.items():
+ path = tmp_path / filename
+ path.write_text(json.dumps(payload), encoding="utf-8")
+ monkeypatch.setattr(immortal, name, path)
+
+ fresh = tmp_path / "fresh.txt"
+ fresh.write_text("x" * 2048, encoding="utf-8")
+ for name in (
+ "QUALITY_MD",
+ "PROFILE_ATTRIBUTION_AUDIT_JSON",
+ "PROFILE_NUWA_JSON",
+ "PROFILE_NUWA_MD",
+ "AGENT_ENTRY_MD",
+ "AGENT_ENTRY_JSON",
+ "FEISHU_CLEAN_COVERAGE",
+ "FEISHU_DISTILLED_COVERAGE",
+ "FEISHU_DRIVE_MIRROR_COVERAGE",
+ ):
+ monkeypatch.setattr(immortal, name, fresh)
+ retired = tmp_path / "cards_compact.md"
+ retired.write_text("legacy" * 100, encoding="utf-8")
+ os.utime(retired, (1, 1))
+ monkeypatch.setattr(immortal, "CARDS_COMPACT_MD", retired, raising=False)
+ monkeypatch.setattr(immortal, "check_crontab", lambda: (True, ["scheduler"]))
+ monkeypatch.setattr(
+ immortal,
+ "get_backup_status",
+ lambda _vault: {
+ "ok": True,
+ "mode": "manifest-only",
+ "latest_export": {
+ "generated_at": now,
+ "export_dir": str(tmp_path / "export"),
+ "totals": {"files": 1, "bytes": 1},
+ },
+ },
+ )
+
+ code = immortal.command_health(type("Args", (), {"max_age_hours": 30})())
+ output = capsys.readouterr().out
+
+ assert code == 0
+ assert "OK 判断力卡片盒" in output
+ assert "FAIL 判断力卡片盒" not in output
+
+
+def test_raw_context_does_not_inject_retired_cards_cache(
+ tmp_path, monkeypatch, capsys
+):
+ missing = tmp_path / "missing"
+ state = tmp_path / "state.json"
+ state.write_text(
+ json.dumps({"last_collect": "2026-07-28T00:00:00Z", "total_records": 1}),
+ encoding="utf-8",
+ )
+ retired = tmp_path / "cards_compact.md"
+ retired.write_text("RETIRED_UNREVIEWED_JUDGMENT", encoding="utf-8")
+ monkeypatch.setattr(immortal, "STATE_FILE", state)
+ monkeypatch.setattr(immortal, "CARDS_COMPACT_MD", retired, raising=False)
+ for name in (
+ "PROFILE_COMPACT_MD",
+ "PROFILE_MD",
+ "SOUL_FILE",
+ "PROFILE_NUWA_MD",
+ "PEOPLE_MD",
+ ):
+ monkeypatch.setattr(immortal, name, missing)
+
+ code = immortal.command_context(
+ type(
+ "Args",
+ (),
+ {
+ "query": "task",
+ "since": "2026-03-01",
+ "with_recall": False,
+ "profile_lines": 10,
+ "nuwa_lines": 10,
+ "digest_lines": 10,
+ "people_lines": 10,
+ },
+ )()
+ )
+
+ assert code == 0
+ assert "RETIRED_UNREVIEWED_JUDGMENT" not in capsys.readouterr().out
+
+
def test_v1_overview_reuses_truth_snapshot(tmp_path):
server, base = start_server(tmp_path)
try:
diff --git a/tests/test_learning_review.py b/tests/test_learning_review.py
new file mode 100644
index 0000000..ada7b75
--- /dev/null
+++ b/tests/test_learning_review.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+import importlib
+import importlib.util
+import json
+import subprocess
+import tempfile
+from datetime import datetime, timezone
+from pathlib import Path
+from unittest import mock
+
+
+def load_module():
+ assert importlib.util.find_spec("learning_review") is not None, "learning_review module is missing"
+ return importlib.import_module("learning_review")
+
+
+class FakeStore:
+ def __init__(self, rows):
+ self.rows = rows
+
+ def list(self):
+ return list(self.rows)
+
+
+def test_empty_vault_is_a_read_only_zero_candidate_review(tmp_path):
+ module = load_module()
+
+ report = module.build_review(tmp_path)
+
+ assert report["counts"] == {"total": 0, "claims": 0, "judgments": 0, "visible": 0}
+ assert list(tmp_path.rglob("*")) == []
+
+
+def test_tmp_alias_is_canonicalized_before_opening_event_stores():
+ module = load_module()
+ with tempfile.TemporaryDirectory(dir="/tmp") as raw:
+ vault = Path(raw)
+
+ report = module.build_review(vault)
+
+ assert report["counts"]["total"] == 0
+ assert list(vault.rglob("*")) == []
+
+
+def test_review_is_bounded_redacted_and_counts_all_candidates(tmp_path):
+ module = load_module()
+ claims = [
+ {
+ "claim_id": "clm_%02d" % index,
+ "statement": "候选 %d sk-abcdefghijklmnop /Users/example/private" % index,
+ "status": "candidate" if index < 3 else "confirmed",
+ "revision": 1,
+ "updated_at": "2026-07-2%dT08:00:00+00:00" % (index + 1),
+ }
+ for index in range(4)
+ ]
+ judgments = [
+ {
+ "card_id": "jud_01",
+ "title": "待确认判断",
+ "status": "candidate",
+ "revision": 2,
+ "updated_at": "2026-07-27T09:00:00+00:00",
+ }
+ ]
+ with mock.patch.object(module, "ClaimStore", return_value=FakeStore(claims)), mock.patch.object(
+ module, "JudgmentStore", return_value=FakeStore(judgments)
+ ):
+ report = module.build_review(
+ tmp_path,
+ limit=2,
+ clock=lambda: datetime(2026, 7, 28, tzinfo=timezone.utc),
+ )
+
+ assert report["counts"] == {"total": 4, "claims": 3, "judgments": 1, "visible": 2}
+ assert [item["kind"] for item in report["items"]] == ["judgment", "claim"]
+ serialized = json.dumps(report, ensure_ascii=False)
+ assert "sk-abcdefghijklmnop" not in serialized
+ assert "/Users/example" not in serialized
+ assert "evidence" not in serialized
+
+
+def test_feishu_send_is_owner_only_confirmed_and_shell_free(tmp_path):
+ module = load_module()
+ report = {
+ "generated_at": "2026-07-28T00:00:00+00:00",
+ "counts": {"total": 1, "claims": 1, "judgments": 0, "visible": 1},
+ "items": [{"kind": "claim", "summary": "候选理解"}],
+ "review_url": "http://127.0.0.1:8765/?view=home",
+ }
+ completed = subprocess.CompletedProcess(
+ ["lark-cli"],
+ 0,
+ stdout=json.dumps({"ok": True, "data": {"message_id": "om_test", "chat_id": "oc_test"}}),
+ stderr="",
+ )
+ with mock.patch.object(module.shutil, "which", return_value="/usr/local/bin/lark-cli"), mock.patch.object(
+ module.subprocess, "run", return_value=completed
+ ) as run:
+ receipt = module.send_to_feishu(report, "ou_owner", dry_run=False)
+
+ args = run.call_args.args[0]
+ assert args[:3] == ["/usr/local/bin/lark-cli", "im", "+messages-send"]
+ assert ["--as", "bot"] == args[3:5]
+ assert args[args.index("--user-id") + 1] == "ou_owner"
+ assert "--markdown" in args
+ assert run.call_args.kwargs.get("shell") is not True
+ assert receipt["message_id"] == "om_test"
+
+
+def test_main_refuses_unconfirmed_remote_write(tmp_path):
+ module = load_module()
+ with mock.patch.object(module, "send_to_feishu") as send:
+ code = module.main(
+ [
+ "--vault-dir",
+ str(tmp_path),
+ "--send-feishu",
+ ]
+ )
+
+ assert code == 2
+ send.assert_not_called()
+
+
+def test_top_level_cli_forwards_learning_review_options():
+ import immortal
+
+ args = mock.Mock(
+ vault_dir="/tmp/vault",
+ limit=5,
+ json=True,
+ send_feishu=True,
+ dry_run=True,
+ confirm_remote_write=False,
+ )
+ with mock.patch.object(immortal, "run_script", return_value=0) as run:
+ code = immortal.command_learning_review(args)
+
+ assert code == 0
+ run.assert_called_once_with(
+ "learning_review.py",
+ [
+ "--vault-dir",
+ "/tmp/vault",
+ "--limit",
+ "5",
+ "--json",
+ "--send-feishu",
+ "--dry-run",
+ ],
+ )
diff --git a/tests/test_packaging.py b/tests/test_packaging.py
index 1098fd5..b020989 100644
--- a/tests/test_packaging.py
+++ b/tests/test_packaging.py
@@ -23,6 +23,23 @@
class PackagingTest(unittest.TestCase):
+ def test_agent_adapters_document_preview_then_approved_compile(self) -> None:
+ paths = [
+ ROOT / "adapters" / "codex" / "skills" / "immortal-memory" / "SKILL.md",
+ ROOT
+ / "adapters"
+ / "claude-code"
+ / "skills"
+ / "immortal-memory"
+ / "SKILL.md",
+ ROOT / "adapters" / "claude-code" / "CLAUDE.md",
+ ]
+ for path in paths:
+ text = path.read_text(encoding="utf-8")
+ self.assertIn("--preview-id", text, msg=str(path))
+ self.assertIn("--preview-hash", text, msg=str(path))
+ self.assertIn("lifecycle_status=compiled", text, msg=str(path))
+
def test_cli_command_shell_quotes_each_argument(self) -> None:
hostile_path = "/tmp/$(touch /tmp/immortal-command-injection)"
command = cli_command("restore-check", hostile_path)
diff --git a/tests/test_product_data.py b/tests/test_product_data.py
index adf584e..476114d 100644
--- a/tests/test_product_data.py
+++ b/tests/test_product_data.py
@@ -467,6 +467,12 @@ def test_home_leads_with_memory_value_not_machine_metrics(tmp_path):
"latest_outcome",
]
assert home["system_health"]["status"] == "healthy"
+ assert home["confirmation_summary"] == {
+ "total": 2,
+ "claims": 1,
+ "judgments": 1,
+ "visible": 2,
+ }
assert center.calls == 1
assert "private-token" not in json.dumps(home)
@@ -727,12 +733,39 @@ def test_trust_surfaces_bounded_evidence_gaps_privacy_and_candidates(tmp_path):
data, _control, _center = seeded_product_data(tmp_path)
trust = data.trust()
assert trust["summary"]["needs_confirmation"] >= 2
+ assert trust["summary"]["candidate_claims"] == 1
+ assert trust["summary"]["candidate_judgments"] == 1
kinds = {item["kind"] for item in trust["items"]}
assert {"missing_evidence", "low_confidence", "privacy_exclusion"} <= kinds
assert len(trust["items"]) <= 50
assert "private-token" not in json.dumps(trust)
+def test_trust_surfaces_challenged_context_refs_without_mutating_memory(tmp_path):
+ data, _control, _center = seeded_product_data(tmp_path)
+ data.outcome_store.list = lambda: [
+ {
+ "outcome_id": "out_" + "1" * 32,
+ "context_id": "ctx_" + "1" * 32,
+ "result": "negative",
+ "summary": "实际执行失败",
+ "challenged_refs": [
+ {"kind": "claim", "id": "clm_confirmed", "revision": 3}
+ ],
+ "created_at": "2026-07-22T00:40:00+00:00",
+ }
+ ]
+
+ trust = data.trust()
+
+ assert trust["summary"]["challenged_memories"] == 1
+ item = trust["categories"]["failed_outcome"]["items"][0]
+ assert item["id"] == "clm_confirmed"
+ assert item["kind"] == "failed_outcome"
+ assert "实际执行失败" in item["summary"]
+ assert data.claim_store.get("clm_confirmed")["status"] == "confirmed"
+
+
def test_trust_does_not_mislabel_user_exclusion_as_privacy_policy(tmp_path):
data, _control, _center = seeded_product_data(tmp_path)
contexts = data.context_store.list()
@@ -1407,6 +1440,7 @@ def test_trust_has_all_spec_categories_with_counts_and_coverage(tmp_path):
"privacy_exclusion",
"recent_correction",
"model_evaluation",
+ "failed_outcome",
}
assert set(trust["categories"]) == expected
for category in trust["categories"].values():
diff --git a/tests/test_product_ui_v2.py b/tests/test_product_ui_v2.py
index 8daa23f..501f83e 100644
--- a/tests/test_product_ui_v2.py
+++ b/tests/test_product_ui_v2.py
@@ -198,6 +198,11 @@ def test_use_ui_has_preview_compile_consume_and_outcome_states():
assert "navigator.clipboard.writeText" in page
assert "已复制,尚未标记为已交给 Agent" in page
assert "标记为已交给 Agent" in page
+ assert "这条记忆实际表现如何" in page
+ assert "confirmed_refs" in page
+ assert "challenged_refs" in page
+ assert "标记为需复核" in page
+ assert "支持 ${confirmed} 条,需复核 ${challenged} 条" in page
def test_home_ui_can_confirm_or_reject_candidate_claims():
@@ -207,6 +212,8 @@ def test_home_ui_can_confirm_or_reject_candidate_claims():
assert 'reviewClaim(item, "reject"' in home
assert "expected_version: item.revision" in home
assert "createMutationAttempt" in home
+ assert "confirmation_summary?.total" in home
+ assert "当前展示" in home
def test_judgment_and_trust_are_real_and_coverage_honest():
@@ -216,8 +223,15 @@ def test_judgment_and_trust_are_real_and_coverage_honest():
assert "/actions" in judgments
assert "revision" in judgments
assert "/api/v2/trust" in trust
+ assert "任务结果提出复核" in trust
+ assert "challenged_memories" in trust
for state in ("complete", "partial", "unknown", "truncated"):
assert state in trust
+ assert 'document.createElement("details")' in trust
+ assert "审核候选理解" in trust
+ assert 'navigate("home")' in trust
+ assert "审核候选判断" in trust
+ assert 'navigate("judgments")' in trust
assert "window.confirm" not in judgments
assert "innerHTML" not in judgments
assert "innerHTML" not in trust
diff --git a/tests/test_runtime_resilience.py b/tests/test_runtime_resilience.py
index 9e48e12..05a33b6 100644
--- a/tests/test_runtime_resilience.py
+++ b/tests/test_runtime_resilience.py
@@ -28,6 +28,106 @@ def test_disappearing_file_is_skipped_without_dedup_pollution(self):
self.assertEqual(existing, set())
+class AgentEntryAuthorityTest(unittest.TestCase):
+ def test_command_entry_writes_approved_compile_prompt(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ entry_md = root / "ENTRY.md"
+ entry_json = root / "entry.json"
+ claude_prompt = root / "claude-code-prompt.txt"
+ meta = {
+ "generated_at": "2026-07-28T00:00:00+08:00",
+ "owner": "Owner",
+ "vault_status": "healthy",
+ "context_status": "ready",
+ "loss_protection": "unprotected",
+ "real_record_count": 1,
+ "total_records": 1,
+ "quality_status": "ok",
+ "quality_score": 100,
+ "quality_issues": 0,
+ "last_collect": "2026-07-28T00:00:00Z",
+ "last_feishu_collect": "2026-07-28T00:00:00Z",
+ "paths": {},
+ "commands": {
+ "health": "immortal-memory health",
+ "agent_context": 'immortal-memory agent-context ""',
+ "recall": 'immortal-memory recall ""',
+ "context": 'immortal-memory context ""',
+ },
+ "local_urls": {},
+ }
+ with mock.patch.object(agent_bridge, "AGENT_DIR", root), \
+ mock.patch.object(agent_bridge, "ENTRY_MD", entry_md), \
+ mock.patch.object(agent_bridge, "ENTRY_JSON", entry_json), \
+ mock.patch.object(agent_bridge, "CLAUDE_PROMPT", claude_prompt), \
+ mock.patch.object(agent_bridge, "bridge_metadata", return_value=meta):
+ code = agent_bridge.command_entry(argparse.Namespace())
+
+ self.assertEqual(code, 0)
+ prompt = claude_prompt.read_text(encoding="utf-8")
+ self.assertIn("--preview-id", prompt)
+ self.assertIn("--preview-hash", prompt)
+ self.assertIn("lifecycle_status=compiled", prompt)
+
+ def test_entry_explains_preview_then_approved_compile(self):
+ meta = {
+ "generated_at": "2026-07-28T00:00:00+08:00",
+ "owner": "Owner",
+ "vault_status": "healthy",
+ "context_status": "ready",
+ "loss_protection": "unprotected",
+ "real_record_count": 1,
+ "total_records": 1,
+ "quality_status": "ok",
+ "quality_score": 100,
+ "quality_issues": 0,
+ "last_collect": "2026-07-28T00:00:00Z",
+ "last_feishu_collect": "2026-07-28T00:00:00Z",
+ "paths": {},
+ "commands": {
+ "health": "immortal-memory health",
+ "agent_context": 'immortal-memory agent-context ""',
+ "recall": 'immortal-memory recall ""',
+ "context": 'immortal-memory context ""',
+ },
+ "local_urls": {},
+ }
+
+ text = agent_bridge.render_entry(meta)
+
+ self.assertIn("--preview-id", text)
+ self.assertIn("--preview-hash", text)
+ self.assertIn("lifecycle_status=compiled", text)
+
+ def test_entry_points_to_authoritative_judgment_store(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ vault = Path(tmp)
+ with mock.patch.object(agent_bridge, "IMMORTAL_DIR", vault), \
+ mock.patch.object(agent_bridge, "read_json", return_value={}), \
+ mock.patch.object(agent_bridge, "owner_display_name", return_value="Owner"), \
+ mock.patch.object(
+ agent_bridge,
+ "gather_preflight",
+ return_value={
+ "vault_status": "healthy",
+ "context_status": "ready",
+ "loss_protection": "unprotected",
+ "real_record_count": 1,
+ },
+ ):
+ paths = agent_bridge.bridge_metadata()["paths"]
+
+ self.assertEqual(
+ paths["judgments_current"], str(vault / "judgment" / "current.jsonl")
+ )
+ self.assertEqual(
+ paths["judgment_evaluations"],
+ str(vault / "judgment" / "evaluations.jsonl"),
+ )
+ self.assertFalse(any("/cards/" in value for value in paths.values()))
+
+
class ProcessGroupTimeoutTest(unittest.TestCase):
@unittest.skipIf(os.name == "nt", "POSIX process-group behavior")
def test_timeout_terminates_grandchild_process(self):
@@ -287,6 +387,12 @@ def test_task_compile_parser_exposes_reviewable_preview_and_compile(self):
self.assertEqual(compile_args.preview_id, "prv_one")
self.assertEqual(compile_args.preview_hash, "sha256:" + "1" * 64)
+ def test_task_compile_rejects_removed_shadow_context_mode(self):
+ with self.assertRaises(SystemExit):
+ task_compile.build_parser().parse_args(
+ ["preview", "review plan", "--mode", "shadow"]
+ )
+
def test_naked_task_compile_returns_preview_without_formal_session(self):
with tempfile.TemporaryDirectory() as tmp:
sessions = Path(tmp) / "sessions"
diff --git a/tests/test_version.py b/tests/test_version.py
index 6290f75..f544e61 100644
--- a/tests/test_version.py
+++ b/tests/test_version.py
@@ -32,11 +32,11 @@ def test_source_versions_and_cli_are_consistent(self):
immortal.main(["--version"])
self.assertEqual(raised.exception.code, 0)
- self.assertEqual(version, "1.3.1")
+ self.assertEqual(version, "1.3.3")
self.assertIsNotNone(project_version)
self.assertEqual(project_version.group(1), version)
self.assertIn(f"version-v{version}-", readme)
- self.assertEqual(output.getvalue().strip(), "immortal 1.3.1")
+ self.assertEqual(output.getvalue().strip(), "immortal 1.3.3")
setup_version = subprocess.run(
[sys.executable, "setup.py", "--version"],