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 @@
-![Version](https://img.shields.io/badge/version-v1.3.1-111827.svg) +![Version](https://img.shields.io/badge/version-v1.3.3-111827.svg) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB.svg?logo=python&logoColor=white) ![Platform](https://img.shields.io/badge/platform-Codex%20%7C%20Claude%20Code%20%7C%20Local%20Agent-0F766E.svg) ![License](https://img.shields.io/badge/license-MIT-059669.svg) @@ -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` : '读取页面不会自动生成文件。'}

-
CONTEXT COMPILER${tag('ready')}

生成任务上下文

只接受目标和固定模式,不接受命令字符串。

+
CONTEXT COMPILER${tag('ready')}

生成任务上下文

只接受目标和固定模式,不接受命令字符串。

最近上下文

这里只展示元数据,不返回完整 vault。

${rows || '
还没有任务上下文
'}
`; 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"],