diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f6fcce..7f129dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,10 @@ jobs: python-version: ["3.11", "3.12"] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - run: python -m pip install --no-deps . @@ -35,29 +35,29 @@ jobs: e2e-windows: runs-on: windows-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: scriptorium persist-credentials: false - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: scriptorium-suite/scriptorium-spec - ref: 9f044458a9ec106b7959a1ea961c40774081a1d2 + ref: 66bfefea93758231491fadc4694708d1b8107ea0 path: scriptorium-spec persist-credentials: false - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: scriptorium-suite/steward - ref: dcdb46726493bd13735b1c0f327331bbebee7047 + ref: 21e4e454fb97702bc5f31c24b805cf3c9489bc31 path: steward persist-credentials: false - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: foxsplendid/Provenance - ref: 4addf490526840ef9220d8d13f15832f6df4d346 + ref: 910efbc47e604c51314866174581cfdd7eac47b6 path: Provenance persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install source checkouts @@ -188,7 +188,7 @@ jobs: --provenance-root .\Provenance .\.demo-venv\Scripts\python.exe -m json.tool '.\demo 输出\demo-report.json' .\.demo-venv\Scripts\python.exe -c "import json, pathlib; report=json.loads(pathlib.Path(r'.\demo 输出\demo-report.json').read_text(encoding='utf-8')); assert report['demo_status'] == 'passed'" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: scriptorium-demo-windows path: | diff --git a/CHANGELOG.md b/CHANGELOG.md index eee16d0..7206b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.1.0 — Unreleased +## 0.1.0 — 2026-07-20 - Add `scriptorium inventory`, a deterministic, zero-write preview for explicitly selected Markdown/PDF sources, AI conversation exports, and Zotero exports. It @@ -9,8 +9,10 @@ It does not discover personal folders, persist a manifest, ingest, copy, or apply a migration. - Add the first thin suite entry with `scriptorium demo`. -- Add a credential-free, offline synthetic golden path through the canonical - spec validator, Steward review workflow, and Provenance ingest/search/MCP APIs. +- Add a credential-free synthetic golden path through the canonical spec validator, + Steward review workflow, and Provenance ingest/search/MCP APIs. After source + installation, the path requests no suite-managed runtime network action; source + installation itself may fetch declared build requirements. - Add an isolated Markdown workspace, compatibility manifest, machine-readable demo report, Windows end-to-end CI, and cross-platform unit tests. - Add read-only `doctor` targets for Demo and Public Alpha readiness, with stable @@ -35,6 +37,12 @@ identifiers, research content, stderr, or an implicit `--run` authorization. It authorizes no suite project/data writes while accurately disclosing external readiness probes. +- Report content-free path-selection provenance across `doctor`, `pull`, and + `status`; warn when environment roots conflict with suite config and fail closed + before `pull --run` unless explicit CLI roots remove the ambiguity. Treat a + configured but missing `CODEX_HOME` as zero sessions plus setup remediation, + without creating the directory. Explicit component-root environment variables + also fail closed instead of silently falling back to a different checkout. - Preserve local home and temporary-directory variables in the secret-stripped diagnostic subprocess environment so installed Windows Provenance entries can resolve their profile without inheriting provider credentials. diff --git a/README.md b/README.md index 9ffbfcd..e47a4e2 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,24 @@ # Scriptorium -> **Public Alpha candidate:** the umbrella repository ships safe initialization for +> **Public Alpha v0.1.0:** the umbrella repository ships safe initialization for > a real project, one synthetic vertical slice through `scriptorium demo`, the > read-only `scriptorium doctor`, a content-free `scriptorium status` control-plane > summary, a zero-write `scriptorium inventory` preview for explicitly selected > local sources, > and explicit project-scoped Codex and Claude Code skill installers. It also ships > the accepted on-demand `scriptorium pull` entry through Provenance's -> machine-readable public command. Clean-machine release validation is still pending. +> machine-readable public command. GitHub-hosted clean Windows CI and an isolated +> Windows source-install acceptance run pass; live Agent-host parity and Lectern +> remain outside the credential-free golden path. Scriptorium is a local-first, agent-native research workflow suite. This repository is its thin control plane: it coordinates independently useful components through public commands and versioned files without importing their internals or owning their research data. -[中文说明](README.zh.md) · [Contract source of truth](https://github.com/scriptorium-suite/scriptorium-spec) · [Steward](https://github.com/scriptorium-suite/steward) · [Provenance](https://github.com/foxsplendid/Provenance) · [Design inspirations](ACKNOWLEDGEMENTS.md) +[中文说明](README.zh.md) · [中文产品案例](docs/case-study.zh-CN.md) · [Showcase evidence](docs/showcase/README.zh-CN.md) · [Contract source of truth](https://github.com/scriptorium-suite/scriptorium-spec) · [Design inspirations](ACKNOWLEDGEMENTS.md) + +![Scriptorium Public Alpha synthetic golden-path evidence](docs/showcase/demo-poster.svg) ## What works now @@ -28,6 +32,15 @@ init does not request network access or read provider credentials. After init, selection from the suite config when higher-precedence CLI flags or environment variables are absent. +`doctor`, `status`, and `pull` report whether each root came from the CLI, +environment, suite config, or auto-discovery. `status` and `pull` reports +do not echo selected paths; `doctor` is a detailed local diagnostic and its report +contains resolved paths, so review it before sharing. When an environment root +differs from suite config they emit a visible warning; `pull --run` then fails closed +until the user supplies explicit CLI roots. A selected but unavailable Codex log home +is treated as zero sessions plus an actionable setup cue, not as an internal error, +and the directory is never created implicitly. + `scriptorium demo` creates an isolated Markdown workspace and runs a synthetic AI4Science literature workflow through the real public interfaces: @@ -43,7 +56,7 @@ Once the source checkouts are installed, this demo path is designed to operate without a network action. It does not call a live model: the agent-written draft is an explicitly labelled synthetic fixture. The report states that network behavior is policy-constrained rather than observed by an OS-level sandbox. -Passing this demo proves deterministic component integration, not full Public +Passing this demo proves functionally repeatable component integration, not full Public Alpha readiness or scientific validity. `scriptorium doctor` separately checks installation and capability readiness. It @@ -110,6 +123,11 @@ python -m venv .venv .\.venv\Scripts\python.exe -m pip install --no-deps -e ..\Provenance ``` +Depending on the local Python environment, editable installation may contact the +configured package index to obtain declared build requirements such as +`setuptools>=68`. The no-runtime-network statement applies only after source +installation is complete; it is not an offline-install guarantee. + ### Optional: preview existing local sources Pass only local files or directories that you intentionally selected. Zotero and @@ -242,8 +260,8 @@ scriptorium demo ``` Re-running against an output directory carrying the Scriptorium demo marker is -idempotent. A non-empty directory without that marker is rejected and never -overwritten. +functionally idempotent. Timestamp-bearing generated records may differ byte for +byte. A non-empty directory without that marker is rejected and never overwritten. ## Agent host adapters @@ -379,23 +397,25 @@ the current report. ## Compatibility baseline -The first golden path intentionally locks exact source versions: +The first golden path intentionally locks exact source versions as its coordinated +Public Alpha release targets: -- `scriptorium-spec` 2.2.0 release candidate -- Steward 0.2.0 unreleased +- `scriptorium-spec` 2.2.0 +- Steward 0.2.0 - Provenance 0.17.0 -This source-based constraint is temporary and explicit. Tagged releases and a -range-based compatibility policy belong to the later Public Alpha release step. +The demo and CI workflows continue to pin exact component commits. A range-based +compatibility policy is intentionally deferred until external Alpha usage provides +evidence for safe ranges. ## Next product increments 1. add a compact project context-capsule/resume entry over Provenance MCP, distinct from the content-free control-plane `status`; -2. add uniform side-effect-free version probes and profile compatibility facts; -3. add an explicitly reviewed, adapter-specific migration manifest and apply path; -4. add schema-driven cross-repository E2E for Lectern handoff; -5. verify installation and the golden path on a clean Windows environment; -6. align screenshots, release notes, tags, and compatibility ranges for Public Alpha. +2. add an explicitly reviewed, adapter-specific migration manifest and apply path; +3. add schema-driven cross-repository E2E for Lectern handoff; +4. verify live Claude Code `SessionEnd` parity with the Codex capture path; +5. run an external-user Alpha and use the evidence to shape packaging and + compatibility ranges. Apache-2.0. No telemetry. diff --git a/README.zh.md b/README.zh.md index af29ff1..7a018a0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,17 +1,20 @@ # Scriptorium -> **Public Alpha 候选版:** 当前 umbrella 仓库已交付真实项目的安全初始化、一个 +> **Public Alpha v0.1.0:** 当前 umbrella 仓库已交付真实项目的安全初始化、一个 > 通过 `scriptorium demo` 跑通的合成纵向切片、只读诊断入口 `scriptorium doctor`、 > 不暴露内容的 `scriptorium status` 控制面状态摘要、仅盘点显式本地来源且零写入的 > `scriptorium inventory` 预览,以及显式、项目级的 Codex / > Claude Code 技能安装器。已确认的按需入口 `scriptorium pull` 也已通过 Provenance -> 的机器可读公共命令交付;干净机器发布验收仍待完成。 +> 的机器可读公共命令交付。GitHub-hosted 干净 Windows CI 与隔离的 Windows 源码 +> 安装验收已通过;Agent host 实时对等路径与 Lectern 仍不属于无凭据黄金路径。 Scriptorium 是面向科研工作者的本地优先、Agent 原生研究工作流套件。本仓库是 它的薄控制面:通过公开 CLI 与版本化文件编排可独立使用的组件,不导入组件内部 模块,也不成为论文库、项目笔记或科研记忆的新数据主库。 -[English](README.md) · [契约单一事实源](https://github.com/scriptorium-suite/scriptorium-spec) · [Steward](https://github.com/scriptorium-suite/steward) · [Provenance](https://github.com/foxsplendid/Provenance) · [设计借鉴与致谢](ACKNOWLEDGEMENTS.zh.md) +[English](README.md) · [中文产品案例](docs/case-study.zh-CN.md) · [展示与证据](docs/showcase/README.zh-CN.md) · [契约单一事实源](https://github.com/scriptorium-suite/scriptorium-spec) · [设计借鉴与致谢](ACKNOWLEDGEMENTS.zh.md) + +![Scriptorium Public Alpha 合成黄金路径证据](docs/showcase/demo-poster.svg) ## 当前真正可用的内容 @@ -23,6 +26,13 @@ Markdown workspace 与 Provenance 数据根目录,以及一份有效的 `proje `host install`、`doctor`、`status` 和 `pull` 可以从套件配置解析相应的 workspace、数据根与项目选择。 +`doctor`、`status` 和 `pull` 会说明各根目录来自 CLI、环境变量、套件配置还是自动发现。 +`status` 与 `pull` 报告不会回显实际路径;`doctor` 是详细的本地诊断,其报告包含 +已解析路径,分享前必须审阅。若环境变量选择与套件配置不一致,命令会显示显著警告; +`pull --run` 将 fail closed,直到用户用显式 CLI 根目录消除歧义。所选 Codex 日志目录 +不可用时,会被视为“0 个会话 + 可执行的设置提示”,而不是内部错误;套件也不会隐式 +创建该目录。 + `scriptorium demo` 会建立一个隔离的 Markdown 工作区,并通过真实公开接口跑通 一条合成 AI4Science 文献工作流: @@ -91,6 +101,10 @@ python -m venv .venv .\.venv\Scripts\python.exe -m pip install --no-deps -e ..\Provenance ``` +根据本地 Python 环境,editable 安装可能访问已配置的包索引以取得 +`setuptools>=68` 等声明的构建依赖。“运行期不申请网络动作”只适用于源码安装完成后, +不代表离线安装保证。 + ### 可选:预览已有本地资料 只传入你主动选择的本地文件或目录。Zotero 与对话导出均为可选;实时 Zotero 数据库 @@ -212,8 +226,8 @@ scriptorium doctor --target demo scriptorium demo ``` -对带有 Scriptorium demo 标记的目录重复运行是幂等的;非空但没有该标记的目录 -会被拒绝,不会覆盖用户文件。 +对带有 Scriptorium demo 标记的目录重复运行在功能上是幂等的;带摄取时间戳的生成记录 +可能不会逐字节一致。非空但没有该标记的目录会被拒绝,不会覆盖用户文件。 ## Agent 宿主适配 @@ -336,21 +350,21 @@ scriptorium-demo/ ## 当前兼容基线 -- `scriptorium-spec` 2.2.0 release candidate -- Steward 0.2.0 unreleased +- `scriptorium-spec` 2.2.0 +- Steward 0.2.0 - Provenance 0.17.0 -首个 golden path 暂时锁定精确源码版本,这是主动暴露的 Alpha 约束。正式 tag 与 -版本范围兼容策略留到 Public Alpha 发布阶段完成。 +首个 golden path 将以上版本作为协同 Public Alpha 的发布目标,并在 demo 与 CI 中 +继续固定精确组件提交。版本范围兼容策略暂不靠猜测定义,而是在获得外部 Alpha +使用证据后再确定。 ## 紧邻的产品增量 1. 在 Provenance MCP 之上增加精炼的项目 context-capsule/resume 入口,并与不暴露 内容的控制面 `status` 明确区分; -2. 为组件增加统一、无副作用的版本探针与 profile 兼容事实; -3. 增加需要显式人审的适配器级迁移清单与执行路径; -4. 补齐 Lectern handoff 的 schema 驱动跨仓 E2E; -5. 在干净 Windows 环境完成首装和 golden path 验收; -6. 对齐 Public Alpha 的截图、发布说明、tag 与兼容版本范围。 +2. 增加需要显式人审的适配器级迁移清单与执行路径; +3. 补齐 Lectern handoff 的 schema 驱动跨仓 E2E; +4. 验证 Claude Code `SessionEnd` 与 Codex 捕获路径的实时对等性; +5. 开展外部用户 Alpha,用实测结果决定安装打包与兼容版本范围。 Apache-2.0,无遥测。 diff --git a/docs/case-study.zh-CN.md b/docs/case-study.zh-CN.md new file mode 100644 index 0000000..a8de1d9 --- /dev/null +++ b/docs/case-study.zh-CN.md @@ -0,0 +1,162 @@ +# Scriptorium:把科研 Agent demo 做成可验证的本地系统 + +> 项目状态:Public Alpha · Windows-first · 本地优先 · Agent 原生 +> 这是一份产品与工程案例,不是科研成果声明。 + +![Scriptorium Public Alpha 合成黄金路径验收](showcase/demo-poster.svg) + +## 30 秒看结果 + +Scriptorium 解决的不是“再做一个聊天框”,而是一个更基础的问题:科研工作者与 Agent +协作后,研究问题、证据、决定和下一步如何跨会话延续,并且仍由用户掌握事实所有权。 + +当前 Public Alpha 交付了一条可复现的合成纵向链路: + +- 3 条明确标注为合成的文献记录,其中 2 条命中研究边界,1 条负例被排除; +- 通过公开 CLI 与版本化文件依次运行 Spec、Steward 和 Provenance; +- 9 个跨组件阶段全部返回 `exit 0`; +- 契约、主题边界、综述、记忆、检索和 MCP 共 6 项机器断言通过; +- 生成 9 个可检查产物,包括 Markdown 项目、综述、检索结果和本地 SQLite FTS5 索引。 + +证据不是宣传数字:[机器报告](showcase/evidence/demo-report.json)、 +[证据清单](showcase/evidence-manifest.json)和 +[Windows 隔离验收摘要](showcase/evidence/windows-uat-summary.json)可以交叉核验。 +干净根替换后的远端发布门禁已经完成:经验证的代码提交 +[`8069cbf1`](https://github.com/scriptorium-suite/scriptorium/commit/8069cbf1a2de4b31e8239740368799ff9519294e)、 +[PR CI `29818934741`](https://github.com/scriptorium-suite/scriptorium/actions/runs/29818934741)与 +[合成证据 artifact `8490533423`](https://github.com/scriptorium-suite/scriptorium/actions/runs/29818934741/artifacts/8490533423) +相互对应。旧提交对象的 GitHub 服务端垃圾回收与缓存清理仍在单独处理。 + +## 用户与待完成任务 + +目标用户是有一定 GitHub 开源项目配置能力、希望使用 AI4Science 提质增效的科研工作者。 +他们可能只有模糊直觉,也可能已有 Markdown、PDF、Agent 对话或 Zotero 积累。 + +核心待完成任务是: + +> 在不交出本地资料控制权的前提下,把零散研究输入变成可恢复、可追溯、可以继续执行的 +> 科研项目,并从同一研究状态派生论文结构、阶段总结和汇报材料。 + +首发选择 Windows;Codex 与 Claude Code 是一等 Agent host。Zotero、Obsidian 和 Lectern +保持可选,避免把个人工具偏好变成产品硬依赖。 + +## 约束先于功能 + +| 产品原则 | 落地方式 | +|---|---| +| 本地资料属于用户 | Markdown、PDF、代码和运行数据留在用户选择的目录;套件不成为新的云端主库 | +| 文件契约而非内部耦合 | 组件只交换版本化 JSON/Markdown 文件,不导入彼此内部模块 | +| 写操作必须可见 | `preview`/`--run` 分离,已有文件不静默覆盖 | +| AI 输出不是事实 | 高价值声明先进入候选与审批区,用户确认后才能进入权威记忆 | +| 恢复能力优先 | 事件追加、锁、幂等和可恢复状态机优先于“全自动”叙事 | +| 隐私声明必须可验证 | demo 使用全合成数据、剥离凭据,并在报告中主动记录未验证边界 | + +## 为什么拆成薄入口与独立组件 + +```mermaid +flowchart LR + U["研究者选择的本地资料"] --> S["Scriptorium\n薄控制面"] + S --> C["scriptorium-spec\n契约单一事实源"] + S --> W["Steward\n文献治理"] + S --> P["Provenance\n项目记忆与证据"] + W -->|"versioned files"| P + W -. "optional handoff" .-> L["Lectern\n可编辑汇报"] + P --> A["Codex / Claude Code\n按需读取与人审"] +``` + +这个拆分服务两个产品目标:组件可以独立部署;同时,用户仍有一个统一入口检查安装、 +初始化项目、查看状态和触发按需同步。代价是跨仓版本与契约治理更严格,因此 +`scriptorium-spec` 被设为唯一契约事实源,CI 使用固定组件提交验证纵向链路。 + +## 一条真实接口、全合成数据的 walkthrough + +1. `scriptorium doctor --target demo` 检查组件版本与能力,不把“源码存在”误报为可运行。 +2. `scriptorium demo` 校验 `library-kb/1.1`,并调用 Steward 形成主题综述。 +3. 主题边界负例验证相似名称不会被错误纳入研究方向。 +4. Provenance 摄取合成文献与项目 Markdown,构建本地 FTS5 索引。 +5. demo 通过只读 MCP 检查 portfolio、current context 与 literature search。 +6. `demo-report.json` 固化版本、阶段、断言、产物和限制,使结果可重复核验。 + +这里没有 API Key、真实 Zotero、真实对话、在线模型或私人研究资料。Agent 草稿是仓库内 +明确标注的 fixture。Lectern 也没有被算入本条黄金路径,因为生成幻灯片需要独立配置的 +provider 路径。重复运行在功能上幂等,但带摄取时间戳的记录不承诺逐字节一致。 + +## 安全机制如何进入产品细节 + +- 初始化和数据操作默认只预览,只有显式 `--run` 才授权写入; +- workspace 与 Provenance 数据根必须分离,输出目录需要所有权标记; +- Windows 路径校验覆盖 symlink、junction/reparse、8.3 短路径和竞态; +- 子进程只继承最小环境白名单,不继承 provider 凭据; +- `status` 与 `inventory` 只返回白名单化聚合信息,不回显路径、文件名或研究正文; +- 未解析项目不会生成 `session-summary/1.0`,而是留在可恢复的待解析状态; +- 高价值声明必须经过人工勾选,Agent 不能自行把建议提升为项目事实。 + +## 验证矩阵 + +| 要证明的能力 | 公开证据 | 当前结论 | +|---|---|---| +| Windows 首装与源码安装 | [隔离验收摘要](showcase/evidence/windows-uat-summary.json) + [GitHub-hosted clean Windows E2E](https://github.com/scriptorium-suite/scriptorium/actions/runs/29818934741) | 通过 | +| Python 兼容 | Windows / Ubuntu,Python 3.11 / 3.12 | 通过 | +| 跨组件契约 | 9 阶段机器报告与固定版本基线 | 通过 | +| 无凭据合成 demo | 报告 `credentials_requested: []` | 通过 | +| 当前旗舰证据包未检出私人内容 | 合成 fixture、历史/路径/邮箱/secret 扫描与人工复核 | 通过(限本发布候选) | +| 远端候选证据链 | commit `8069cbf1` / run `29818934741` / artifact `8490533423` | 通过;旧悬空对象待 GitHub Support 清理 | +| 完整 Agent 工作流用户价值 | 尚无外部 beta 行为数据 | 未验证 | +| Claude Code SessionEnd 对等路径 | 仍缺 live golden-path 证据 | 未验证 | +| Lectern 跨仓生成 | 不属于当前 credential-free demo | 未验证 | + +“未验证”不是隐藏在脚注里的免责声明,而是下一阶段产品判断的输入。 + +## 我在项目中的职责 + +该项目由本人发起并作为产品与技术负责人推进,采用 Agent 协作开发。公开仓库可以验证的 +职责包括: + +- 定义目标用户、JTBD、Windows-first 范围以及可选组件边界; +- 将“本地优先、人审、可恢复”转译为 CLI 权限模型和文件契约; +- 拆分统一入口与三个可独立产品,维护生产者/消费者和版本兼容关系; +- 设计合成黄金路径、负例、机器报告、Windows/Linux CI 与隐私发布门禁; +- 记录竞品与开源思路借鉴,同时保持独立实现和许可证边界; +- 把未完成项、已验证事实和产品主张分开表达。 + +这组工作希望展示的不是“功能数量”,而是 AI 产品经理在问题定义、边界设计、技术协同、 +风险管理和可验证交付之间建立闭环的能力。 + +## 权衡与尚未完成 + +- 当前以 GitHub 源码安装为主,还没有 PyPI/winget 或图形安装器; +- editable 源码安装可能从已配置的包索引下载 Python 构建依赖;“运行期不申请网络动作” + 不是离线安装承诺; +- `inventory` 当前只做不读取正文的安全盘点与路由,实际迁移仍缺受审阅的 + manifest/apply 闭环; +- 完整 Public Alpha 需要用户已经安装 Codex 或 Claude Code,并主动安装项目级 Skill; +- Claude Code `SessionEnd` live 对等验证、Lectern schema-driven E2E 与外部用户 beta 仍待完成; +- demo 的“无网络动作”来自实现与环境白名单,尚未由操作系统级网络沙箱观测; +- 尚无真实用户效率数据,因此不宣称已经证明科研质量或节省了确定比例的时间。 + +下一阶段优先验证三个指标:首次成功运行耗时、下一次会话恢复上下文的人工可接受率、 +以及高价值候选声明的确认/驳回与冲突率。目标值会在获得外部 beta 基线后确定,不用臆测 +数字代替研究。 + +## 复现 + +按[中文 README](../README.zh.md#windows-源码快速体验)克隆四个仓库并安装后运行: + +```powershell +.\.venv\Scripts\scriptorium.exe doctor ` + --target demo ` + --spec-root ..\scriptorium-spec ` + --steward-root ..\steward ` + --provenance-root ..\Provenance + +.\.venv\Scripts\scriptorium.exe demo ` + --output .\scriptorium-demo ` + --spec-root ..\scriptorium-spec ` + --steward-root ..\steward ` + --provenance-root ..\Provenance +``` + +预期结果是 9 个阶段均为 `exit 0`,`demo-report.json` 中 6 项断言为 `true`。请先阅读 +[录制与证据边界](showcase/README.zh-CN.md),不要把真实科研资料用于公开演示。 + +开源思路借鉴与独立实现边界见[设计借鉴与致谢](../ACKNOWLEDGEMENTS.zh.md)。 diff --git a/docs/showcase/README.zh-CN.md b/docs/showcase/README.zh-CN.md new file mode 100644 index 0000000..e278511 --- /dev/null +++ b/docs/showcase/README.zh-CN.md @@ -0,0 +1,57 @@ +# Public Alpha 展示与录制说明 + +本目录只承载可公开、可复现的**合成 demo** 证据。它不能包含真实论文、Zotero 库、 +Agent 对话、用户名、本机路径、终端历史、通知、环境变量值或 API Key。 + +## 当前材料 + +- [`demo-poster.svg`](demo-poster.svg):从机器报告派生的展示图,不是产品 UI 截图; +- [`evidence-manifest.json`](evidence-manifest.json):来源提交、CI、版本、哈希和隐私声明; +- [`evidence/demo-report.json`](evidence/demo-report.json):Windows E2E 产生的机器报告快照。 +- [`evidence/windows-uat-summary.json`](evidence/windows-uat-summary.json):隔离 Windows + 源码安装、P1 安全回归与已知边界的脱敏验收摘要。 + +## 90 秒视频分镜 + +| 时间 | 画面 | 旁白重点 | +|---|---|---| +| 0–8 秒 | 标题与痛点 | Scriptorium 解决跨会话研究连续性,不是新的聊天框 | +| 8–18 秒 | 文件契约架构图 | 组件不互调内部代码,每类事实只有一个 master | +| 18–38 秒 | 真实运行 `doctor` 与 `demo` | 无 API Key、Zotero、Obsidian 或在线模型 | +| 38–52 秒 | Steward 综述 | 3 条记录中选中 2 条,主题负例被排除 | +| 52–66 秒 | Provenance 搜索与 MCP | 展示本地 FTS5 命中和 current-context | +| 66–78 秒 | `demo-report.json` | 9 阶段、6 断言、9 产物和固定版本 | +| 78–90 秒 | 限制与 CTA | 不代表科学结论;Agent host、Lectern、OS 网络隔离未由该 demo 验证 | + +视频建议作为 GitHub Release asset 发布,仓库只保存轻量 poster 与可核验证据,避免把大体积 +视频写入 Git 历史。 + +## 真实截图清单 + +1. `doctor --target demo` 的版本与能力就绪结果; +2. `demo` 实际阶段与完成行; +3. 生成后的 workspace / provenance / report 文件树; +4. Steward 综述中的两篇文献、负例排除说明和权威引用表; +5. Provenance 搜索命中与 MCP current-context; +6. 报告中的 `passed`、合成标记、断言和 limitations。 + +截图必须来自专用合成目录。原始截图与带标注版本分开保存;裁剪、加速或省略步骤必须明确 +标注。不要把 Lectern 仓库内的 vendored exemplar 当作本项目运行证据;Lectern 需要另行 +生成并打开真实 synthetic `.pptx`,显示可编辑对象后才能纳入展示。 + +## 录制前门禁 + +- 使用新建 Windows 账号、Windows Sandbox 或等价的干净虚拟环境; +- 显式清空或重定向 `PROVENANCE_*`、`SCRIPTORIUM_*`、Agent home 与 provider 凭据变量; +- 只克隆公开仓库,并用无凭据 HTTPS 验证可访问性; +- 录制前确认路径中不含真实用户名,桌面没有通知或私人窗口; +- 运行隐私扫描并核对 `evidence-manifest.json`; +- 若任何命令解析到合成根之外,立即停止,不继续摄取或写入。 + +## 不得使用的表述 + +- “全自动科研操作系统”——当前是按需 pull、人审驱动的 Public Alpha; +- “完全离线、绝不联网”——只能陈述本 demo 不实现网络动作,OS 级出站未观测; +- “已完成真实科研”——这里只验证工程链路,不验证科学结论; +- “Codex 与 Claude Code 完全对等”——Claude live SessionEnd 路径仍缺证据; +- “Lectern 已在旗舰 demo 跑通”——当前 demo 明确未调用 Lectern。 diff --git a/docs/showcase/demo-poster.svg b/docs/showcase/demo-poster.svg new file mode 100644 index 0000000..eb610ed --- /dev/null +++ b/docs/showcase/demo-poster.svg @@ -0,0 +1,83 @@ + + Scriptorium Public Alpha verified synthetic demo + A local-first research workflow product card showing three matched components, nine successful stages, six passed assertions, and zero credentials or private paths. + + + + + + + + + + + + + + + + + + + + + + + + SCRIPTORIUM / PUBLIC ALPHA + + 研究上下文, + 不随一次对话消失。 + 把模糊想法、文献与 Agent 协作过程,沉淀为可恢复、 + 可追溯、可继续推进的科研项目。 + + + + 0102030405 + + + 科研直觉文献证据研究状态项目记忆论文 / 汇报 + + + + + + + LOCAL-FIRST + FILE CONTRACTS + HUMAN-GATED + MODEL-NEUTRAL + + + + + + + VERIFIED SYNTHETIC DEMO + commit 8069cbf1 · PR CI #29818934741 · PASS + artifact #8490533423 · synthetic only + + + 3/396 + + + 组件版本与兼容基线完全匹配 + 跨组件黄金路径阶段全部 exit 0 + 契约、综述、记忆、检索与 MCP 断言通过 + + + + + + 0 + API CREDENTIALS + 0 + PRIVATE PATHS + + + + SYNTHETIC · VERIFIED + + + 可复现工程验收,不代表科学结论或外部用户价值已经验证。 + diff --git a/docs/showcase/evidence-manifest.json b/docs/showcase/evidence-manifest.json new file mode 100644 index 0000000..63463a5 --- /dev/null +++ b/docs/showcase/evidence-manifest.json @@ -0,0 +1,89 @@ +{ + "format_version": 1, + "evidence_type": "scriptorium-public-alpha-synthetic-demo", + "evidence_state": "remote-ci-verified-release-candidate", + "observed_on": "2026-07-21", + "source": { + "repository": "scriptorium-suite/scriptorium", + "commit": "8069cbf1a2de4b31e8239740368799ff9519294e", + "workflow_run": 29818934741, + "workflow_run_url": "https://github.com/scriptorium-suite/scriptorium/actions/runs/29818934741", + "runner": "GitHub Actions windows-latest (pull_request)", + "python": "3.12", + "remote_ci_status": "passed" + }, + "components": { + "scriptorium": { + "version": "0.1.0", + "commit": "8069cbf1a2de4b31e8239740368799ff9519294e", + "state": "verified remote commit" + }, + "scriptorium-spec": { + "version": "2.2.0", + "commit": "66bfefea93758231491fadc4694708d1b8107ea0", + "history": "parentless sanitized root" + }, + "steward": { + "version": "0.2.0", + "commit": "21e4e454fb97702bc5f31c24b805cf3c9489bc31" + }, + "provenance": { + "version": "0.17.0", + "commit": "910efbc47e604c51314866174581cfdd7eac47b6" + }, + "lectern": { + "version": "pre-release", + "commit": "bb461db4a5eee3d5ed37f52a7f6c2c3e4203df42", + "history": "parentless sanitized root", + "invoked_by_demo": false + } + }, + "evidence": { + "demo_report": { + "path": "evidence/demo-report.json", + "source_sha256": "0d03e735238b864b502063153549cb97b47a993e18daf01140c10a5c7b0e2093", + "snapshot_sha256": "0d03e735238b864b502063153549cb97b47a993e18daf01140c10a5c7b0e2093" + }, + "windows_acceptance": { + "path": "evidence/windows-uat-summary.json", + "snapshot_sha256": "33fbcb398a7b663d368e6c55a64cd80f35721d9fa02211cbb2587a12f7086559" + }, + "remote_ci": { + "workflow_run": 29818934741, + "workflow_run_url": "https://github.com/scriptorium-suite/scriptorium/actions/runs/29818934741", + "artifact_id": 8490533423, + "artifact_name": "scriptorium-demo-windows", + "artifact_demo_report_sha256": "0d03e735238b864b502063153549cb97b47a993e18daf01140c10a5c7b0e2093" + }, + "derived_visual": { + "path": "demo-poster.svg", + "claims": [ + "three runtime component versions matched", + "nine stages exited zero", + "six assertions passed", + "no credentials requested" + ] + } + }, + "privacy": { + "synthetic_only": true, + "private_data_used": false, + "live_model_invoked": false, + "provider_credentials_requested": false, + "private_paths_present": false, + "history_path_email_secret_scan_passed": true, + "scan_scope": "current public refs, release-candidate tree, and artifact 8490533423", + "superseded_commit_object_cleanup_pending": true, + "os_network_isolation_observed": false + }, + "release_blockers": [ + "GitHub Support cleanup remains pending for two superseded commit objects that are no longer referenced by public branches or Actions runs but remain directly addressable by SHA." + ], + "limitations": [ + "The evidence verifies a credential-free synthetic integration path, not scientific validity.", + "Codex and Claude Code hosts were not invoked by the demo.", + "Lectern was not invoked by the demo.", + "Network and filesystem isolation were policy-constrained, not observed by an OS-level sandbox.", + "GitHub may retain superseded commit objects until server-side garbage collection and cached-view removal." + ] +} diff --git a/docs/showcase/evidence/demo-report.json b/docs/showcase/evidence/demo-report.json new file mode 100644 index 0000000..7068d73 --- /dev/null +++ b/docs/showcase/evidence/demo-report.json @@ -0,0 +1,117 @@ +{ + "format_version": 1, + "generated_by": { + "name": "scriptorium", + "version": "0.1.0" + }, + "demo_status": "passed", + "suite_readiness": { + "status": "not-verified", + "reason_codes": [ + "agent-host-not-invoked", + "lectern-not-invoked" + ] + }, + "safety": { + "synthetic_data": true, + "agent_step": "recorded fixture; no live agent invoked", + "credentials_requested": [], + "network_policy": "this demo path implements no network action", + "network_observation": "not measured by an OS-level sandbox", + "config_policy": "documented component homes are redirected under the output root" + }, + "components": { + "scriptorium-spec": { + "expected_version": "2.2.0", + "source_version": "2.2.0", + "runtime_version": "2.2.0", + "compatibility": "matched" + }, + "steward": { + "expected_version": "0.2.0", + "source_version": "0.2.0", + "runtime_version": "0.2.0", + "compatibility": "matched" + }, + "provenance": { + "expected_version": "0.17.0", + "source_version": "0.17.0", + "runtime_version": "0.17.0", + "compatibility": "matched" + } + }, + "stages": [ + { + "name": "validate library-kb/1.1", + "exit_code": 0 + }, + { + "name": "check Steward version", + "exit_code": 0 + }, + { + "name": "Steward review scaffold", + "exit_code": 0 + }, + { + "name": "Steward review assemble", + "exit_code": 0 + }, + { + "name": "Provenance library ingest", + "exit_code": 0 + }, + { + "name": "Provenance project ingest", + "exit_code": 0 + }, + { + "name": "Provenance search build", + "exit_code": 0 + }, + { + "name": "Provenance literature search", + "exit_code": 0 + }, + { + "name": "Provenance MCP context", + "exit_code": 0 + } + ], + "assertions": { + "contract_validated": true, + "steward_topic_boundary": true, + "review_assembled": true, + "memory_ingested": true, + "search_hit_verified": true, + "mcp_context_verified": true + }, + "artifacts": [ + "fixtures/library-kb.v1.1.json", + "workspace/Projects/synthetic-catalyst-discovery.md", + "workspace/Reviews/ai4science-materials.md", + "workspace/Reports/provenance-search.txt", + "workspace/Reports/provenance-mcp.jsonl", + "provenance/memory/library.json", + "provenance/memory/projects.json", + "provenance/search-index.db", + "demo-report.json" + ], + "expected_artifacts": [ + "fixtures/library-kb.v1.1.json", + "workspace/Projects/synthetic-catalyst-discovery.md", + "workspace/Reviews/ai4science-materials.md", + "workspace/Reports/provenance-search.txt", + "workspace/Reports/provenance-mcp.jsonl", + "provenance/memory/library.json", + "provenance/memory/projects.json", + "provenance/search-index.db", + "demo-report.json" + ], + "limitations": [ + "A successful demo does not assert full Public Alpha readiness.", + "Codex and Claude Code hosts are not invoked by the credential-free demo.", + "Lectern is not invoked because slide generation requires a separately configured provider path.", + "Network and filesystem isolation are policy-constrained here, not observed by an OS-level sandbox." + ] +} diff --git a/docs/showcase/evidence/windows-uat-summary.json b/docs/showcase/evidence/windows-uat-summary.json new file mode 100644 index 0000000..447fbc8 --- /dev/null +++ b/docs/showcase/evidence/windows-uat-summary.json @@ -0,0 +1,69 @@ +{ + "format_version": 1, + "evidence_type": "scriptorium-public-alpha-windows-source-acceptance", + "observed_on": "2026-07-20", + "environment": { + "os": "Windows 11", + "python": "3.12.3", + "git": "2.52", + "installation": "editable source install in an isolated virtual environment" + }, + "privacy": { + "synthetic_only": true, + "private_data_used": false, + "provider_credentials_present": false, + "live_model_invoked": false, + "upload_performed": false + }, + "isolated_acceptance": { + "public_repositories_cloned_without_credentials": "passed", + "component_versions": { + "scriptorium": "0.1.0", + "steward": "0.2.0", + "provenance": "0.17.0" + }, + "init_preview_zero_write": "passed", + "init_run_separate_workspace_and_data_root": "passed", + "codex_project_skill_install_and_rerun": "passed", + "doctor_public_alpha": "ready", + "pull_preview_zero_write": "passed", + "pull_run_synthetic_root_only": "passed", + "status": "passed", + "inventory_metadata_only": "passed", + "golden_demo": { + "status": "passed", + "stages_exited_zero": 9, + "assertions_true": 6 + } + }, + "post_review_regressions": { + "test_suite": "212 tests: 206 passed, 6 skipped", + "missing_codex_home": { + "pull_exit_code": 0, + "pull_status": "action-required", + "status_exit_code": 0, + "status_state": "attention", + "codex_sessions_found": 0, + "directory_created": false + }, + "environment_suite_config_conflict": { + "pull_run_exit_code": 2, + "provenance_started": false, + "synthetic_data_root_writes": 0 + }, + "invalid_component_environment_root": { + "fallback_to_sibling_or_path": false, + "behavior": "fail-closed" + }, + "claude_only_workspace": { + "codex_scan_requested": false, + "codex_setup_action_added": false + } + }, + "limitations": [ + "The acceptance uses synthetic fixtures and does not validate scientific conclusions.", + "Editable installation may download declared Python build requirements.", + "The demo is functionally repeatable; timestamp-bearing files are not byte-for-byte deterministic.", + "Inventory migration apply and Lectern cross-repository generation are not covered." + ] +} diff --git a/src/scriptorium/cli.py b/src/scriptorium/cli.py index d1e8b15..92b0c7a 100644 --- a/src/scriptorium/cli.py +++ b/src/scriptorium/cli.py @@ -20,6 +20,13 @@ ) from .inventory import format_inventory_report, run_inventory from .init import InitError, format_init_report, run_init +from .path_selection import ( + attach_path_selection, + codex_home_selection, + root_selection, + select_configured_path, + selection_warnings, +) from .pull import PullError, format_pull_report, run_pull from .status import StatusError, format_status_report, run_status @@ -262,13 +269,8 @@ def _configured_path( environment_names: tuple[str, ...], configured: Path | None, ) -> Path | None: - if explicit is not None: - return explicit - for name in environment_names: - value = os.environ.get(name) - if value and value.strip(): - return Path(value.strip()) - return configured + selected, _ = select_configured_path(explicit, environment_names, configured) + return selected def _has_nonblank_environment(names: tuple[str, ...]) -> bool: @@ -523,33 +525,45 @@ def main(argv: list[str] | None = None) -> int: print(format_init_report(init_report)) return int(init_report["exit_code"]) if args.command == "doctor": + selections = { + "spec_root": root_selection(args.spec_root, "SCRIPTORIUM_SPEC_ROOT"), + "steward_root": root_selection( + args.steward_root, "SCRIPTORIUM_STEWARD_ROOT" + ), + "provenance_root": root_selection( + args.provenance_root, "SCRIPTORIUM_PROVENANCE_ROOT" + ), + "lectern_root": root_selection( + args.lectern_root, "SCRIPTORIUM_LECTERN_ROOT" + ), + "codex_home": codex_home_selection(), + } + warnings: list[dict[str, object]] = [] try: needs_config = ( args.config_dir is not None - or ( - args.workspace is None - and not _has_nonblank_environment( - ("SCRIPTORIUM_WORKSPACE", "PROVENANCE_VAULT") - ) - ) - or ( - args.provenance_home is None - and not _has_nonblank_environment(("PROVENANCE_HOME",)) - ) + or args.workspace is None + or args.provenance_home is None ) suite_config = _load_suite_config( args.config_dir, needed=needs_config ) - workspace = _configured_path( + workspace, workspace_selection = select_configured_path( args.workspace, ("SCRIPTORIUM_WORKSPACE", "PROVENANCE_VAULT"), suite_config.workspace if suite_config else None, ) - provenance_home = _configured_path( + provenance_home, data_root_selection = select_configured_path( args.provenance_home, ("PROVENANCE_HOME",), suite_config.provenance_home if suite_config else None, ) + selections = { + "workspace": workspace_selection, + "data_root": data_root_selection, + **selections, + } + warnings = selection_warnings(selections) doctor_report = run_doctor( target=args.target, spec_root=args.spec_root, @@ -559,11 +573,14 @@ def main(argv: list[str] | None = None) -> int: lectern_root=args.lectern_root, workspace=workspace, ) + attach_path_selection(doctor_report, selections, warnings) except (ConfigError, DoctorError) as exc: if args.json_output: + error_report = _doctor_error_report(target=args.target) + attach_path_selection(error_report, selections, warnings) print( json.dumps( - _doctor_error_report(target=args.target), + error_report, ensure_ascii=False, indent=2, ) @@ -577,37 +594,47 @@ def main(argv: list[str] | None = None) -> int: print(format_doctor_report(doctor_report)) return int(doctor_report["exit_code"]) if args.command == "pull": + selections = { + "provenance_root": root_selection( + args.provenance_root, "SCRIPTORIUM_PROVENANCE_ROOT" + ), + "codex_home": codex_home_selection(), + } + warnings: list[dict[str, object]] = [] try: needs_config = ( args.config_dir is not None - or ( - args.workspace is None - and not _has_nonblank_environment( - ("SCRIPTORIUM_WORKSPACE", "PROVENANCE_VAULT") - ) - ) - or ( - args.provenance_home is None - and not _has_nonblank_environment(("PROVENANCE_HOME",)) - ) + or args.workspace is None + or args.provenance_home is None ) suite_config = _load_suite_config( args.config_dir, needed=needs_config ) - workspace = _configured_path( + workspace, workspace_selection = select_configured_path( args.workspace, ("SCRIPTORIUM_WORKSPACE", "PROVENANCE_VAULT"), suite_config.workspace if suite_config else None, ) - provenance_home = _configured_path( + provenance_home, data_root_selection = select_configured_path( args.provenance_home, ("PROVENANCE_HOME",), suite_config.provenance_home if suite_config else None, ) + selections = { + "workspace": workspace_selection, + "data_root": data_root_selection, + **selections, + } + warnings = selection_warnings(selections) if workspace is None or provenance_home is None: raise PullError( "workspace and Provenance home are required via flags, environment, or suite config" ) + if args.run and warnings: + raise PullError( + "environment path selection conflicts with suite config; " + "pass explicit --workspace and --provenance-home paths before --run" + ) pull_report = run_pull( workspace=workspace, provenance_home=provenance_home, @@ -619,11 +646,14 @@ def main(argv: list[str] | None = None) -> int: ), run=args.run, ) + attach_path_selection(pull_report, selections, warnings) except (ConfigError, PullError) as exc: if args.json_output: + error_report = _pull_error_report(run=args.run) + attach_path_selection(error_report, selections, warnings) print( json.dumps( - _pull_error_report(run=args.run), + error_report, ensure_ascii=False, indent=2, ) @@ -637,33 +667,45 @@ def main(argv: list[str] | None = None) -> int: print(format_pull_report(pull_report)) return int(pull_report["exit_code"]) if args.command == "status": + selections = { + "spec_root": root_selection(args.spec_root, "SCRIPTORIUM_SPEC_ROOT"), + "steward_root": root_selection( + args.steward_root, "SCRIPTORIUM_STEWARD_ROOT" + ), + "provenance_root": root_selection( + args.provenance_root, "SCRIPTORIUM_PROVENANCE_ROOT" + ), + "lectern_root": root_selection( + args.lectern_root, "SCRIPTORIUM_LECTERN_ROOT" + ), + "codex_home": codex_home_selection(), + } + warnings: list[dict[str, object]] = [] try: needs_config = ( args.config_dir is not None - or ( - args.workspace is None - and not _has_nonblank_environment( - ("SCRIPTORIUM_WORKSPACE", "PROVENANCE_VAULT") - ) - ) - or ( - args.provenance_home is None - and not _has_nonblank_environment(("PROVENANCE_HOME",)) - ) + or args.workspace is None + or args.provenance_home is None ) suite_config = _load_suite_config( args.config_dir, needed=needs_config ) - workspace = _configured_path( + workspace, workspace_selection = select_configured_path( args.workspace, ("SCRIPTORIUM_WORKSPACE", "PROVENANCE_VAULT"), suite_config.workspace if suite_config else None, ) - provenance_home = _configured_path( + provenance_home, data_root_selection = select_configured_path( args.provenance_home, ("PROVENANCE_HOME",), suite_config.provenance_home if suite_config else None, ) + selections = { + "workspace": workspace_selection, + "data_root": data_root_selection, + **selections, + } + warnings = selection_warnings(selections) if workspace is None or provenance_home is None: raise StatusError( "workspace and Provenance home are required via flags, environment, or suite config" @@ -681,12 +723,15 @@ def main(argv: list[str] | None = None) -> int: provenance_root=args.provenance_root, lectern_root=args.lectern_root, ) + attach_path_selection(status_report, selections, warnings) # Status is a content-free boundary; unexpected probe failures must not escape. except Exception: if args.json_output: + error_report = _status_error_report() + attach_path_selection(error_report, selections, warnings) print( json.dumps( - _status_error_report(), + error_report, ensure_ascii=False, indent=2, ) diff --git a/src/scriptorium/demo.py b/src/scriptorium/demo.py index a50bfb2..73fdffd 100644 --- a/src/scriptorium/demo.py +++ b/src/scriptorium/demo.py @@ -124,16 +124,17 @@ def resolve_component_root(component: str, explicit: Path | None = None) -> Path candidates.append(explicit) else: configured = os.environ.get(COMPONENT_ENVS[component]) - if configured: + if configured and configured.strip(): candidates.append(Path(configured)) - source_parent = _source_root().parent - candidates.extend( - [ - source_parent / COMPONENT_DIRS[component], - Path.cwd() / COMPONENT_DIRS[component], - Path.cwd().parent / COMPONENT_DIRS[component], - ] - ) + else: + source_parent = _source_root().parent + candidates.extend( + [ + source_parent / COMPONENT_DIRS[component], + Path.cwd() / COMPONENT_DIRS[component], + Path.cwd().parent / COMPONENT_DIRS[component], + ] + ) for candidate in candidates: resolved = candidate.expanduser().resolve() if (resolved / "pyproject.toml").is_file(): diff --git a/src/scriptorium/doctor.py b/src/scriptorium/doctor.py index 8c939e6..c51bfd8 100644 --- a/src/scriptorium/doctor.py +++ b/src/scriptorium/doctor.py @@ -23,6 +23,7 @@ ) from .host import HostInstallError, inspect_host_installation from .init import parse_project_frontmatter +from .path_selection import format_path_selection, resolve_codex_home TARGETS = ("demo", "public-alpha") @@ -673,20 +674,16 @@ def _host_adapter_check( def _codex_scan_location() -> dict[str, Any]: - configured = os.environ.get("CODEX_HOME") - if configured and configured.strip(): - requested = Path(os.path.expandvars(configured.strip())).expanduser() - source = "CODEX_HOME" - else: - profile = None - source = "Path.home" - for name in ("USERPROFILE", "HOME"): - value = os.environ.get(name) - if value and value.strip(): - profile = Path(os.path.expandvars(value.strip())).expanduser() - source = name - break - requested = (profile or Path.home()) / ".codex" + requested, source, state = resolve_codex_home() + if requested is None: + return { + "root": None, + "source": source, + "sessions": None, + "state": state, + "root_exists": False, + "sessions_exist": False, + } try: root = requested.resolve(strict=False) except (OSError, RuntimeError): @@ -696,6 +693,7 @@ def _codex_scan_location() -> dict[str, Any]: "root": str(root), "source": source, "sessions": str(sessions), + "state": state, "root_exists": root.is_dir(), "sessions_exist": sessions.is_dir(), } @@ -710,7 +708,15 @@ def _agent_capture_check( claude_ready = "claude-code" in ready_hosts manual = claude_ready and not codex_ready codex_scan = _codex_scan_location() - if codex_ready: + codex_home_unavailable = bool( + codex_ready and codex_scan["state"] != "ready" + ) + if codex_home_unavailable: + summary = ( + "Codex log home is unavailable; capture will report zero sessions until " + "Codex creates it or the configured location is corrected" + ) + elif codex_ready: summary = ( "Codex capture can use the explicit local-log scan in `scriptorium pull` " f"from {codex_scan['root']}" @@ -731,8 +737,23 @@ def _agent_capture_check( summary=summary, details={ "ready_hosts": sorted(ready_hosts), - "codex_scan": "available" if codex_ready else "unavailable", + "codex_scan": ( + "configured-home-missing" + if codex_home_unavailable + and codex_scan["source"] == "CODEX_HOME" + and codex_scan["state"] == "missing" + else "home-unavailable" + if codex_home_unavailable + else "available" + if codex_ready + else "unavailable" + ), "codex_scan_location": codex_scan, + "action_required": ( + "run Codex once to create its log home or correct CODEX_HOME" + if codex_home_unavailable + else None + ), "claude_session_end": "manual-verification" if claude_ready else "not-selected", "trigger_parity_claimed": False, }, @@ -964,16 +985,17 @@ def _resolve_lectern_root(explicit: Path | None) -> Path | None: candidates.append(explicit) else: configured = os.environ.get("SCRIPTORIUM_LECTERN_ROOT") - if configured: + if configured and configured.strip(): candidates.append(Path(configured)) - source_parent = Path(__file__).resolve().parents[2].parent - candidates.extend( - [ - source_parent / "Academic-Slides-Agent", - Path.cwd() / "Academic-Slides-Agent", - Path.cwd().parent / "Academic-Slides-Agent", - ] - ) + else: + source_parent = Path(__file__).resolve().parents[2].parent + candidates.extend( + [ + source_parent / "Academic-Slides-Agent", + Path.cwd() / "Academic-Slides-Agent", + Path.cwd().parent / "Academic-Slides-Agent", + ] + ) for candidate in candidates: try: root = candidate.expanduser().resolve() @@ -1289,8 +1311,14 @@ def format_doctor_report(report: dict[str, Any]) -> str: "Network: no suite-managed action requested; " "OS-level subprocess egress not observed" ), - "", ] + selection_lines = format_path_selection( + report, + conflict_guidance="review the selected environment values.", + ) + if selection_lines: + lines.extend(["", *selection_lines]) + lines.append("") for check in report["checks"]: lines.append( f"{STATUS_LABELS[check['status']]:<5} {check['id']:<30} {check['summary']}" @@ -1313,7 +1341,9 @@ def format_doctor_report(report: dict[str, Any]) -> str: "", ( f"Result: {report['status'].upper()} -- " - f"{report['summary']['fail']} failed, {report['summary']['warn']} warnings, " + f"{report['summary']['fail']} failed, " + f"{report['summary']['warn']} check warnings, " + f"{len(report.get('warnings', []))} path-selection warnings, " f"{report['summary']['info']} informational, " f"{report['summary']['manual']} manual" ), diff --git a/src/scriptorium/path_selection.py b/src/scriptorium/path_selection.py new file mode 100644 index 0000000..e4b620c --- /dev/null +++ b/src/scriptorium/path_selection.py @@ -0,0 +1,167 @@ +"""Content-free path precedence evidence for suite entry commands.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + + +def select_configured_path( + explicit: Path | None, + environment_names: tuple[str, ...], + configured: Path | None, +) -> tuple[Path | None, dict[str, object]]: + if explicit is not None: + return explicit, _selection("cli") + for name in environment_names: + value = os.environ.get(name) + if value and value.strip(): + selected = Path(value.strip()) + conflict = bool( + configured is not None and not _same_path(selected, configured) + ) + return selected, _selection( + "environment", + environment=name, + suite_config_conflict=conflict, + ) + return configured, _selection( + "suite-config" if configured is not None else "unset" + ) + + +def root_selection(explicit: Path | None, environment_name: str) -> dict[str, object]: + if explicit is not None: + return _selection("cli") + value = os.environ.get(environment_name) + if value and value.strip(): + return _selection("environment", environment=environment_name) + return _selection("auto-discovery") + + +def codex_home_selection() -> dict[str, object]: + _root, source, state = resolve_codex_home() + if source != "CODEX_HOME": + return _selection("profile-default", state=state) + return _selection( + "environment", + environment="CODEX_HOME", + state=state, + ) + + +def resolve_codex_home() -> tuple[Path | None, str, str]: + value = os.environ.get("CODEX_HOME") + if value and value.strip(): + requested = Path(os.path.expandvars(value.strip())).expanduser() + source = "CODEX_HOME" + else: + profile = None + for name in ("USERPROFILE", "HOME"): + candidate = os.environ.get(name) + if candidate and candidate.strip(): + profile = Path(os.path.expandvars(candidate.strip())).expanduser() + break + if profile is None: + try: + profile = Path.home() + except RuntimeError: + return None, "profile-default", "not-probed" + requested = profile / ".codex" + source = "profile-default" + try: + resolved = requested.resolve(strict=False) + except (OSError, RuntimeError): + resolved = requested + if resolved.is_dir(): + state = "ready" + elif resolved.exists(): + state = "not-directory" + else: + state = "missing" + return resolved, source, state + + +def selection_warnings( + selections: dict[str, dict[str, object]], +) -> list[dict[str, object]]: + return [ + { + "code": "environment_suite_config_conflict", + "path": name, + "selected_source": "environment", + "environment": selection.get("environment"), + } + for name, selection in selections.items() + if selection.get("suite_config_conflict") is True + ] + + +def attach_path_selection( + report: dict[str, Any], + selections: dict[str, dict[str, object]], + warnings: list[dict[str, object]], +) -> None: + report["path_selection"] = selections + report["warnings"] = warnings + + +def format_path_selection( + report: dict[str, Any], *, conflict_guidance: str +) -> list[str]: + lines: list[str] = [] + selections = report.get("path_selection", {}) + if isinstance(selections, dict) and selections: + lines.append("Path selection:") + for name, selection in selections.items(): + if not isinstance(selection, dict): + continue + source = str(selection.get("source", "unknown")) + environment = selection.get("environment") + state = selection.get("state") + suffix = f" ({environment})" if environment else "" + if state and state != "not-probed": + suffix += f" [{state}]" + if selection.get("suite_config_conflict") is True: + suffix += " [CONFLICT: differs from suite config]" + lines.append(f" {name}: {source}{suffix}") + + warnings = report.get("warnings", []) + if isinstance(warnings, list) and warnings: + conflicted = ", ".join( + str(item.get("path")) + for item in warnings + if isinstance(item, dict) and item.get("path") + ) + lines.append( + "WARNING: environment and suite config select different roots for " + f"{conflicted}; {conflict_guidance}" + ) + return lines + + +def _selection( + source: str, + *, + environment: str | None = None, + state: str | None = None, + suite_config_conflict: bool = False, +) -> dict[str, object]: + selection: dict[str, object] = { + "source": source, + "environment": environment, + "suite_config_conflict": suite_config_conflict, + } + if state is not None: + selection["state"] = state + return selection + + +def _same_path(left: Path, right: Path) -> bool: + try: + return left.expanduser().resolve(strict=False) == right.expanduser().resolve( + strict=False + ) + except (OSError, RuntimeError): + return os.path.normcase(str(left)) == os.path.normcase(str(right)) diff --git a/src/scriptorium/pull.py b/src/scriptorium/pull.py index 3d5d0d5..2093501 100644 --- a/src/scriptorium/pull.py +++ b/src/scriptorium/pull.py @@ -19,6 +19,7 @@ resolve_component_root, ) from .host import HostInstallError, inspect_host_installation +from .path_selection import format_path_selection, resolve_codex_home PULL_PREVIEW_TIMEOUT_SECONDS = 60 @@ -113,6 +114,7 @@ "workspace-review", "project-resolution", } +ENTRY_ACTION_TYPES = {"codex-home-setup"} SAFE_ERROR_CODES = { "invalid_configuration", "invalid_sync_jsonl", @@ -144,11 +146,9 @@ def _resolve_directory(path: Path, *, label: str) -> Path: try: resolved = requested.resolve(strict=True) except (OSError, RuntimeError) as exc: - raise PullError( - f"{label} does not exist or cannot be resolved: {requested}" - ) from exc + raise PullError(f"{label} does not exist or cannot be resolved") from exc if not resolved.is_dir(): - raise PullError(f"{label} is not a directory: {requested}") + raise PullError(f"{label} is not a directory") return resolved @@ -164,20 +164,6 @@ def _validate_data_boundary(workspace: Path, provenance_home: Path) -> None: ) -def _configured_codex_home() -> Path | None: - value = os.environ.get("CODEX_HOME") - if not value or not value.strip(): - return None - requested = Path(os.path.expandvars(value.strip())).expanduser() - try: - resolved = requested.resolve(strict=True) - except (OSError, RuntimeError) as exc: - raise PullError("CODEX_HOME does not exist or cannot be resolved") from exc - if not resolved.is_dir(): - raise PullError("CODEX_HOME is not a directory") - return resolved - - def _compatibility_version() -> str: try: value = load_compatibility()["provenance"] @@ -199,7 +185,12 @@ def _resolve_provenance( else: try: root = resolve_component_root("provenance") - except (DemoError, OSError, RuntimeError): + except (DemoError, OSError, RuntimeError) as exc: + configured = os.environ.get("SCRIPTORIUM_PROVENANCE_ROOT") + if configured and configured.strip(): + raise PullError( + "configured Provenance root is unavailable" + ) from exc root = None if root is not None: @@ -451,8 +442,13 @@ def run_pull( provenance_root, expected_version=expected_version ) canonical_hosts = _canonical_hosts(resolved_workspace) - scan_codex = "codex" in canonical_hosts - codex_home = _configured_codex_home() if scan_codex else None + codex_selected = "codex" in canonical_hosts + codex_home, codex_home_source, codex_home_state = ( + resolve_codex_home() + if codex_selected + else (None, "not-selected", "not-probed") + ) + scan_codex = codex_selected and codex_home_state == "ready" mode = "run" if run else "preview" argv = [ @@ -505,14 +501,25 @@ def run_pull( "component_generated_by": producer, "expected_component_version": expected_version, "canonical_hosts": canonical_hosts, + "codex_selected": codex_selected, "scan_codex": scan_codex, - "codex_home_source": ( - "CODEX_HOME" if codex_home is not None else "profile-default" - ), + "codex_home_source": codex_home_source, + "codex_home_state": codex_home_state, "component_exit_code": completed.returncode, "stdout": "parsed-and-suppressed", "stderr": "suppressed" if completed.stderr else "empty", } + if codex_selected and codex_home_state in { + "missing", + "not-directory", + "not-probed", + }: + report["action_required"] = [ + *report["action_required"], + {"type": "codex-home-setup", "count": 1}, + ] + if report["status"] in {"planned", "noop", "completed"}: + report["status"] = "action-required" return report @@ -530,7 +537,10 @@ def format_pull_report(report: dict[str, Any]) -> str: } actions = [] for item in report.get("action_required", []): - if not isinstance(item, dict) or item.get("type") not in SAFE_ACTION_TYPES: + if ( + not isinstance(item, dict) + or item.get("type") not in SAFE_ACTION_TYPES | ENTRY_ACTION_TYPES + ): continue count = item.get("count") suffix = ( @@ -547,6 +557,17 @@ def format_pull_report(report: dict[str, Any]) -> str: f"Scriptorium pull {report['generated_by']['version']}", f"Mode: {report['mode']}", f"Status: {str(report['status']).upper()}", + ] + lines.extend( + format_path_selection( + report, + conflict_guidance=( + "pull --run is blocked until explicit CLI roots are used." + ), + ) + ) + lines.extend( + [ f"Codex scan: {'enabled' if report['entry']['scan_codex'] else 'disabled'}", "Stages: " + ( @@ -562,5 +583,6 @@ def format_pull_report(report: dict[str, Any]) -> str: "Errors: " + (", ".join(sorted(set(errors))) or "none"), f"Limitations: {len(report.get('limitations', []))}", "Raw component output: suppressed", - ] + ] + ) return "\n".join(lines) diff --git a/src/scriptorium/status.py b/src/scriptorium/status.py index c8bb504..91548e4 100644 --- a/src/scriptorium/status.py +++ b/src/scriptorium/status.py @@ -7,6 +7,7 @@ from . import __version__ from .doctor import DoctorError, run_doctor +from .path_selection import format_path_selection from .pull import SAFE_ERROR_CODES, PullError, run_pull @@ -41,6 +42,7 @@ PENDING_FIELDS = WORKFLOW_FIELDS[1:] SOURCE_ACTION_TYPES = { "run-confirmation", + "codex-home-setup", "agent-fill", "human-approval", "workspace-review", @@ -48,6 +50,7 @@ } ACTION_ORDER = ( "doctor-remediation", + "codex-home-setup", "project-resolution", "agent-fill", "human-approval", @@ -365,6 +368,7 @@ def run_status( needs_review = any( action_counts.get(action_type, 0) for action_type in ( + "codex-home-setup", "project-resolution", "agent-fill", "human-approval", @@ -405,10 +409,18 @@ def format_status_report(report: dict[str, Any]) -> str: and isinstance(item.get("count"), int) and not isinstance(item.get("count"), bool) ] - return "\n".join( - [ + lines = [ f"Scriptorium status {report['generated_by']['version']}", f"Overall: {str(report['status']).upper()}", + ] + lines.extend( + format_path_selection( + report, + conflict_guidance="review the selected environment values.", + ) + ) + lines.extend( + [ f"Public Alpha readiness: {str(readiness.get('public_alpha', 'unknown')).upper()}", f"Literature: {str(readiness.get('literature', 'unknown')).upper()}", f"Slides: {str(readiness.get('slides', 'unknown')).upper()}", @@ -425,3 +437,4 @@ def format_status_report(report: dict[str, Any]) -> str: "suppressed; external probe side effects not OS-observed", ] ) + return "\n".join(lines) diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py index bd41621..3b90f5a 100644 --- a/tests/test_cli_config.py +++ b/tests/test_cli_config.py @@ -70,22 +70,101 @@ def test_environment_paths_precede_configured_paths(self): mock.patch.dict( os.environ, { + "SCRIPTORIUM_CONFIG_DIR": str(self.config_root), "SCRIPTORIUM_WORKSPACE": str(env_workspace), "PROVENANCE_HOME": str(env_home), }, - clear=False, + clear=True, ), mock.patch( "scriptorium.cli.run_pull", return_value={"operation": "pull", "exit_code": 0}, ) as run, ): - exit_code, _report = self.invoke_json(["pull", "--json"]) + exit_code, report = self.invoke_json(["pull", "--json"]) self.assertEqual(exit_code, 0) self.assertEqual(run.call_args.kwargs["workspace"], env_workspace) self.assertEqual(run.call_args.kwargs["provenance_home"], env_home) self.assertIsNone(run.call_args.kwargs["project"]) + self.assertEqual( + report["path_selection"]["workspace"]["source"], "environment" + ) + self.assertEqual( + report["path_selection"]["data_root"]["environment"], + "PROVENANCE_HOME", + ) + self.assertEqual( + {warning["path"] for warning in report["warnings"]}, + {"workspace", "data_root"}, + ) + self.assertNotIn(str(env_workspace), json.dumps(report)) + self.assertNotIn(str(env_home), json.dumps(report)) + + def test_pull_run_fails_closed_on_environment_config_conflict(self): + env_workspace = self.root / "environment workspace" + env_home = self.root / "environment provenance" + env_workspace.mkdir() + env_home.mkdir() + with ( + mock.patch.dict( + os.environ, + { + "SCRIPTORIUM_CONFIG_DIR": str(self.config_root), + "SCRIPTORIUM_WORKSPACE": str(env_workspace), + "PROVENANCE_HOME": str(env_home), + }, + clear=True, + ), + mock.patch("scriptorium.cli.run_pull") as run, + ): + exit_code, report = self.invoke_json(["pull", "--run", "--json"]) + + self.assertEqual(exit_code, 2) + run.assert_not_called() + self.assertEqual(report["mode"], "run") + self.assertEqual( + {warning["path"] for warning in report["warnings"]}, + {"workspace", "data_root"}, + ) + + def test_pull_run_accepts_explicit_roots_despite_conflicting_environment(self): + explicit_workspace = self.root / "explicit workspace" + explicit_home = self.root / "explicit provenance" + explicit_workspace.mkdir() + explicit_home.mkdir() + component_report = {"operation": "pull", "exit_code": 0} + with ( + mock.patch.dict( + os.environ, + { + "SCRIPTORIUM_WORKSPACE": str(self.root / "environment workspace"), + "PROVENANCE_HOME": str(self.root / "environment provenance"), + }, + clear=True, + ), + mock.patch( + "scriptorium.cli.run_pull", return_value=component_report + ) as run, + ): + exit_code, report = self.invoke_json( + [ + "pull", + "--config-dir", + str(self.config_root), + "--workspace", + str(explicit_workspace), + "--provenance-home", + str(explicit_home), + "--run", + "--json", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertTrue(run.call_args.kwargs["run"]) + self.assertEqual(report["warnings"], []) + self.assertEqual(report["path_selection"]["workspace"]["source"], "cli") def test_blank_environment_paths_do_not_block_config_fallback(self): component_report = {"operation": "pull", "exit_code": 0} @@ -102,7 +181,7 @@ def test_blank_environment_paths_do_not_block_config_fallback(self): ), mock.patch("scriptorium.cli.run_pull", return_value=component_report) as run, ): - exit_code, _report = self.invoke_json(["pull", "--json"]) + exit_code, report = self.invoke_json(["pull", "--json"]) self.assertEqual(exit_code, 0) self.assertEqual(run.call_args.kwargs["workspace"], self.workspace.resolve()) @@ -110,6 +189,42 @@ def test_blank_environment_paths_do_not_block_config_fallback(self): run.call_args.kwargs["provenance_home"], self.provenance_home.resolve() ) self.assertEqual(run.call_args.kwargs["project"], "configured-project") + self.assertEqual( + report["path_selection"]["workspace"]["source"], "suite-config" + ) + self.assertEqual(report["warnings"], []) + + def test_doctor_warns_when_environment_and_suite_config_disagree(self): + env_workspace = self.root / "doctor environment workspace" + env_home = self.root / "doctor environment provenance" + env_workspace.mkdir() + env_home.mkdir() + with ( + mock.patch.dict( + os.environ, + { + "SCRIPTORIUM_CONFIG_DIR": str(self.config_root), + "SCRIPTORIUM_WORKSPACE": str(env_workspace), + "PROVENANCE_HOME": str(env_home), + }, + clear=True, + ), + mock.patch( + "scriptorium.cli.run_doctor", + return_value={"operation": "doctor", "exit_code": 1}, + ), + ): + exit_code, report = self.invoke_json(["doctor", "--json"]) + + self.assertEqual(exit_code, 1) + self.assertEqual( + {warning["path"] for warning in report["warnings"]}, + {"workspace", "data_root"}, + ) + self.assertEqual( + report["path_selection"]["provenance_root"]["source"], + "auto-discovery", + ) def test_single_workspace_override_does_not_reuse_configured_project(self): other_workspace = self.root / "other workspace" diff --git a/tests/test_cli_status.py b/tests/test_cli_status.py index fe5c529..f2995d7 100644 --- a/tests/test_cli_status.py +++ b/tests/test_cli_status.py @@ -72,6 +72,7 @@ def test_nonblank_environment_precedes_config_and_blank_environment_does_not(sel mock.patch.dict( os.environ, { + "SCRIPTORIUM_CONFIG_DIR": str(self.config_root), "SCRIPTORIUM_WORKSPACE": str(environment_workspace), "PROVENANCE_HOME": str(environment_home), }, @@ -79,10 +80,14 @@ def test_nonblank_environment_precedes_config_and_blank_environment_does_not(sel ), mock.patch("scriptorium.cli.run_status", return_value=status_report) as run, ): - self.invoke_json(["status", "--json"]) + _exit_code, report, _stderr = self.invoke_json(["status", "--json"]) self.assertEqual(run.call_args.kwargs["workspace"], environment_workspace) self.assertEqual(run.call_args.kwargs["provenance_home"], environment_home) self.assertIsNone(run.call_args.kwargs["project"]) + self.assertEqual( + {warning["path"] for warning in report["warnings"]}, + {"workspace", "data_root"}, + ) with ( mock.patch.dict( diff --git a/tests/test_demo.py b/tests/test_demo.py index 22028cc..b1c64ea 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -17,12 +17,24 @@ load_compatibility, prepare_output, project_version, + resolve_component_root, run_demo, ) from importlib import resources class DemoUnitTests(unittest.TestCase): + def test_invalid_component_environment_root_fails_closed(self): + with tempfile.TemporaryDirectory() as temporary: + missing = Path(temporary) / "missing-spec" + with mock.patch.dict( + os.environ, + {"SCRIPTORIUM_SPEC_ROOT": str(missing)}, + clear=False, + ): + with self.assertRaisesRegex(DemoError, "cannot find scriptorium-spec"): + resolve_component_root("scriptorium-spec") + def test_compatibility_manifest_locks_alpha_baseline(self): self.assertEqual( load_compatibility(), diff --git a/tests/test_doctor.py b/tests/test_doctor.py index a972a0f..63e9ff0 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -23,6 +23,7 @@ _project_metadata, _provenance_home_check, _provenance_runtime_version, + _resolve_lectern_root, _run_probe, _workspace_check, format_doctor_report, @@ -213,17 +214,44 @@ def test_human_output_is_stable_plain_text_with_fix(self): ), *capability_checks(target=target), ] - rendered = format_doctor_report(_build_report(target, checks, [])) + report = _build_report(target, checks, []) + report["path_selection"] = { + "data_root": { + "source": "environment", + "environment": "PROVENANCE_HOME", + "suite_config_conflict": True, + } + } + report["warnings"] = [ + { + "code": "environment_suite_config_conflict", + "path": "data_root", + } + ] + rendered = format_doctor_report(report) self.assertIn("FAIL host.adapter", rendered) self.assertIn("Fix: install adapter", rendered) self.assertIn("Next: Install only if desired.", rendered) self.assertIn("Public Alpha readiness: INCOMPLETE", rendered) self.assertIn("Not tested:", rendered) self.assertIn("local filesystem paths", rendered) + self.assertIn("data_root: environment (PROVENANCE_HOME)", rendered) + self.assertIn("WARNING:", rendered) + self.assertIn("1 path-selection warnings", rendered) self.assertNotIn("\x1b", rendered) class DoctorProbeTests(unittest.TestCase): + def test_invalid_lectern_environment_root_fails_closed(self): + with tempfile.TemporaryDirectory() as temporary: + missing = Path(temporary) / "missing-lectern" + with mock.patch.dict( + os.environ, + {"SCRIPTORIUM_LECTERN_ROOT": str(missing)}, + clear=False, + ): + self.assertIsNone(_resolve_lectern_root(None)) + def test_probe_environment_does_not_inherit_provider_secret(self): with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "secret-sentinel"}): output = _run_probe( @@ -577,8 +605,37 @@ def test_codex_scan_location_prefers_codex_home_then_profile_default(self): self.assertEqual(configured_location["source"], "CODEX_HOME") self.assertEqual(Path(configured_location["root"]), configured.resolve()) self.assertTrue(configured_location["sessions_exist"]) - self.assertEqual(default_location["source"], "USERPROFILE") + self.assertEqual(default_location["source"], "profile-default") self.assertEqual(Path(default_location["root"]), (profile / ".codex").resolve()) + self.assertEqual(default_location["state"], "missing") + self.assertEqual( + default_check["details"]["codex_scan"], "home-unavailable" + ) + self.assertIsNotNone(default_check["details"]["action_required"]) + + def test_missing_codex_home_is_an_actionable_zero_session_state(self): + codex = _check( + "host.adapter", + target="public-alpha", + required_for=("public-alpha",), + passed=True, + summary="ready", + details={"ready_hosts": ["codex"]}, + ) + with tempfile.TemporaryDirectory() as temporary: + missing = Path(temporary) / "missing-codex-home" + with mock.patch.dict( + os.environ, {"CODEX_HOME": str(missing)}, clear=True + ): + capture = _agent_capture_check("public-alpha", codex) + + self.assertEqual(capture["status"], "pass") + self.assertEqual( + capture["details"]["codex_scan"], "configured-home-missing" + ) + self.assertIn("zero sessions", capture["summary"]) + self.assertIsNotNone(capture["details"]["action_required"]) + self.assertFalse(missing.exists()) def test_pull_requires_a_compatible_public_capability_probe(self): public = _entry_pull_check("public-alpha", None) diff --git a/tests/test_pull.py b/tests/test_pull.py index 124336c..9b69fe0 100644 --- a/tests/test_pull.py +++ b/tests/test_pull.py @@ -9,6 +9,7 @@ from unittest import mock from scriptorium import cli +from scriptorium.demo import DemoError from scriptorium.host import run_host_install from scriptorium.pull import ( ENTRY_LIMITATIONS, @@ -67,13 +68,18 @@ def test_canonical_codex_adapter_adds_scan_flag_and_run_scope(self): root = Path(temporary) workspace = root / "workspace" home = root / "provenance-home" + codex_home = root / "codex-home" provenance_root = root / "Provenance" workspace.mkdir() home.mkdir() + codex_home.mkdir() provenance_root.mkdir() run_host_install(workspace=workspace, host="codex") result = component_report(mode="run", status="action-required", exit_code=0) with ( + mock.patch.dict( + os.environ, {"CODEX_HOME": str(codex_home)}, clear=False + ), mock.patch( "scriptorium.pull._resolve_provenance", return_value=(provenance_root, Path("prov-sync-pull")), @@ -91,6 +97,10 @@ def test_canonical_codex_adapter_adds_scan_flag_and_run_scope(self): command = invoke.call_args.args[0] self.assertIn("--scan-codex", command) + self.assertEqual( + command[command.index("--codex-home") + 1], + str(codex_home.resolve()), + ) self.assertIn("--run", command) self.assertIsNone(invoke.call_args.kwargs["timeout"]) self.assertEqual(command[command.index("--project") + 1], "catalyst") @@ -123,6 +133,9 @@ def test_claude_only_manifest_does_not_add_codex_scan(self): self.assertNotIn("--run", invoke.call_args.args[0]) self.assertEqual(invoke.call_args.kwargs["timeout"], 60) self.assertEqual(report["mode"], "preview") + self.assertFalse(report["entry"]["codex_selected"]) + self.assertEqual(report["status"], "planned") + self.assertEqual(report["action_required"], []) def test_codex_home_is_forwarded_as_the_scan_root(self): with tempfile.TemporaryDirectory() as temporary: @@ -154,6 +167,49 @@ def test_codex_home_is_forwarded_as_the_scan_root(self): command[command.index("--codex-home") + 1], str(codex_home.resolve()) ) self.assertEqual(report["entry"]["codex_home_source"], "CODEX_HOME") + self.assertEqual(report["entry"]["codex_home_state"], "ready") + + def test_missing_codex_home_reports_zero_session_setup_without_creating_it(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + home = root / "provenance-home" + missing_codex_home = root / "missing-codex-home" + workspace.mkdir() + home.mkdir() + run_host_install(workspace=workspace, host="codex") + result = component_report(status="noop") + result["summary"]["codex_found"] = 0 + with ( + mock.patch.dict( + os.environ, + {"CODEX_HOME": str(missing_codex_home)}, + clear=False, + ), + mock.patch( + "scriptorium.pull._resolve_provenance", + return_value=(None, Path("prov-sync-pull")), + ), + mock.patch( + "scriptorium.pull.subprocess.run", + return_value=completed(result), + ) as invoke, + ): + report = run_pull(workspace=workspace, provenance_home=home) + + command = invoke.call_args.args[0] + self.assertNotIn("--scan-codex", command) + self.assertNotIn("--codex-home", command) + self.assertFalse(missing_codex_home.exists()) + self.assertEqual(report["status"], "action-required") + self.assertEqual(report["summary"]["codex_found"], 0) + self.assertTrue(report["entry"]["codex_selected"]) + self.assertFalse(report["entry"]["scan_codex"]) + self.assertEqual(report["entry"]["codex_home_state"], "missing") + self.assertIn( + {"type": "codex-home-setup", "count": 1}, + report["action_required"], + ) def test_codex_home_expands_environment_variables_and_whitespace(self): with tempfile.TemporaryDirectory() as temporary: @@ -195,11 +251,18 @@ def test_blank_codex_home_uses_the_component_profile_default(self): root = Path(temporary) workspace = root / "workspace" home = root / "provenance-home" + profile = root / "profile" + codex_home = profile / ".codex" workspace.mkdir() home.mkdir() + codex_home.mkdir(parents=True) run_host_install(workspace=workspace, host="codex") with ( - mock.patch.dict(os.environ, {"CODEX_HOME": " "}, clear=False), + mock.patch.dict( + os.environ, + {"CODEX_HOME": " ", "USERPROFILE": str(profile)}, + clear=True, + ), mock.patch( "scriptorium.pull._resolve_provenance", return_value=(None, Path("prov-sync-pull")), @@ -211,9 +274,73 @@ def test_blank_codex_home_uses_the_component_profile_default(self): ): report = run_pull(workspace=workspace, provenance_home=home) - self.assertNotIn("--codex-home", invoke.call_args.args[0]) + command = invoke.call_args.args[0] + self.assertEqual( + command[command.index("--codex-home") + 1], + str(codex_home.resolve()), + ) self.assertEqual(report["entry"]["codex_home_source"], "profile-default") + def test_missing_profile_default_skips_scan_and_requests_setup(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + home = root / "provenance-home" + profile = root / "profile" + workspace.mkdir() + home.mkdir() + profile.mkdir() + run_host_install(workspace=workspace, host="codex") + result = component_report(status="noop") + result["summary"]["codex_found"] = 0 + with ( + mock.patch.dict( + os.environ, + {"CODEX_HOME": " ", "USERPROFILE": str(profile)}, + clear=True, + ), + mock.patch( + "scriptorium.pull._resolve_provenance", + return_value=(None, Path("prov-sync-pull")), + ), + mock.patch( + "scriptorium.pull.subprocess.run", + return_value=completed(result), + ) as invoke, + ): + report = run_pull(workspace=workspace, provenance_home=home) + + command = invoke.call_args.args[0] + self.assertNotIn("--scan-codex", command) + self.assertNotIn("--codex-home", command) + self.assertEqual(report["entry"]["codex_home_state"], "missing") + self.assertEqual(report["status"], "action-required") + self.assertIn( + {"type": "codex-home-setup", "count": 1}, + report["action_required"], + ) + + def test_invalid_provenance_environment_root_does_not_fall_back_to_path(self): + with tempfile.TemporaryDirectory() as temporary: + missing = Path(temporary) / "missing-provenance" + with ( + mock.patch.dict( + os.environ, + {"SCRIPTORIUM_PROVENANCE_ROOT": str(missing)}, + clear=False, + ), + mock.patch( + "scriptorium.pull.resolve_component_root", + side_effect=DemoError("not found"), + ), + mock.patch("scriptorium.pull.shutil.which") as installed, + ): + with self.assertRaisesRegex( + PullError, "configured Provenance root is unavailable" + ): + _resolve_provenance(None, expected_version="0.17.0") + installed.assert_not_called() + def test_environment_is_narrow_and_raw_stderr_is_suppressed(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -255,11 +382,13 @@ def test_missing_explicit_path_is_an_error_before_launch(self): tempfile.TemporaryDirectory() as temporary, mock.patch("scriptorium.pull.subprocess.run") as invoke, ): - with self.assertRaisesRegex(PullError, "workspace does not exist"): + missing = Path(temporary) / "missing-private-name" + with self.assertRaisesRegex(PullError, "workspace does not exist") as raised: run_pull( - workspace=Path(temporary) / "missing", + workspace=missing, provenance_home=Path(temporary), ) + self.assertNotIn(str(missing), str(raised.exception)) invoke.assert_not_called() def test_provenance_home_and_workspace_must_not_overlap(self): @@ -541,6 +670,11 @@ def test_required_nested_semantics_fail_closed(self): unknown_action = component_report() unknown_action["action_required"] = [{"type": "private-action", "count": 1}] + wrapper_only_action = component_report() + wrapper_only_action["action_required"] = [ + {"type": "codex-home-setup", "count": 1} + ] + missing_action_count = component_report() missing_action_count["action_required"] = [{"type": "agent-fill"}] @@ -556,6 +690,7 @@ def test_required_nested_semantics_fail_closed(self): invalid_stage_status, invalid_stage_count, unknown_action, + wrapper_only_action, missing_action_count, unknown_error, incompatible_egress, @@ -584,10 +719,25 @@ def test_text_report_only_contains_aggregate_safe_information(self): {"code": "private_error", "detail": "secret"}, ] report["limitations"] = ["private limitation"] + report["path_selection"] = { + "workspace": { + "source": "environment", + "environment": "SCRIPTORIUM_WORKSPACE", + "suite_config_conflict": True, + } + } + report["warnings"] = [ + { + "code": "environment_suite_config_conflict", + "path": "workspace", + } + ] rendered = format_pull_report(report) self.assertIn("codex_found=1", rendered) self.assertIn("Actions: agent-fill=1", rendered) self.assertIn("Errors: worker_busy", rendered) + self.assertIn("workspace: environment (SCRIPTORIUM_WORKSPACE)", rendered) + self.assertIn("pull --run is blocked", rendered) self.assertNotIn("secret claim", rendered) self.assertNotIn("D:/private", rendered) self.assertNotIn("private-action", rendered) diff --git a/tests/test_status.py b/tests/test_status.py index b60d128..84502e1 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -92,6 +92,24 @@ def test_empty_preview_is_ready_and_run_confirmation_is_not_attention(self): self.assertFalse(pull_mock.call_args.kwargs["run"]) self.assertEqual(pull_mock.call_args.kwargs["project"], "configured-project") + def test_missing_codex_home_is_attention_not_an_internal_error(self): + report, _pull_mock = self.run_with( + doctor_report(), + pull_report( + status="action-required", + counts={"codex_found": 0}, + actions=[{"type": "codex-home-setup", "count": 1}], + ), + ) + + self.assertEqual(report["status"], "attention") + self.assertEqual(report["exit_code"], 0) + self.assertEqual(report["freshness"]["state"], "review-required") + self.assertIn( + {"type": "codex-home-setup", "count": 1}, + report["action_required"], + ) + def test_pending_change_is_attention_and_only_suggests_reviewing_pull(self): report, _pull_mock = self.run_with( doctor_report(), @@ -294,12 +312,21 @@ def test_human_report_contains_only_aggregate_state(self): doctor_report(), pull_report(counts={"pending_fill": 1}), ) + report["path_selection"] = { + "workspace": { + "source": "suite-config", + "environment": None, + "suite_config_conflict": False, + } + } + report["warnings"] = [] rendered = format_status_report(report) self.assertIn("Overall: ATTENTION", rendered) self.assertIn("pending_fill=1", rendered) self.assertIn("raw content and local paths suppressed", rendered) self.assertIn("external probe side effects not OS-observed", rendered) + self.assertIn("workspace: suite-config", rendered) self.assertNotIn("configured-project", rendered)