diff --git a/.env.example b/.env.example index 49be85a..fb4fab8 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ MINICODE_KEEP_RECENT_TURNS=4 MINICODE_TOOL_RESULT_TOKENS=1500 MINICODE_SUMMARY_TOKENS=1200 +# 生命周期元数据默认写入工作区 .minicode-rebuild/events.jsonl。 +# 日志不会保存提示、工具参数、工具输出或 API Key。 + # 使用 DeepSeek 时填写这一项;使用其他 OpenAI-compatible 服务时, # 可以改为填写 OPENAI_API_KEY。若两者同时存在,OPENAI_API_KEY 优先。 DEEPSEEK_API_KEY= diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..5018064 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,27 @@ +name: quality + +on: + push: + branches: [master, rebuild/minicode-learning] + pull_request: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.13"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e ".[dev]" + - run: python scripts/release_check.py diff --git a/.gitignore b/.gitignore index 0579b52..5429f63 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ htmlcov/ build/ dist/ *.egg-info/ +.coverage.* # Virtual environments .venv/ diff --git a/README.md b/README.md index e05d4f2..bcdc249 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MiniCode Rebuild 是一个从零、分阶段实现的本地终端 AI Coding Agen ## 当前状态 -阶段 0“仓库初始化与工程基线”至阶段 8“会话、Checkpoint 与 Rewind”已经完成。 +阶段 0“仓库初始化与工程基线”至阶段 10“可观测性、质量与发布准备”已经完成。 目前已经具备: @@ -33,9 +33,42 @@ MiniCode Rebuild 是一个从零、分阶段实现的本地终端 AI Coding Agen - 在工作区内持久化会话、统计和完整 transcript,并支持跨进程恢复; - 在内置文件工具修改前记录 Checkpoint,先预览、再确认 Rewind; - 使用修改后哈希阻止 Rewind 覆盖 Agent 之后发生的外部编辑; +- 扫描工作区 `.minicode/skills//SKILL.md`,仅注入有界元数据,并通过 `load_skill` 按需加载正文; +- 在 Agent、会话与工具边界注册进程内 Hooks,隔离并显式报告 Hook 失败; +- 将脱敏生命周期元数据写入工作区 JSONL 日志,并通过时间线查看运行过程; +- 离线检查 Python、运行配置、Provider 配置、会话存储与 Skills readiness; +- 使用 Ruff、Mypy、分支覆盖率、构建、安装和跨平台 CI 作为发布质量门禁; - 执行自动化测试。 -真实模型适配器、工具注册表、安全工作区工具、Agent Loop、上下文管理和会话恢复已经接入 CLI。下一阶段将继续扩展记忆与检索能力。 +阶段 0 至阶段 10 的基础路线已经完成。后续高级能力必须从阶段 11 清单中单独选择、设计、测试和提交。 + +## 可观测性与 Readiness + +每次 CLI 会话默认把生命周期元数据追加到工作区 `.minicode-rebuild/events.jsonl`。日志只包含时间、事件名、session ID、工具名、成功状态、错误代码和停止原因;不保存用户提示、工具参数、工具输出或 API Key。该目录已从 Git 和模型通用文件工具中隔离。 + +查看最近 100 条脱敏事件: + +```bash +minicode-rebuild --timeline +minicode-rebuild --timeline 20 +``` + +交互模式可以使用 `/timeline`。离线检查 Provider 与本地运行条件: + +```bash +minicode-rebuild --readiness +minicode-rebuild --demo --readiness +``` + +`--readiness` 不会向 Provider 发送请求或验证余额,只检查本地配置结构。普通模式缺少 API Key 时返回非零状态;`--demo --readiness` 不要求 Key。 + +## Skills 与 Hooks + +项目 Skill 放在工作区的 `.minicode/skills//SKILL.md`。目录名使用字母、数字、点、下划线或连字符;可选 frontmatter 中的 `name` 必须与目录名一致。运行时只把名称和描述放入系统提示,模型判断相关后才调用只读的 `load_skill` 工具加载单个正文。交互模式可用 `/skills` 查看当前发现结果。 + +Hooks 是供 Python 嵌入方使用的进程内扩展点。`HookManager` 支持 `agent_start`、`agent_stop`、`session_create`、`session_resume`、`session_save`、`before_tool` 和 `after_tool`。Handler 收到只读数据快照;普通异常不会中断 Agent 或工具,但会写入 Hook report,并由默认终端运行时显示 `[hook:error]`。Hooks 不绕过工作区、权限、Checkpoint 或工具参数校验。 + +本阶段不加载任意 Hook 配置或外部脚本,也不实现 MCP;这些能力需要独立威胁模型和验收标准。 ## 环境要求 @@ -285,6 +318,30 @@ print(result.stop_reason.value, result.content) python -m pytest -q ``` +完整质量门禁: + +```bash +python scripts/release_check.py +``` + +它依次运行 Ruff、Mypy、分支覆盖率测试、`compileall`、使用当前已安装构建依赖的 sdist/wheel 构建和无网络 MockModel 演示。当前覆盖率门槛为 85%。GitHub Actions 会在 Windows 与 Ubuntu、Python 3.11 与 3.13 上执行相同门禁。 + +只运行可复现演示: + +```bash +python scripts/demo.py +``` + +## 跨平台与发布检查清单 + +- Windows 使用 `\.venv\Scripts\python.exe`,macOS/Linux 使用 `./.venv/bin/python`;项目业务命令仍通过参数数组和 `shell=False` 执行。 +- 两个符号链接安全测试在未授予 Windows 创建符号链接权限时会跳过;CI 的 Ubuntu 任务覆盖该路径。 +- 终端输出、Skill、会话和事件日志统一使用 UTF-8;Windows 文件替换与权限位行为已有平台保护。 +- 发布前确认 Ruff、Mypy、覆盖率测试、编译、构建、全新环境 wheel 安装和 Mock 演示全部通过。 +- 检查 Git diff 中没有 `.env`、API Key、会话、事件日志、缓存、构建产物或无关目录。 +- 核对 README、`--help`、版本号、Python 版本范围、PR 测试结果和实际行为一致。 +- 真实 Provider 验收需由用户自行提供有效 Key;默认质量门禁不发起计费请求。 + ## 开发原则 - 从零实现,不整体复制参考项目。 diff --git a/docs/REBUILD_LOG.md b/docs/REBUILD_LOG.md index e0ff880..6306391 100644 --- a/docs/REBUILD_LOG.md +++ b/docs/REBUILD_LOG.md @@ -8,12 +8,12 @@ | 项目 | 内容 | |---|---| -| 当前阶段 | 阶段 9:记忆与检索(待开始) | -| 最近完成 | 阶段 8:会话、Checkpoint 与 Rewind | +| 当前阶段 | 阶段 11:可选高级能力(待选择) | +| 最近完成 | 阶段 10:可观测性、质量与发布准备 | | 当前分支 | `rebuild/minicode-learning` | -| 最新阶段实现提交 | `b4afec3 feat(phase-08): add sessions checkpoints and rewind` | -| 测试状态 | 阶段 8 相关测试 `93 passed, 1 skipped`;全量回归 `224 passed, 2 skipped` | -| 下一步 | 分析阶段 9 的记忆提取、存储、检索与注入边界 | +| 最新阶段实现提交 | `edf1569 chore(phase-10): add readiness checks and release verification` | +| 测试状态 | 阶段 10 相关测试 `37 passed`;全量回归 `244 passed, 2 skipped`;分支覆盖率 `85.08%` | +| 下一步 | 从阶段 11 清单中选择一个独立高级能力,不打包推进 | ## 总体架构 @@ -1583,4 +1583,146 @@ passed ### 9. 下一阶段 -阶段 9 将在持久化会话之上增加可解释的记忆提取与检索,但不会把完整 transcript 无筛选地注入模型。开始前需要定义记忆来源、去重、相关性、时效性、工作区隔离和用户可见的删除边界。 +阶段 9 按主执行规范实现 Skills、Hooks 与扩展机制。长期记忆与检索仍属于阶段 11 的可选高级能力,不在基础能力稳定前提前实现。 + +## 阶段 9:Skills、Hooks 与扩展机制 + +### 1. 阶段目标与非目标 + +- 扫描工作区 `.minicode/skills//SKILL.md`,提供可解释的名称、描述和路径。 +- 系统提示只注入有界目录;完整 Skill 正文由 `load_skill` 工具按名称、按需加载。 +- 提供 Agent 开始/停止、会话创建/恢复/保存和工具执行前/后的同步生命周期 Hook。 +- Hook 普通异常必须隔离、记录并对终端用户可见,不能静默改变主流程结果。 +- 不从磁盘自动执行 Hook 脚本,不加载 Python 插件,不给予 Hook 额外权限,不实现 MCP。 + +### 2. 参考分析与取舍 + +参考 `D:\code\MiniCode-Python\minicode\skills.py`、`tools/load_skill.py` 与 `hooks.py`。参考实现同时扫描用户级和兼容目录,并支持异步/外部脚本 Hook;本阶段缩小为工作区单一来源和进程内同步注册,避免用户主目录隐式输入、脚本执行与事件循环复杂度。Skills 采用渐进加载:发现阶段读取文件以提取元数据,但不把正文放入模型请求;只有模型明确调用 `load_skill` 才返回单个正文。 + +### 3. 模块与扩展边界 + +- `skills.py`:安全名称、128 KiB 单文件上限、100 个目录上限、UTF-8 校验、frontmatter 名称一致性和 workspace realpath 守卫。 +- `tools/skills.py`:唯一模型可见的 `load_skill` 入口;未知、越界或损坏 Skill 返回稳定 `skill_error`。 +- `hooks.py`:无全局单例的 `HookManager`、只读深拷贝 `HookContext`、`HookReport` 与明确失败列表。 +- `ToolRegistry`:在已完成参数校验后触发 `before_tool`,工具结束或普通执行异常规范化后触发 `after_tool`;Hook 不修改参数或结果。 +- `AgentSession`:负责生命周期事件、技能目录系统提示、Hook 错误终端展示;核心 `run_agent_turn()` 未依赖 Skills 或 Hooks。 + +`.minicode` 与 `.minicode-rebuild` 一样被内置通用读写/搜索工具保留。模型只能通过受限 `load_skill` 阅读 Skill,不能用 `read_file` 绕过按需加载,也不能通过写工具篡改运行中指令。Hooks 仅能由可信 Python 装配层显式注册,未引入自动发现或任意代码执行。 + +### 4. Hook 事件契约 + +| 事件 | 触发位置 | 可见数据 | +|---|---|---| +| `session_create` / `session_resume` | 默认会话装配完成 | session ID | +| `agent_start` | 每个用户轮次进入循环前 | session ID、用户输入 | +| `before_tool` | 工具存在且参数 schema 验证通过后 | 工具名、参数深拷贝 | +| `after_tool` | 工具结果完成规范化与截断后 | 工具名、结构化结果 | +| `session_save` | 会话原子保存成功后 | session ID | +| `agent_stop` | 结果保存完成后 | stop reason、completed | + +未知工具和参数验证失败不会触发工具 Hook,因为没有进入具体工具执行边界。`KeyboardInterrupt` 与 `SystemExit` 等 `BaseException` 继续传播,避免 Hook 或工具吞掉进程控制信号。 + +### 5. 验收与验证 + +- [x] 可发现有效 `SKILL.md`,系统提示只包含名称和描述。 +- [x] `load_skill` 每次只加载显式命名的单个正文。 +- [x] 目录穿越、frontmatter 名称不一致、越界路径和保留目录通用访问被拒绝。 +- [x] Agent、会话和工具生命周期事件可由嵌入方注册。 +- [x] Hook 失败不阻断后续 Hook、工具或 Agent,并生成可见错误报告。 +- [x] 核心 Agent Loop 未反向依赖扩展模块。 +- [x] MCP 明确延后为独立阶段。 + +阶段相关测试覆盖 Skills 扫描/加载/隔离、Hook 顺序/失败/工具边界、系统提示渐进注入和终端错误展示。阶段相关回归为 `98 passed, 1 skipped`,全量回归为 `234 passed, 2 skipped`;两个 skip 均为 Windows 当前环境无法创建符号链接。`python -m compileall -q src tests` 与 `git diff --check` 通过。 + +### 6. 风险、限制与下一阶段 + +Skill frontmatter 只解析本阶段所需的单行 `name` 与 `description`,不是通用 YAML;损坏或过大的 Skill 在扫描中跳过,显式加载时返回错误。同步 Hook 应保持快速,阶段 10 将通过时间线与结构化日志提高耗时可见性。Hook 注册是编程接口,不是面向不可信项目代码的自动插件系统。 + +阶段 10 将完成可观测性、质量与发布准备,包括结构化日志、运行时间线、Provider readiness、lint/type check、安装验证、跨平台说明、演示脚本与发布检查清单。 + +### 7. Git 记录 + +- 分支:`rebuild/minicode-learning` +- 实现提交:`6201245` +- 提交信息:`feat(phase-09): add skills and lifecycle hooks` +- 文档收口提交:`28e5dc9 docs(phase-09): mark phase complete`。 +- 推送前复核发现 PR #3 已由用户合并至 `master`;阶段 9 提交位于其后的开发分支,将单独进入新的 Draft PR,不自动合并 `master`。 + +## 阶段 10:可观测性、质量与发布准备 + +### 1. 阶段目标与非目标 + +- 用结构化、可解析的工作区事件日志记录 Agent、Session 与 Tool 生命周期。 +- 提供终端运行时间线和不访问网络的 Provider readiness 检查。 +- 把 Ruff、Mypy、分支覆盖率、编译、构建和 Mock 演示固化为一条发布门禁。 +- 通过 GitHub Actions 覆盖 Windows/Ubuntu 与 Python 3.11/3.13。 +- 实际验证 editable 安装、控制台入口、sdist/wheel 和无密钥演示。 +- 不在默认门禁中调用真实 Provider,不发布 PyPI,不自动创建 Release 或合并主分支。 + +### 2. 参考分析与取舍 + +参考 MiniCode Python 的 readiness surface、session replay 和 Provider 配置验证,只提取适合当前同步 CLI 的小边界。参考项目的时间线已混合更多控制器、记忆和任务图;本项目直接复用阶段 9 Hooks,把观察能力实现为可替换的事件接收器,避免再次修改核心 Agent Loop。 + +Provider readiness 被定义为“本地配置可构造”,而不是“远程服务一定可用”。它检查 Python、RuntimeSettings、ModelSettings、SessionStore 与 SkillCatalog,不做 DNS、认证或模型可用性探测,因而不会泄露 Key 或产生费用。 + +### 3. 可观测性设计与隐私边界 + +`EventLog` 将一行一个 JSON 对象追加到 `.minicode-rebuild/events.jsonl`。允许字段按事件白名单固定:session ID、工具名、工具成功状态、错误代码、stop reason 和 completed。用户提示、系统提示、工具参数、工具输出及未知 Hook 字段全部丢弃;API Key 从不进入 Hook 数据。 + +每行限制为 16 KiB,写入后 flush/fsync;读取最多 1,000 条,损坏行跳过,文件本身继续受阶段 8/9 的 Git 忽略与模型工具隔离保护。`--timeline [N]` 和交互 `/timeline` 只渲染这份脱敏数据。 + +### 4. 质量门禁与自动化 + +- `ruff check src tests scripts`:基本语法错误、未使用名称和 import 顺序。 +- `mypy`:检查 26 个源码文件,启用 untyped body、泛型和 Optional 相关约束。 +- `pytest --cov=minicode_rebuild`:全量分支覆盖,最低阈值 85%。 +- `compileall`:编译源码、测试与脚本。 +- `python -m build --no-isolation`:在已由 `.[dev]` 固定的构建环境生成 sdist 与通用 wheel;另行执行过隔离构建验证。 +- `scripts/demo.py`:临时工作区中运行两步 MockModel 工具演示并输出脱敏时间线。 +- GitHub Actions:Windows/Ubuntu × Python 3.11/3.13 执行同一 `release_check.py`。 + +构建产物和 coverage 文件由 `.gitignore` 排除。质量依赖只在 `.[dev]` 中,不增加用户运行时第三方依赖。 + +### 5. 实际验证 + +- 阶段 10 相关测试:`37 passed`。 +- 全量测试与覆盖率:`244 passed, 2 skipped`,分支覆盖率 `85.08%`,达到 `85%` 门槛。 +- Mypy:`Success: no issues found in 26 source files`。 +- Ruff:`All checks passed!`。 +- `compileall`:通过。 +- Mock demo:完成 `list_files` 工具调用、最终响应、统计与六类生命周期事件展示。 +- 隔离构建:成功生成 `minicode_rebuild-0.1.0.tar.gz` 与 `minicode_rebuild-0.1.0-py3-none-any.whl`。 +- editable 安装与控制台入口:`minicode-rebuild 0.1.0`、`--help` 通过。 + +两个 skip 来自当前 Windows 环境未授予符号链接创建权限;Linux CI 将执行对应真实路径逃逸测试。隔离构建首次在沙箱中因不能下载 build requirements 失败,获准联网后成功,属于环境网络限制而非项目缺陷。 + +### 6. 发布检查清单与限制 + +- [x] README 可复制安装、readiness、timeline、演示与质量门禁命令。 +- [x] 结构化日志不含提示、参数、输出或凭据。 +- [x] MockModel 演示无需密钥和网络且可复现。 +- [x] 测试、覆盖率、lint、type check、编译和构建通过。 +- [x] Windows/Linux 差异与符号链接跳过原因已说明。 +- [x] `.env`、运行日志、会话、构建产物、coverage 与无关目录不进入提交。 +- [x] 文档与 CLI 的 `--help`、退出码和真实行为一致。 + +当前事件日志是追加式单进程文件,没有轮转、跨进程锁或远程导出;高并发/长期运行需要独立设计。readiness 不证明 Key 有效、模型存在或账户余额充足。Python 3.11/3.13 的最终跨平台结果由新 PR 的 GitHub Actions 给出。 + +### 7. 下一阶段 + +阶段 0 至阶段 10 的基础路线完成。阶段 11 不应默认“大合集”继续推进;需要从多 Agent、Git Worktree、MCP、长期记忆与检索、多模型路由、成本控制、完整 TUI 或上下文调节中选择一个能力,建立独立威胁模型、测试和提交。 + +### 8. 构建门禁修复记录 + +第一次把隔离构建直接放进一键脚本时,沙箱环境无法下载临时 build requirements;改为 `--no-isolation` 后又发现项目 dev 环境未显式安装 setuptools/wheel。将二者加入 `.[dev]` 后,重复写已有 `dist` 文件在 Windows 触发访问拒绝。最终门禁为每次构建创建新的临时输出目录,既不依赖临时联网,也不覆盖旧产物。 + +此外使用全新 `.verify-venv` 从生成的 wheel 执行 `pip --no-index` 安装,`minicode-rebuild --version` 和 `--demo` 均成功,证明控制台入口和运行时依赖没有依赖 editable checkout。验证目录与构建产物已清理,未进入 Git。 + +### 9. Git 记录 + +- 分支:`rebuild/minicode-learning` +- 实现提交:`edf1569` +- 提交信息:`chore(phase-10): add readiness checks and release verification` +- 文档收口将在下一提交记录;提交将推送到阶段 10 的独立 Draft PR,不自动合并 `master`。 + +推送后沿用仍开放的 Draft PR #4,并将标题/说明扩展为阶段 9—10。新引入的 GitHub Actions 在 Ubuntu 3.11、Ubuntu 3.13、Windows 3.11、Windows 3.13 四个组合全部通过;PR 保持 Draft、`MERGEABLE`,未合并 `master`。 diff --git a/pyproject.toml b/pyproject.toml index e84f3bd..222c910 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,13 @@ dependencies = [] [project.optional-dependencies] dev = [ + "build>=1.2", + "mypy>=1.11", "pytest>=8.0", + "pytest-cov>=5.0", + "ruff>=0.6", + "setuptools>=68", + "wheel", ] [project.scripts] @@ -30,3 +36,28 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] + +[tool.coverage.run] +branch = true +source = ["minicode_rebuild"] + +[tool.coverage.report] +fail_under = 85 +show_missing = true +skip_covered = true + +[tool.ruff] +target-version = "py311" +line-length = 88 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + +[tool.mypy] +python_version = "3.11" +files = ["src/minicode_rebuild"] +check_untyped_defs = true +disallow_any_generics = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true diff --git a/scripts/demo.py b/scripts/demo.py new file mode 100644 index 0000000..2c565d6 --- /dev/null +++ b/scripts/demo.py @@ -0,0 +1,30 @@ +"""Reproducible no-network MiniCode Rebuild demonstration.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +from minicode_rebuild.cli import main + + +def run() -> int: + with tempfile.TemporaryDirectory(prefix="minicode-rebuild-demo-") as directory: + workspace = Path(directory) + (workspace / "hello.py").write_text("print('hello')\n", encoding="utf-8") + print(f"Demo workspace: {workspace}") + code = main( + ["--demo", "--cwd", str(workspace), "inspect this workspace"], + environment={}, + ) + if code != 0: + return code + print("\nRedacted runtime timeline:") + return main( + ["--timeline", "20", "--cwd", str(workspace)], + environment={}, + ) + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/scripts/release_check.py b/scripts/release_check.py new file mode 100644 index 0000000..bb7c613 --- /dev/null +++ b/scripts/release_check.py @@ -0,0 +1,52 @@ +"""Run the local release-quality gate with the current Python interpreter.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile + +COMMANDS = ( + (sys.executable, "-m", "ruff", "check", "src", "tests", "scripts"), + (sys.executable, "-m", "mypy"), + ( + sys.executable, + "-m", + "pytest", + "--cov=minicode_rebuild", + "--cov-report=term-missing", + "-q", + ), + (sys.executable, "-m", "compileall", "-q", "src", "tests", "scripts"), +) + + +def run() -> int: + for command in COMMANDS: + print(f"+ {' '.join(command)}", flush=True) + completed = subprocess.run(command, check=False) + if completed.returncode: + return completed.returncode + with tempfile.TemporaryDirectory(prefix="minicode-rebuild-dist-") as directory: + command = ( + sys.executable, + "-m", + "build", + "--no-isolation", + "--outdir", + directory, + ) + print(f"+ {' '.join(command)}", flush=True) + completed = subprocess.run(command, check=False) + if completed.returncode: + return completed.returncode + command = (sys.executable, "scripts/demo.py") + print(f"+ {' '.join(command)}", flush=True) + completed = subprocess.run(command, check=False) + if completed.returncode: + return completed.returncode + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/src/minicode_rebuild/cli.py b/src/minicode_rebuild/cli.py index 65cf4ae..a40fbf3 100644 --- a/src/minicode_rebuild/cli.py +++ b/src/minicode_rebuild/cli.py @@ -24,9 +24,17 @@ RuntimeSettings, ) from minicode_rebuild.core import ModelAdapter, ModelResponse, ToolCall +from minicode_rebuild.hooks import HookManager from minicode_rebuild.models import MockModel from minicode_rebuild.models.openai_compatible import OpenAICompatibleAdapter +from minicode_rebuild.observability import ( + EventLog, + ObservabilityError, + format_timeline, + register_event_log, +) from minicode_rebuild.permissions import PermissionDecision +from minicode_rebuild.readiness import check_readiness, format_readiness from minicode_rebuild.session import RewindPlan, SessionError, SessionStore EXIT_OK = 0 @@ -98,6 +106,19 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="list saved sessions for the workspace and exit", ) + parser.add_argument( + "--readiness", + action="store_true", + help="run offline runtime and provider configuration checks and exit", + ) + parser.add_argument( + "--timeline", + nargs="?", + type=int, + const=100, + metavar="N", + help="show the latest N redacted runtime events and exit (default: 100)", + ) parser.add_argument( "--version", action="version", @@ -215,7 +236,8 @@ def _run_interactive( "MiniCode Rebuild interactive\n" f"Session: {session.session_id}\n" "Commands: /help, /session, /sessions, /transcript, /checkpoints, " - "/rewind-preview [id], /rewind [id], /stats, /compact, /exit\n" + "/rewind-preview [id], /rewind [id], /skills, /timeline, /stats, " + "/compact, /exit\n" ) output.flush() while True: @@ -234,7 +256,8 @@ def _run_interactive( if user_message == "/help": output.write( "Commands: /help, /session, /sessions, /transcript, /checkpoints, " - "/rewind-preview [id], /rewind [id], /stats, /compact, /exit\n" + "/rewind-preview [id], /rewind [id], /skills, /timeline, /stats, " + "/compact, /exit\n" ) continue if user_message == "/session": @@ -245,24 +268,39 @@ def _run_interactive( if not records: output.write("No saved sessions.\n") for record in records: - active = sum(item.rewound_at is None for item in record.checkpoints) + active_count = sum( + item.rewound_at is None for item in record.checkpoints + ) output.write( - f"{record.session_id} turns={record.stats.turns} checkpoints={active}\n" + f"{record.session_id} turns={record.stats.turns} " + f"checkpoints={active_count}\n" ) continue if user_message == "/transcript": output.write((session.transcript() or "(empty transcript)") + "\n") continue if user_message == "/checkpoints": - record = session.session_record - active = [] if record is None else [ - item for item in record.checkpoints if item.rewound_at is None + current_record = session.session_record + active_checkpoints = [] if current_record is None else [ + item + for item in current_record.checkpoints + if item.rewound_at is None ] - if not active: + if not active_checkpoints: output.write("No active checkpoints.\n") - for item in active: + for item in active_checkpoints: output.write(f"{item.checkpoint_id} {item.operation} {item.path}\n") continue + if user_message == "/skills": + skills = session.list_skills() + if not skills: + output.write("No workspace skills discovered.\n") + for skill in skills: + output.write(f"{skill.name}: {skill.description}\n") + continue + if user_message == "/timeline": + output.write(session.timeline() + "\n") + continue if user_message == "/rewind-preview" or user_message.startswith("/rewind-preview "): checkpoint_id = user_message[len("/rewind-preview") :].strip() or None try: @@ -333,6 +371,8 @@ def main( and not args.prompt and not args.demo and not args.list_sessions + and not args.readiness + and args.timeline is None ): parser.print_help(file=output) return EXIT_OK @@ -348,14 +388,41 @@ def main( return EXIT_USAGE_ERROR if args.list_sessions and ( args.prompt or args.interactive or args.headless or args.demo or args.resume + or args.readiness or args.timeline is not None ): error_output.write( "Configuration error: --list-sessions cannot run a model request\n" ) return EXIT_USAGE_ERROR + if args.readiness and ( + args.prompt or args.interactive or args.headless or args.resume + or args.list_sessions or args.timeline is not None + ): + error_output.write( + "Configuration error: --readiness cannot run a model request\n" + ) + return EXIT_USAGE_ERROR + if args.timeline is not None and ( + args.prompt or args.interactive or args.headless or args.demo or args.resume + or args.list_sessions or args.readiness + ): + error_output.write( + "Configuration error: --timeline cannot run a model request\n" + ) + return EXIT_USAGE_ERROR try: workspace = _workspace(args.cwd) + event_log = EventLog(workspace) + if args.readiness: + report = check_readiness( + workspace, env, require_provider=not args.demo + ) + output.write(format_readiness(report) + "\n") + return EXIT_OK if report.ready else EXIT_RUNTIME_ERROR + if args.timeline is not None: + output.write(format_timeline(event_log.read(limit=args.timeline)) + "\n") + return EXIT_OK if args.list_sessions: records = SessionStore(workspace).list() if not records: @@ -379,9 +446,12 @@ def main( "WARNING: --allow-mutations approves file and command changes " "for this Headless run.\n" ) - permission_prompt = lambda _request: PermissionDecision.ALLOW_ONCE + def permission_prompt(_request): + return PermissionDecision.ALLOW_ONCE else: permission_prompt = None + hooks = HookManager() + register_event_log(hooks, event_log) session = build_session( model=selected_model, workspace=workspace, @@ -389,6 +459,8 @@ def main( output=output, permission_prompt=permission_prompt, resume=args.resume, + hooks=hooks, + event_log=event_log, ) if args.interactive: return _run_interactive( @@ -408,6 +480,9 @@ def main( except SessionError as exc: error_output.write(f"Session error: {exc}\n") return EXIT_USAGE_ERROR + except ObservabilityError as exc: + error_output.write(f"Runtime data error: {exc}\n") + return EXIT_USAGE_ERROR except KeyboardInterrupt: error_output.write("\nInterrupted by user. Exiting safely.\n") return EXIT_INTERRUPTED diff --git a/src/minicode_rebuild/cli_runtime.py b/src/minicode_rebuild/cli_runtime.py index b77f2b3..a1a0017 100644 --- a/src/minicode_rebuild/cli_runtime.py +++ b/src/minicode_rebuild/cli_runtime.py @@ -11,6 +11,8 @@ from minicode_rebuild.config import RuntimeSettings from minicode_rebuild.context import CompactionResult, ContextManager from minicode_rebuild.core import Message, MessageRole, ModelAdapter, ToolCall +from minicode_rebuild.hooks import HookEvent, HookManager, HookReport +from minicode_rebuild.observability import EventLog, format_timeline from minicode_rebuild.permissions import ( PermissionDecision, PermissionManager, @@ -23,8 +25,9 @@ SessionStore, format_transcript, ) +from minicode_rebuild.skills import SkillCatalog, SkillSummary from minicode_rebuild.tooling import ToolContext, ToolRegistry, ToolResult -from minicode_rebuild.tools import MUTATING_TOOLS, READ_ONLY_TOOLS +from minicode_rebuild.tools import EXTENSION_TOOLS, MUTATING_TOOLS, READ_ONLY_TOOLS @dataclass(frozen=True, slots=True) @@ -78,7 +81,7 @@ def prompt(request: PermissionRequest) -> PermissionDecision: def create_tool_registry() -> ToolRegistry: """Return the complete built-in registry in a stable order.""" - return ToolRegistry((*READ_ONLY_TOOLS, *MUTATING_TOOLS)) + return ToolRegistry((*READ_ONLY_TOOLS, *EXTENSION_TOOLS, *MUTATING_TOOLS)) class AgentSession: @@ -95,6 +98,9 @@ def __init__( context_manager: ContextManager | None = None, session_store: SessionStore | None = None, session_record: SessionRecord | None = None, + skill_catalog: SkillCatalog | None = None, + hooks: HookManager | None = None, + event_log: EventLog | None = None, ) -> None: self.model = model self.tools = tools @@ -106,6 +112,9 @@ def __init__( ) self.session_store = session_store self.session_record = session_record + self.skill_catalog = skill_catalog + self.hooks = hooks + self.event_log = event_log self.history = session_record.messages if session_record is not None else () self.stats = ( SessionStats( @@ -123,6 +132,11 @@ def __init__( if session_store is not None and session_record is not None: self.context.state["checkpoint_recorder"] = self._record_checkpoint self.context.state["checkpoint_discarder"] = self._discard_checkpoint + if skill_catalog is not None: + self.context.state["skill_catalog"] = skill_catalog + if hooks is not None: + self.context.hooks = hooks + self.context.hook_observer = self._observe_hook @property def session_id(self) -> str | None: @@ -171,23 +185,49 @@ def _persist(self, *, new_transcript: tuple[Message, ...] = ()) -> None: compactions=self.stats.compactions, ) self.session_store.save(self.session_record) + self._emit(HookEvent.SESSION_SAVE, session_id=self.session_id) def _observe_tool(self, call: ToolCall, result: ToolResult) -> None: status = "ok" if result.ok else f"error ({result.error_code})" self.output.write(f"[tool] {call.name} -> {status}\n") self.output.flush() + def _observe_hook(self, report: HookReport) -> None: + for failure in report.failures: + self.output.write( + f"[hook:error] {failure.event.value}/{failure.name}: " + f"{failure.error}\n" + ) + if report.failures: + self.output.flush() + + def _emit(self, event: HookEvent, **data: object) -> None: + if self.hooks is None: + return + self._observe_hook(self.hooks.emit(event, **data)) + + def _system_prompt(self) -> str: + parts = [self.settings.system_prompt.strip()] + if self.skill_catalog is not None: + parts.append(self.skill_catalog.prompt_summary()) + return "\n\n".join(part for part in parts if part) + def run(self, user_message: str) -> AgentResult: """Run one turn, retain normalized history, and update counters.""" self._request_compactions = 0 + self._emit( + HookEvent.AGENT_START, + session_id=self.session_id, + user_message=user_message, + ) result = run_agent_turn( model=self.model, tools=self.tools, context=self.context, user_message=user_message, history=self.history, - system_prompt=self.settings.system_prompt, + system_prompt=self._system_prompt(), max_steps=self.settings.max_steps, tool_observer=self._observe_tool, message_preparer=self._prepare_messages, @@ -213,6 +253,12 @@ def run(self, user_message: str) -> AgentResult: ), ) self._persist(new_transcript=self._current_turn(result.messages, user_message)) + self._emit( + HookEvent.AGENT_STOP, + session_id=self.session_id, + stop_reason=result.stop_reason.value, + completed=result.completed, + ) return result def _prepare_messages( @@ -250,6 +296,18 @@ def transcript(self) -> str: def list_sessions(self) -> list[SessionRecord]: return [] if self.session_store is None else self.session_store.list() + def list_skills(self) -> tuple[SkillSummary, ...]: + """Return workspace skill metadata without loading instruction bodies.""" + + return () if self.skill_catalog is None else self.skill_catalog.discover() + + def timeline(self, *, limit: int = 100) -> str: + """Render recent redacted runtime events for this workspace.""" + + if self.event_log is None: + return "Runtime event logging is disabled." + return format_timeline(self.event_log.read(limit=limit)) + def preview_rewind(self, checkpoint_id: str | None = None) -> RewindPlan: if self.session_store is None or self.session_record is None: raise RuntimeError("Session persistence is disabled") @@ -271,13 +329,15 @@ def build_session( output: TextIO, permission_prompt: Callable[[PermissionRequest], PermissionDecision] | None, resume: str | None = None, + hooks: HookManager | None = None, + event_log: EventLog | None = None, ) -> AgentSession: """Assemble the default registry, permission boundary, and session.""" permissions = PermissionManager(prompt=permission_prompt) store = SessionStore(workspace) record = store.load(resume) if resume is not None else store.create() - return AgentSession( + session = AgentSession( model=model, tools=create_tool_registry(), context=ToolContext(workspace, permissions=permissions), @@ -286,7 +346,15 @@ def build_session( context_manager=ContextManager(settings.context_policy), session_store=store, session_record=record, + skill_catalog=SkillCatalog(workspace), + hooks=hooks, + event_log=event_log, + ) + session._emit( + HookEvent.SESSION_RESUME if resume is not None else HookEvent.SESSION_CREATE, + session_id=session.session_id, ) + return session __all__ = [ diff --git a/src/minicode_rebuild/file_changes.py b/src/minicode_rebuild/file_changes.py index 0c69cd3..23b0c49 100644 --- a/src/minicode_rebuild/file_changes.py +++ b/src/minicode_rebuild/file_changes.py @@ -69,9 +69,13 @@ def apply_file_change( assessment = classify_file_risk(target, existed=existed) relative = _relative(target, context) - if relative.split("/", 1)[0].casefold() == ".minicode-rebuild": + if relative.split("/", 1)[0].casefold() in { + ".minicode-rebuild", + ".minicode", + }: return ToolResult.error( - "reserved_path", "The .minicode-rebuild runtime directory is managed internally." + "reserved_path", + "The requested runtime or extension directory is managed internally.", ) request = PermissionRequest( operation=operation, diff --git a/src/minicode_rebuild/hooks.py b/src/minicode_rebuild/hooks.py new file mode 100644 index 0000000..7aee6f1 --- /dev/null +++ b/src/minicode_rebuild/hooks.py @@ -0,0 +1,156 @@ +"""Small synchronous extension hooks with explicit failure reporting.""" + +from __future__ import annotations + +import time +from collections.abc import Callable, Mapping +from copy import deepcopy +from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType + + +class HookEvent(str, Enum): + """Stable extension points exposed by the runtime and tool boundary.""" + + AGENT_START = "agent_start" + AGENT_STOP = "agent_stop" + SESSION_CREATE = "session_create" + SESSION_RESUME = "session_resume" + SESSION_SAVE = "session_save" + BEFORE_TOOL = "before_tool" + AFTER_TOOL = "after_tool" + + +@dataclass(frozen=True, slots=True) +class HookContext: + """Read-only snapshot passed to one hook handler.""" + + event: HookEvent + data: Mapping[str, object] + + +HookHandler = Callable[[HookContext], None] + + +@dataclass(frozen=True, slots=True) +class HookFailure: + """One isolated hook failure that callers can surface to users.""" + + event: HookEvent + name: str + error: str + + +@dataclass(frozen=True, slots=True) +class HookReport: + """Observable result of emitting one hook event.""" + + event: HookEvent + handlers: int + failures: tuple[HookFailure, ...] + duration_ms: float + + +@dataclass(frozen=True, slots=True) +class HookRegistration: + """Metadata for a registered in-process extension.""" + + event: HookEvent + name: str + handler: HookHandler + + +HookObserver = Callable[[HookReport], None] + + +class HookManager: + """Register and emit bounded synchronous hooks without global state.""" + + def __init__(self) -> None: + self._hooks: dict[HookEvent, list[HookRegistration]] = { + event: [] for event in HookEvent + } + self._reports: list[HookReport] = [] + + def register( + self, event: HookEvent, handler: HookHandler, *, name: str = "" + ) -> Callable[[], None]: + """Register a handler and return an idempotent unregister callback.""" + + if not isinstance(event, HookEvent): + raise TypeError("hook event must be a HookEvent") + if not callable(handler): + raise TypeError("hook handler must be callable") + normalized_name = name.strip() or getattr(handler, "__name__", "hook") + registration = HookRegistration(event, normalized_name, handler) + self._hooks[event].append(registration) + + def unregister() -> None: + try: + self._hooks[event].remove(registration) + except ValueError: + pass + + return unregister + + def registrations( + self, event: HookEvent | None = None + ) -> tuple[HookRegistration, ...]: + """Return a stable registration snapshot.""" + + if event is not None: + return tuple(self._hooks[event]) + return tuple( + registration + for current_event in HookEvent + for registration in self._hooks[current_event] + ) + + def emit(self, event: HookEvent, **data: object) -> HookReport: + """Run hooks in registration order and isolate ordinary exceptions.""" + + if not isinstance(event, HookEvent): + raise TypeError("hook event must be a HookEvent") + handlers = tuple(self._hooks[event]) + failures: list[HookFailure] = [] + started = time.perf_counter() + for registration in handlers: + try: + snapshot = MappingProxyType(deepcopy(data)) + registration.handler(HookContext(event=event, data=snapshot)) + except Exception as exc: + detail = str(exc) + suffix = f": {detail}" if detail else "" + failures.append( + HookFailure( + event=event, + name=registration.name, + error=f"{type(exc).__name__}{suffix}", + ) + ) + report = HookReport( + event=event, + handlers=len(handlers), + failures=tuple(failures), + duration_ms=(time.perf_counter() - started) * 1_000, + ) + self._reports.append(report) + return report + + def reports(self) -> tuple[HookReport, ...]: + """Return emitted reports so embedding callers never lose failures.""" + + return tuple(self._reports) + + +__all__ = [ + "HookContext", + "HookEvent", + "HookFailure", + "HookHandler", + "HookManager", + "HookObserver", + "HookRegistration", + "HookReport", +] diff --git a/src/minicode_rebuild/observability.py b/src/minicode_rebuild/observability.py new file mode 100644 index 0000000..45c6be7 --- /dev/null +++ b/src/minicode_rebuild/observability.py @@ -0,0 +1,150 @@ +"""Redacted workspace-local JSONL events and runtime timeline rendering.""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from minicode_rebuild.hooks import HookContext, HookEvent, HookManager +from minicode_rebuild.workspace import WorkspacePathError, resolve_workspace_path + +EVENT_LOG_PATH = ".minicode-rebuild/events.jsonl" +MAX_EVENT_BYTES = 16 * 1024 +MAX_TIMELINE_EVENTS = 1_000 +_ALLOWED_FIELDS: dict[HookEvent, tuple[str, ...]] = { + HookEvent.SESSION_CREATE: ("session_id",), + HookEvent.SESSION_RESUME: ("session_id",), + HookEvent.SESSION_SAVE: ("session_id",), + HookEvent.AGENT_START: ("session_id",), + HookEvent.AGENT_STOP: ("session_id", "stop_reason", "completed"), + HookEvent.BEFORE_TOOL: ("tool_name",), + HookEvent.AFTER_TOOL: ("tool_name",), +} + + +class ObservabilityError(RuntimeError): + """Raised for recoverable event storage failures.""" + + +@dataclass(frozen=True, slots=True) +class RuntimeEvent: + timestamp: float + event: str + data: Mapping[str, str | bool | int | float | None] + + +def _safe_scalar(value: object) -> str | bool | int | float | None: + if value is None or isinstance(value, (str, bool, int, float)): + return value + return str(value) + + +class EventLog: + """Append redacted lifecycle metadata inside the reserved runtime directory.""" + + def __init__(self, workspace: Path) -> None: + self.workspace = Path(workspace).resolve() + try: + self.path = resolve_workspace_path(self.workspace, EVENT_LOG_PATH) + except WorkspacePathError as exc: + raise ObservabilityError("Event log resolves outside workspace") from exc + + def record(self, context: HookContext) -> None: + """Write one bounded event without prompt, arguments, output, or secrets.""" + + allowed = _ALLOWED_FIELDS[context.event] + data = { + name: _safe_scalar(context.data[name]) + for name in allowed + if name in context.data + } + if context.event is HookEvent.AFTER_TOOL: + result = context.data.get("result") + if isinstance(result, Mapping): + data["ok"] = bool(result.get("ok")) + error_code = result.get("error_code") + if error_code is not None: + data["error_code"] = str(error_code) + payload = json.dumps( + {"timestamp": time.time(), "event": context.event.value, "data": data}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + encoded = (payload + "\n").encode("utf-8") + if len(encoded) > MAX_EVENT_BYTES: + raise ObservabilityError("Event exceeds the bounded log entry size") + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("ab") as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + except OSError as exc: + raise ObservabilityError("Event log could not be written") from exc + + def read(self, *, limit: int = 100) -> tuple[RuntimeEvent, ...]: + """Read and validate the latest bounded events, skipping corrupt lines.""" + + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_TIMELINE_EVENTS: + raise ObservabilityError( + f"timeline limit must be between 1 and {MAX_TIMELINE_EVENTS}" + ) + try: + lines = self.path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return () + except (OSError, UnicodeDecodeError) as exc: + raise ObservabilityError("Event log could not be read") from exc + events: list[RuntimeEvent] = [] + for line in lines[-limit:]: + try: + value: Any = json.loads(line) + timestamp = float(value["timestamp"]) + event = str(value["event"]) + data = value["data"] + if not isinstance(data, dict): + continue + normalized = { + str(key): _safe_scalar(item) for key, item in data.items() + } + events.append(RuntimeEvent(timestamp, event, normalized)) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + continue + return tuple(events) + + +def register_event_log(hooks: HookManager, event_log: EventLog) -> None: + """Register the same redacted sink at every public lifecycle point.""" + + for event in HookEvent: + hooks.register(event, event_log.record, name="event-log") + + +def format_timeline(events: tuple[RuntimeEvent, ...]) -> str: + """Render a concise chronological event timeline.""" + + if not events: + return "No runtime events." + lines: list[str] = [] + for event in events: + timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(event.timestamp)) + details = " ".join(f"{key}={value}" for key, value in event.data.items()) + lines.append(f"{timestamp} {event.event}" + (f" {details}" if details else "")) + return "\n".join(lines) + + +__all__ = [ + "EVENT_LOG_PATH", + "EventLog", + "MAX_TIMELINE_EVENTS", + "ObservabilityError", + "RuntimeEvent", + "format_timeline", + "register_event_log", +] diff --git a/src/minicode_rebuild/readiness.py b/src/minicode_rebuild/readiness.py new file mode 100644 index 0000000..6157c29 --- /dev/null +++ b/src/minicode_rebuild/readiness.py @@ -0,0 +1,95 @@ +"""Offline provider and runtime readiness checks with no network requests.""" + +from __future__ import annotations + +import platform +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from minicode_rebuild.config import ( + ModelConfigurationError, + ModelSettings, + RuntimeSettings, +) +from minicode_rebuild.session import SessionStore +from minicode_rebuild.skills import SkillCatalog, SkillError + + +@dataclass(frozen=True, slots=True) +class ReadinessCheck: + name: str + ready: bool + detail: str + + +@dataclass(frozen=True, slots=True) +class ReadinessReport: + checks: tuple[ReadinessCheck, ...] + + @property + def ready(self) -> bool: + return all(check.ready for check in self.checks) + + +def check_readiness( + workspace: Path, + environment: Mapping[str, str], + *, + require_provider: bool = True, +) -> ReadinessReport: + """Validate local configuration without contacting a provider.""" + + checks: list[ReadinessCheck] = [ + ReadinessCheck( + "python", + sys.version_info >= (3, 11), + f"{platform.python_implementation()} {platform.python_version()}", + ) + ] + try: + RuntimeSettings.from_env(environment) + checks.append(ReadinessCheck("runtime-config", True, "valid")) + except ModelConfigurationError as exc: + checks.append(ReadinessCheck("runtime-config", False, str(exc))) + if require_provider: + try: + settings = ModelSettings.from_env(environment) + checks.append( + ReadinessCheck( + "provider-config", + True, + f"model={settings.model} endpoint={settings.chat_completions_url}", + ) + ) + except ModelConfigurationError as exc: + checks.append(ReadinessCheck("provider-config", False, str(exc))) + else: + checks.append(ReadinessCheck("provider-config", True, "not required for demo")) + try: + SessionStore(workspace) + checks.append(ReadinessCheck("session-store", True, "workspace-local")) + except Exception as exc: + checks.append(ReadinessCheck("session-store", False, f"{type(exc).__name__}: {exc}")) + try: + count = len(SkillCatalog(workspace).discover()) + checks.append(ReadinessCheck("skills", True, f"{count} discovered")) + except SkillError as exc: + checks.append(ReadinessCheck("skills", False, str(exc))) + return ReadinessReport(tuple(checks)) + + +def format_readiness(report: ReadinessReport) -> str: + lines = [f"Readiness: {'ready' if report.ready else 'not ready'}"] + for check in report.checks: + lines.append(f"[{'ok' if check.ready else 'fail'}] {check.name}: {check.detail}") + return "\n".join(lines) + + +__all__ = [ + "ReadinessCheck", + "ReadinessReport", + "check_readiness", + "format_readiness", +] diff --git a/src/minicode_rebuild/session.py b/src/minicode_rebuild/session.py index 3b19bf1..7543ad6 100644 --- a/src/minicode_rebuild/session.py +++ b/src/minicode_rebuild/session.py @@ -2,6 +2,7 @@ from __future__ import annotations +import builtins import difflib import hashlib import json @@ -356,7 +357,9 @@ def discard_checkpoint(self, record: SessionRecord, checkpoint_id: str) -> None: ] self.save(record) - def _selected(self, record: SessionRecord, checkpoint_id: str | None) -> list[FileCheckpoint]: + def _selected( + self, record: SessionRecord, checkpoint_id: str | None + ) -> builtins.list[FileCheckpoint]: self._validate_record(record) active = [item for item in record.checkpoints if item.rewound_at is None] if not active: @@ -368,7 +371,7 @@ def _selected(self, record: SessionRecord, checkpoint_id: str | None) -> list[Fi return active[positions[0] :] def _rewind_states( - self, selected: list[FileCheckpoint] + self, selected: builtins.list[FileCheckpoint] ) -> tuple[dict[str, str | None], tuple[str, ...]]: desired: dict[str, str | None] = {} latest: dict[str, FileCheckpoint] = {} diff --git a/src/minicode_rebuild/skills.py b/src/minicode_rebuild/skills.py new file mode 100644 index 0000000..4cbea45 --- /dev/null +++ b/src/minicode_rebuild/skills.py @@ -0,0 +1,187 @@ +"""Workspace-local discovery and on-demand loading of SKILL.md files.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +from minicode_rebuild.workspace import WorkspacePathError, resolve_workspace_path + +SKILL_ROOT = ".minicode/skills" +MAX_SKILLS = 100 +MAX_SKILL_BYTES = 128 * 1024 +_SKILL_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + + +class SkillError(RuntimeError): + """Base class for safe, user-visible skill failures.""" + + +class SkillNotFoundError(SkillError): + """Raised when a requested skill was not discovered.""" + + +class SkillFormatError(SkillError): + """Raised when a SKILL.md cannot be safely parsed.""" + + +@dataclass(frozen=True, slots=True) +class SkillSummary: + name: str + description: str + path: str + + +@dataclass(frozen=True, slots=True) +class LoadedSkill: + name: str + description: str + path: str + content: str + + +def _frontmatter(markdown: str) -> dict[str, str]: + normalized = markdown.replace("\r\n", "\n") + if not normalized.startswith("---\n"): + return {} + end = normalized.find("\n---\n", 4) + if end < 0: + return {} + values: dict[str, str] = {} + for line in normalized[4:end].splitlines(): + key, separator, value = line.partition(":") + if separator and key.strip() in {"name", "description"}: + values[key.strip()] = value.strip().strip("\"'") + return values + + +def _description(markdown: str) -> str: + metadata = _frontmatter(markdown) + if metadata.get("description"): + return metadata["description"][:500] + normalized = markdown.replace("\r\n", "\n") + if normalized.startswith("---\n"): + end = normalized.find("\n---\n", 4) + if end >= 0: + normalized = normalized[end + 5 :] + for raw_line in normalized.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + return line.replace("`", "")[:500] + return "No description provided." + + +class SkillCatalog: + """Discover only direct child skills under one workspace-owned root.""" + + def __init__(self, workspace: Path) -> None: + self.workspace = Path(workspace).resolve() + if not self.workspace.is_dir(): + raise SkillFormatError("Skill workspace must be a directory") + try: + self.root = resolve_workspace_path(self.workspace, SKILL_ROOT) + except WorkspacePathError as exc: + raise SkillFormatError("Skill root resolves outside workspace") from exc + + def _path(self, name: str) -> Path: + if not isinstance(name, str) or not _SKILL_NAME.fullmatch(name.strip()): + raise SkillFormatError("Skill name must be a safe 1-64 character slug") + try: + target = resolve_workspace_path( + self.workspace, f"{SKILL_ROOT}/{name.strip()}/SKILL.md" + ) + target.relative_to(self.root) + except (WorkspacePathError, ValueError) as exc: + raise SkillFormatError("Skill path resolves outside skill root") from exc + return target + + @staticmethod + def _read(path: Path) -> str: + try: + if not path.is_file(): + raise SkillNotFoundError("SKILL.md does not exist") + if path.stat().st_size > MAX_SKILL_BYTES: + raise SkillFormatError( + f"SKILL.md exceeds the {MAX_SKILL_BYTES}-byte limit" + ) + return path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise SkillFormatError("SKILL.md must be UTF-8 text") from exc + except OSError as exc: + raise SkillFormatError("SKILL.md could not be read") from exc + + def discover(self) -> tuple[SkillSummary, ...]: + """Return bounded metadata without retaining full skill content.""" + + try: + entries = sorted( + self.root.iterdir(), key=lambda item: (item.name.casefold(), item.name) + ) + except FileNotFoundError: + return () + except OSError as exc: + raise SkillFormatError("Skill root could not be scanned") from exc + summaries: list[SkillSummary] = [] + for entry in entries[:MAX_SKILLS]: + if not _SKILL_NAME.fullmatch(entry.name): + continue + try: + path = self._path(entry.name) + content = self._read(path) + except SkillError: + continue + metadata = _frontmatter(content) + summaries.append( + SkillSummary( + name=entry.name, + description=_description(content), + path=path.relative_to(self.workspace).as_posix(), + ) + ) + if metadata.get("name") and metadata["name"] != entry.name: + summaries.pop() + return tuple(summaries) + + def load(self, name: str) -> LoadedSkill: + """Load one explicitly selected skill after validating its metadata.""" + + normalized = name.strip() + path = self._path(normalized) + content = self._read(path) + metadata = _frontmatter(content) + if metadata.get("name") and metadata["name"] != normalized: + raise SkillFormatError("SKILL.md name must match its directory") + return LoadedSkill( + name=normalized, + description=_description(content), + path=path.relative_to(self.workspace).as_posix(), + content=content, + ) + + def prompt_summary(self) -> str: + """Build the bounded catalog injected into the system prompt.""" + + skills = self.discover() + if not skills: + return "" + lines = [ + "[Available skills]", + "Skills are local instructions. Load one with load_skill only when relevant; do not guess its contents.", + ] + lines.extend(f"- {skill.name}: {skill.description}" for skill in skills) + return "\n".join(lines) + + +__all__ = [ + "LoadedSkill", + "MAX_SKILL_BYTES", + "MAX_SKILLS", + "SKILL_ROOT", + "SkillCatalog", + "SkillError", + "SkillFormatError", + "SkillNotFoundError", + "SkillSummary", +] diff --git a/src/minicode_rebuild/tooling.py b/src/minicode_rebuild/tooling.py index cd5bd94..1e4ad0d 100644 --- a/src/minicode_rebuild/tooling.py +++ b/src/minicode_rebuild/tooling.py @@ -7,11 +7,12 @@ from copy import deepcopy from dataclasses import dataclass, field, replace from pathlib import Path -from typing import TYPE_CHECKING, Self, TypeAlias +from typing import TYPE_CHECKING, Self, TypeAlias, cast from minicode_rebuild.core import JsonValue, ModelTool if TYPE_CHECKING: + from minicode_rebuild.hooks import HookManager, HookObserver from minicode_rebuild.permissions import PermissionManager ToolHandler: TypeAlias = Callable[ @@ -44,6 +45,8 @@ class ToolContext: cwd: Path state: MutableMapping[str, object] = field(default_factory=dict) permissions: "PermissionManager | None" = None + hooks: "HookManager | None" = None + hook_observer: "HookObserver | None" = None def __post_init__(self) -> None: self.cwd = Path(self.cwd) @@ -200,12 +203,18 @@ def execute( ) safe_arguments = deepcopy(dict(arguments)) + self._emit_hook( + context, + "BEFORE_TOOL", + tool_name=tool.name, + arguments=safe_arguments, + ) try: result = tool.handler(safe_arguments, context) except Exception as exc: detail = str(exc) suffix = f": {detail}" if detail else "" - return self._finalize( + result = self._finalize( ToolResult.error( "execution_error", f"Error running tool '{tool.name}': " @@ -213,9 +222,11 @@ def execute( ), limit, ) + self._emit_after_tool(context, tool.name, result) + return result if not isinstance(result, ToolResult): - return self._finalize( + result = self._finalize( ToolResult.error( "invalid_result", f"Tool '{tool.name}' must return ToolResult, " @@ -223,7 +234,37 @@ def execute( ), limit, ) - return self._finalize(result, limit) + self._emit_after_tool(context, tool.name, result) + return result + result = self._finalize(result, limit) + self._emit_after_tool(context, tool.name, result) + return result + + @staticmethod + def _emit_hook(context: ToolContext, event_name: str, **data: object) -> None: + if context.hooks is None: + return + from minicode_rebuild.hooks import HookEvent + + report = context.hooks.emit(HookEvent[event_name], **data) + if context.hook_observer is not None: + context.hook_observer(report) + + @classmethod + def _emit_after_tool( + cls, context: ToolContext, tool_name: str, result: ToolResult + ) -> None: + cls._emit_hook( + context, + "AFTER_TOOL", + tool_name=tool_name, + result={ + "ok": result.ok, + "output": result.output, + "error_code": result.error_code, + "truncated": result.truncated, + }, + ) @staticmethod def _finalize(result: ToolResult, limit: int) -> ToolResult: @@ -328,7 +369,7 @@ def _validate_size_bounds( isinstance(value, bool) or not isinstance(value, int) or value < 0 ): raise ValueError(f"{path}.{label} must be a non-negative integer") - if minimum is not None and maximum is not None and minimum > maximum: + if isinstance(minimum, int) and isinstance(maximum, int) and minimum > maximum: raise ValueError(f"{path} minimum size must not exceed maximum size") @@ -342,7 +383,13 @@ def _validate_number_bounds(schema: Mapping[str, JsonValue], path: str) -> None: or not _is_finite_number(value) ): raise ValueError(f"{path}.{label} must be a finite number") - if minimum is not None and maximum is not None and minimum > maximum: + if ( + isinstance(minimum, (int, float)) + and not isinstance(minimum, bool) + and isinstance(maximum, (int, float)) + and not isinstance(maximum, bool) + and minimum > maximum + ): raise ValueError(f"{path}.minimum must not exceed maximum") @@ -354,7 +401,7 @@ def _validate_value( raise ToolValidationError(f"{path} must be {schema_type}") enum = schema.get("enum") - if enum is not None and value not in enum: + if isinstance(enum, list) and cast(JsonValue, value) not in enum: raise ToolValidationError(f"{path} must be one of the allowed values") if schema_type == "object": diff --git a/src/minicode_rebuild/tools/__init__.py b/src/minicode_rebuild/tools/__init__.py index 64637dc..2944385 100644 --- a/src/minicode_rebuild/tools/__init__.py +++ b/src/minicode_rebuild/tools/__init__.py @@ -1,7 +1,6 @@ """Built-in tool definitions.""" from minicode_rebuild.tools.command import run_command_tool - from minicode_rebuild.tools.read_only import ( READ_ONLY_TOOLS, glob_search_tool, @@ -9,6 +8,7 @@ list_files_tool, read_file_tool, ) +from minicode_rebuild.tools.skills import load_skill_tool from minicode_rebuild.tools.write import ( WRITE_TOOLS, edit_file_tool, @@ -17,8 +17,10 @@ ) MUTATING_TOOLS = (*WRITE_TOOLS, run_command_tool) +EXTENSION_TOOLS = (load_skill_tool,) __all__ = [ + "EXTENSION_TOOLS", "MUTATING_TOOLS", "READ_ONLY_TOOLS", "WRITE_TOOLS", @@ -26,6 +28,7 @@ "glob_search_tool", "grep_files_tool", "list_files_tool", + "load_skill_tool", "patch_file_tool", "read_file_tool", "run_command_tool", diff --git a/src/minicode_rebuild/tools/command.py b/src/minicode_rebuild/tools/command.py index 72d15ea..5e7e96d 100644 --- a/src/minicode_rebuild/tools/command.py +++ b/src/minicode_rebuild/tools/command.py @@ -7,6 +7,7 @@ import subprocess from collections.abc import Mapping from pathlib import Path +from typing import cast from minicode_rebuild.core import JsonValue from minicode_rebuild.permissions import ( @@ -74,7 +75,7 @@ def _run_command( if cwd_error is not None: return cwd_error assert cwd is not None - timeout = int(arguments.get("timeout", DEFAULT_TIMEOUT)) + timeout = cast(int, arguments.get("timeout", DEFAULT_TIMEOUT)) assessment = classify_command_risk(command, command_args) vector = [command, *command_args] signature = json.dumps(vector, ensure_ascii=False, separators=(",", ":")) diff --git a/src/minicode_rebuild/tools/read_only.py b/src/minicode_rebuild/tools/read_only.py index 5f849af..12ce946 100644 --- a/src/minicode_rebuild/tools/read_only.py +++ b/src/minicode_rebuild/tools/read_only.py @@ -8,6 +8,7 @@ import re from collections.abc import Iterator, Mapping from pathlib import Path, PurePosixPath +from typing import cast from minicode_rebuild.core import JsonValue from minicode_rebuild.tooling import ToolContext, ToolDefinition, ToolResult @@ -32,6 +33,7 @@ ".hg", ".mypy_cache", ".minicode-rebuild", + ".minicode", ".pytest_cache", ".ruff_cache", ".svn", @@ -53,10 +55,13 @@ def _resolve( try: target = resolve_workspace_path(context.cwd, input_path) relative = target.relative_to(context.cwd.resolve()) - if relative.parts and relative.parts[0].casefold() == ".minicode-rebuild": + if relative.parts and relative.parts[0].casefold() in { + ".minicode-rebuild", + ".minicode", + }: return None, ToolResult.error( "reserved_path", - "The .minicode-rebuild runtime directory is managed internally.", + "The requested runtime or extension directory is managed internally.", ) return target, None except WorkspacePathError as exc: @@ -105,8 +110,8 @@ def _read_file( if type_error is not None: return type_error - offset = int(arguments.get("offset", 0)) - limit = int(arguments.get("limit", DEFAULT_READ_LIMIT)) + offset = cast(int, arguments.get("offset", 0)) + limit = cast(int, arguments.get("limit", DEFAULT_READ_LIMIT)) try: with target.open("r", encoding="utf-8", newline="") as stream: remaining = offset @@ -149,7 +154,7 @@ def _list_files( if type_error is not None: return type_error - limit = int(arguments.get("limit", DEFAULT_LIST_LIMIT)) + limit = cast(int, arguments.get("limit", DEFAULT_LIST_LIMIT)) workspace_root = _workspace_root(context) try: entries = heapq.nsmallest( @@ -229,7 +234,7 @@ def _glob_search( if type_error is not None: return type_error - limit = int(arguments.get("limit", DEFAULT_SEARCH_LIMIT)) + limit = cast(int, arguments.get("limit", DEFAULT_SEARCH_LIMIT)) workspace_root = _workspace_root(context) matches: list[Path] = [] candidate_count = 0 @@ -323,7 +328,7 @@ def _grep_files( if type_error is not None: return type_error - limit = int(arguments.get("limit", DEFAULT_SEARCH_LIMIT)) + limit = cast(int, arguments.get("limit", DEFAULT_SEARCH_LIMIT)) workspace_root = _workspace_root(context) results: list[str] = [] files_scanned = 0 diff --git a/src/minicode_rebuild/tools/skills.py b/src/minicode_rebuild/tools/skills.py new file mode 100644 index 0000000..46acd8c --- /dev/null +++ b/src/minicode_rebuild/tools/skills.py @@ -0,0 +1,42 @@ +"""Model-visible on-demand skill loader.""" + +from __future__ import annotations + +from collections.abc import Mapping + +from minicode_rebuild.core import JsonValue +from minicode_rebuild.skills import SkillCatalog, SkillError +from minicode_rebuild.tooling import ToolContext, ToolDefinition, ToolResult + + +def _load_skill( + arguments: Mapping[str, JsonValue], context: ToolContext +) -> ToolResult: + catalog = context.state.get("skill_catalog") + if not isinstance(catalog, SkillCatalog): + catalog = SkillCatalog(context.cwd) + try: + skill = catalog.load(str(arguments["name"])) + except SkillError as exc: + return ToolResult.error("skill_error", str(exc)) + return ToolResult.success( + f"SKILL: {skill.name}\nPATH: {skill.path}\n\n{skill.content}" + ) + + +load_skill_tool = ToolDefinition( + name="load_skill", + description="Load one relevant workspace SKILL.md by its discovered name.", + input_schema={ + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 64} + }, + "required": ["name"], + "additionalProperties": False, + }, + handler=_load_skill, +) + + +__all__ = ["load_skill_tool"] diff --git a/src/minicode_rebuild/tools/write.py b/src/minicode_rebuild/tools/write.py index 9d1868d..1056229 100644 --- a/src/minicode_rebuild/tools/write.py +++ b/src/minicode_rebuild/tools/write.py @@ -107,9 +107,16 @@ def _patch_file( return apply_file_change(target, next_content, context, operation="patch_file") -_PATH_SCHEMA = {"type": "string", "minLength": 1, "maxLength": 4_096} -_CONTENT_SCHEMA = {"type": "string", "maxLength": MAX_FILE_CONTENT} -_REPLACEMENT_SCHEMA = { +_PATH_SCHEMA: dict[str, JsonValue] = { + "type": "string", + "minLength": 1, + "maxLength": 4_096, +} +_CONTENT_SCHEMA: dict[str, JsonValue] = { + "type": "string", + "maxLength": MAX_FILE_CONTENT, +} +_REPLACEMENT_SCHEMA: dict[str, JsonValue] = { "type": "object", "properties": { "search": {"type": "string", "minLength": 1, "maxLength": MAX_FILE_CONTENT}, diff --git a/tests/test_cli.py b/tests/test_cli.py index 27645da..f72decc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -52,6 +52,60 @@ def test_module_entrypoint_rejects_unknown_argument() -> None: assert "unrecognized arguments: --unknown" in result.stderr +def test_readiness_command_is_offline_and_redacts_key(tmp_path: Path) -> None: + stdout = StringIO() + code = main( + ["--readiness", "--cwd", str(tmp_path)], + environment={ + "OPENAI_API_KEY": "private-key", + "OPENAI_BASE_URL": "https://example.test/v1", + }, + stdout=stdout, + stderr=StringIO(), + ) + + assert code == 0 + assert "Readiness: ready" in stdout.getvalue() + assert "private-key" not in stdout.getvalue() + + +def test_readiness_returns_nonzero_when_provider_is_missing(tmp_path: Path) -> None: + stdout = StringIO() + + code = main( + ["--readiness", "--cwd", str(tmp_path)], + environment={}, + stdout=stdout, + stderr=StringIO(), + ) + + assert code == 1 + assert "provider-config" in stdout.getvalue() + + +def test_timeline_command_reads_redacted_demo_events(tmp_path: Path) -> None: + assert main( + ["--demo", "--cwd", str(tmp_path), "inspect"], + environment={}, + stdout=StringIO(), + stderr=StringIO(), + ) == 0 + stdout = StringIO() + + code = main( + ["--timeline", "20", "--cwd", str(tmp_path)], + environment={}, + stdout=stdout, + stderr=StringIO(), + ) + + assert code == 0 + output = stdout.getvalue() + assert "session_create" in output + assert "before_tool tool_name=list_files" in output + assert "inspect" not in output + + def test_demo_runs_complete_headless_tool_flow(tmp_path: Path) -> None: stdout = StringIO() stderr = StringIO() @@ -238,7 +292,7 @@ def test_interactive_mode_keeps_history_and_supports_commands( code = main( ["--interactive", "--cwd", str(tmp_path)], environment={"OPENAI_API_KEY": "secret"}, - stdin=StringIO("one\n/stats\ntwo\n/compact\n/help\n/exit\n"), + stdin=StringIO("one\n/stats\ntwo\n/compact\n/skills\n/timeline\n/help\n/exit\n"), stdout=stdout, stderr=StringIO(), model=model, @@ -251,6 +305,8 @@ def test_interactive_mode_keeps_history_and_supports_commands( assert "turns=1" in output assert "/stats" in output and "/exit" in output assert "Context compact" in output + assert "No workspace skills discovered." in output + assert "agent_stop" in output assert "Goodbye" in output assert [message.content for message in model.requests[1].messages[-3:]] == [ "one", diff --git a/tests/test_cli_runtime.py b/tests/test_cli_runtime.py index 577c459..10e1617 100644 --- a/tests/test_cli_runtime.py +++ b/tests/test_cli_runtime.py @@ -13,6 +13,7 @@ from minicode_rebuild.config import RuntimeSettings from minicode_rebuild.context import ContextManager, ContextPolicy from minicode_rebuild.core import ModelResponse, TokenUsage +from minicode_rebuild.hooks import HookEvent, HookManager from minicode_rebuild.models import MockModel from minicode_rebuild.permissions import ( PermissionDecision, @@ -20,6 +21,7 @@ RiskLevel, ) from minicode_rebuild.session import SessionStore +from minicode_rebuild.skills import SkillCatalog from minicode_rebuild.tooling import ToolContext, ToolRegistry @@ -168,3 +170,51 @@ def test_persisted_session_resumes_history_stats_and_transcript(tmp_path: Path) ] assert "user: one" in second.transcript() assert "assistant: Second" in second.transcript() + + +def test_session_injects_skill_catalog_but_not_full_content(tmp_path: Path) -> None: + skill_path = tmp_path / ".minicode" / "skills" / "review" / "SKILL.md" + skill_path.parent.mkdir(parents=True) + skill_path.write_text( + "---\nname: review\ndescription: Review changes.\n---\n\nPRIVATE STEPS", + encoding="utf-8", + ) + model = MockModel([ModelResponse(content="Done")]) + session = AgentSession( + model=model, + tools=ToolRegistry(), + context=ToolContext(tmp_path), + settings=RuntimeSettings(max_steps=3, system_prompt="Be precise"), + output=StringIO(), + skill_catalog=SkillCatalog(tmp_path), + ) + + session.run("review") + + prompt = model.requests[0].messages[0].content + assert "Be precise" in prompt + assert "review: Review changes." in prompt + assert "PRIVATE STEPS" not in prompt + + +def test_session_lifecycle_hook_failure_is_visible_and_nonfatal(tmp_path: Path) -> None: + hooks = HookManager() + hooks.register( + HookEvent.AGENT_START, + lambda context: (_ for _ in ()).throw(RuntimeError("observer failed")), + name="broken", + ) + output = StringIO() + session = AgentSession( + model=MockModel([ModelResponse(content="Still works")]), + tools=ToolRegistry(), + context=ToolContext(tmp_path), + settings=RuntimeSettings(max_steps=3), + output=output, + hooks=hooks, + ) + + result = session.run("hello") + + assert result.content == "Still works" + assert "[hook:error] agent_start/broken: RuntimeError: observer failed" in output.getvalue() diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..9e88d80 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from minicode_rebuild.hooks import HookEvent, HookManager +from minicode_rebuild.tooling import ( + ToolContext, + ToolDefinition, + ToolRegistry, + ToolResult, +) + + +def _tool(handler=None) -> ToolDefinition: + return ToolDefinition( + name="sample", + description="Sample.", + input_schema={ + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": False, + }, + handler=handler or (lambda arguments, context: ToolResult.success("done")), + ) + + +def test_hooks_run_in_registration_order_with_read_only_snapshots() -> None: + hooks = HookManager() + calls: list[str] = [] + + def first(context) -> None: # type: ignore[no-untyped-def] + calls.append(context.data["value"]) + with pytest.raises(TypeError): + context.data["changed"] = True + + hooks.register(HookEvent.AGENT_START, first, name="first") + hooks.register( + HookEvent.AGENT_START, + lambda context: calls.append("second"), + name="second", + ) + + report = hooks.emit(HookEvent.AGENT_START, value="start") + + assert calls == ["start", "second"] + assert report.handlers == 2 + assert report.failures == () + + +def test_hook_failure_is_reported_and_does_not_skip_later_hook() -> None: + hooks = HookManager() + calls: list[str] = [] + hooks.register( + HookEvent.AGENT_STOP, + lambda context: (_ for _ in ()).throw(RuntimeError("boom")), + name="broken", + ) + hooks.register( + HookEvent.AGENT_STOP, + lambda context: calls.append("healthy"), + name="healthy", + ) + + report = hooks.emit(HookEvent.AGENT_STOP) + + assert calls == ["healthy"] + assert report.failures[0].name == "broken" + assert report.failures[0].error == "RuntimeError: boom" + assert hooks.reports() == (report,) + + +def test_tool_boundary_emits_before_and_after_hooks(tmp_path: Path) -> None: + hooks = HookManager() + events: list[tuple[HookEvent, object]] = [] + hooks.register( + HookEvent.BEFORE_TOOL, + lambda context: events.append((context.event, context.data["tool_name"])), + ) + hooks.register( + HookEvent.AFTER_TOOL, + lambda context: events.append((context.event, context.data["result"])), + ) + + result = ToolRegistry([_tool()]).execute( + "sample", {"value": "x"}, ToolContext(tmp_path, hooks=hooks) + ) + + assert result.ok is True + assert events[0] == (HookEvent.BEFORE_TOOL, "sample") + assert events[1][0] is HookEvent.AFTER_TOOL + assert events[1][1]["ok"] is True # type: ignore[index] + + +def test_failing_tool_hook_does_not_block_tool_and_is_observable(tmp_path: Path) -> None: + hooks = HookManager() + reports = [] + hooks.register( + HookEvent.BEFORE_TOOL, + lambda context: (_ for _ in ()).throw(ValueError("bad extension")), + name="bad-hook", + ) + context = ToolContext(tmp_path, hooks=hooks, hook_observer=reports.append) + + result = ToolRegistry([_tool()]).execute("sample", {"value": "x"}, context) + + assert result == ToolResult.success("done") + assert reports[0].failures[0].name == "bad-hook" diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..afefb21 --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from minicode_rebuild.hooks import HookContext, HookEvent, HookManager +from minicode_rebuild.observability import EventLog, format_timeline, register_event_log + + +def test_event_log_redacts_prompts_arguments_outputs_and_unknown_fields( + tmp_path: Path, +) -> None: + log = EventLog(tmp_path) + log.record( + HookContext( + HookEvent.BEFORE_TOOL, + { + "session_id": "not-allowed-here", + "tool_name": "read_file", + "arguments": {"path": "secret.txt", "token": "top-secret"}, + "user_message": "private prompt", + }, + ) + ) + log.record( + HookContext( + HookEvent.AFTER_TOOL, + { + "tool_name": "read_file", + "result": { + "ok": False, + "error_code": "read_error", + "output": "private file contents", + }, + }, + ) + ) + + raw = log.path.read_text(encoding="utf-8") + assert "read_file" in raw and "read_error" in raw + assert "secret.txt" not in raw + assert "top-secret" not in raw + assert "private prompt" not in raw + assert "private file contents" not in raw + + +def test_event_log_skips_corrupt_lines_and_limits_latest_events(tmp_path: Path) -> None: + log = EventLog(tmp_path) + log.path.parent.mkdir(parents=True) + log.path.write_text("not-json\n", encoding="utf-8") + for index in range(3): + log.record(HookContext(HookEvent.AGENT_STOP, {"stop_reason": str(index)})) + + events = log.read(limit=2) + + assert [event.data["stop_reason"] for event in events] == ["1", "2"] + assert "agent_stop" in format_timeline(events) + + +def test_registered_event_log_receives_all_lifecycle_events(tmp_path: Path) -> None: + hooks = HookManager() + log = EventLog(tmp_path) + register_event_log(hooks, log) + + for event in HookEvent: + hooks.emit(event, session_id="abc", tool_name="sample") + + assert [event.event for event in log.read()] == [event.value for event in HookEvent] + + +def test_event_log_is_valid_one_object_per_line(tmp_path: Path) -> None: + log = EventLog(tmp_path) + log.record(HookContext(HookEvent.SESSION_CREATE, {"session_id": "abc"})) + + payload = json.loads(log.path.read_text(encoding="utf-8")) + + assert payload["event"] == "session_create" + assert payload["data"] == {"session_id": "abc"} diff --git a/tests/test_readiness.py b/tests/test_readiness.py new file mode 100644 index 0000000..d77cb0f --- /dev/null +++ b/tests/test_readiness.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path + +from minicode_rebuild.readiness import check_readiness, format_readiness + + +def test_readiness_validates_provider_without_network(tmp_path: Path) -> None: + report = check_readiness( + tmp_path, + { + "OPENAI_API_KEY": "not-sent-anywhere", + "OPENAI_BASE_URL": "https://example.test/v1", + "MINICODE_MODEL": "demo-model", + }, + ) + + assert report.ready is True + output = format_readiness(report) + assert "model=demo-model" in output + assert "https://example.test/v1/chat/completions" in output + assert "not-sent-anywhere" not in output + + +def test_readiness_reports_missing_provider_key(tmp_path: Path) -> None: + report = check_readiness(tmp_path, {}, require_provider=True) + + assert report.ready is False + assert any( + check.name == "provider-config" and not check.ready + for check in report.checks + ) + + +def test_demo_readiness_does_not_require_provider_key(tmp_path: Path) -> None: + report = check_readiness(tmp_path, {}, require_provider=False) + + assert report.ready is True + assert "not required for demo" in format_readiness(report) diff --git a/tests/test_session.py b/tests/test_session.py index a7ef53e..e31b751 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -9,7 +9,6 @@ RewindConfirmationRequired, RewindConflictError, SessionFormatError, - SessionRecord, SessionStatsData, SessionStore, format_transcript, @@ -76,7 +75,7 @@ def test_checkpoint_path_escape_in_persisted_session_is_rejected(tmp_path: Path) store = SessionStore(tmp_path) record = store.create() target = tmp_path / "demo.txt" - checkpoint = store.record_checkpoint(record, target, None, "new", operation="write_file") + store.record_checkpoint(record, target, None, "new", operation="write_file") path = store.sessions_dir / f"{record.session_id}.json" data = path.read_text(encoding="utf-8").replace( '"path":"demo.txt"', '"path":"../outside.txt"' diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..058f8b2 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from pathlib import Path + +from minicode_rebuild.skills import SkillCatalog +from minicode_rebuild.tooling import ToolContext, ToolRegistry +from minicode_rebuild.tools import MUTATING_TOOLS, READ_ONLY_TOOLS, load_skill_tool + + +def _write_skill(workspace: Path, name: str, content: str) -> Path: + target = workspace / ".minicode" / "skills" / name / "SKILL.md" + target.parent.mkdir(parents=True) + target.write_text(content, encoding="utf-8") + return target + + +def test_discovery_returns_metadata_and_prompt_omits_full_content(tmp_path: Path) -> None: + _write_skill( + tmp_path, + "review", + "---\nname: review\ndescription: Review Python safely.\n---\n\n# Steps\nSECRET BODY\n", + ) + + catalog = SkillCatalog(tmp_path) + + assert [(item.name, item.description) for item in catalog.discover()] == [ + ("review", "Review Python safely.") + ] + prompt = catalog.prompt_summary() + assert "review: Review Python safely." in prompt + assert "SECRET BODY" not in prompt + + +def test_load_skill_tool_loads_only_the_named_skill(tmp_path: Path) -> None: + _write_skill(tmp_path, "one", "# One\n\nFIRST CONTENT") + _write_skill(tmp_path, "two", "# Two\n\nSECOND CONTENT") + catalog = SkillCatalog(tmp_path) + context = ToolContext(tmp_path, state={"skill_catalog": catalog}) + + result = ToolRegistry([load_skill_tool]).execute( + "load_skill", {"name": "one"}, context + ) + + assert result.ok is True + assert "FIRST CONTENT" in result.output + assert "SECOND CONTENT" not in result.output + + +def test_invalid_mismatched_and_traversal_skills_are_rejected(tmp_path: Path) -> None: + _write_skill( + tmp_path, + "wrong", + "---\nname: different\ndescription: mismatch\n---\n", + ) + catalog = SkillCatalog(tmp_path) + + assert catalog.discover() == () + result = ToolRegistry([load_skill_tool]).execute( + "load_skill", + {"name": "../outside"}, + ToolContext(tmp_path, state={"skill_catalog": catalog}), + ) + assert result.ok is False + assert result.error_code == "skill_error" + + +def test_model_tools_cannot_read_or_mutate_skill_storage(tmp_path: Path) -> None: + target = _write_skill(tmp_path, "safe", "# Safe\n\nInstructions") + read_result = ToolRegistry(READ_ONLY_TOOLS).execute( + "read_file", + {"path": ".minicode/skills/safe/SKILL.md"}, + ToolContext(tmp_path), + ) + write_result = ToolRegistry(MUTATING_TOOLS).execute( + "write_file", + {"path": ".minicode/skills/safe/SKILL.md", "content": "changed"}, + ToolContext(tmp_path), + ) + + assert read_result.error_code == "reserved_path" + assert write_result.error_code == "reserved_path" + assert target.read_text(encoding="utf-8") == "# Safe\n\nInstructions" diff --git a/tests/test_write_tools.py b/tests/test_write_tools.py index 6c35097..cab68f3 100644 --- a/tests/test_write_tools.py +++ b/tests/test_write_tools.py @@ -221,7 +221,8 @@ def test_denial_and_noop_do_not_record_checkpoint( ) -> None: target = tmp_path / "demo.txt" target.write_text("same", encoding="utf-8") - record = lambda *_args: pytest.fail("checkpoint must not be recorded") + def record(*_args): # type: ignore[no-untyped-def] + pytest.fail("checkpoint must not be recorded") noop = execute( registry,