From b0e1a14757f7091173d66ac903dfb5e77eeb5bb4 Mon Sep 17 00:00:00 2001
From: foxsplendid <62349701+foxsplendid@users.noreply.github.com>
Date: Tue, 21 Jul 2026 03:44:31 +0800
Subject: [PATCH 1/4] Prepare Public Alpha release
---
.github/workflows/ci.yml | 22 +--
CHANGELOG.md | 14 +-
README.md | 52 ++++--
README.zh.md | 42 +++--
docs/case-study.zh-CN.md | 157 ++++++++++++++++
docs/showcase/README.zh-CN.md | 57 ++++++
docs/showcase/demo-poster.svg | 82 +++++++++
docs/showcase/evidence-manifest.json | 80 +++++++++
docs/showcase/evidence/demo-report.json | 117 ++++++++++++
.../evidence/windows-uat-summary.json | 69 ++++++++
src/scriptorium/cli.py | 137 +++++++++-----
src/scriptorium/demo.py | 19 +-
src/scriptorium/doctor.py | 84 ++++++---
src/scriptorium/path_selection.py | 167 ++++++++++++++++++
src/scriptorium/pull.py | 74 +++++---
src/scriptorium/status.py | 17 +-
tests/test_cli_config.py | 121 ++++++++++++-
tests/test_cli_status.py | 7 +-
tests/test_demo.py | 12 ++
tests/test_doctor.py | 61 ++++++-
tests/test_pull.py | 149 +++++++++++++++-
tests/test_status.py | 27 +++
22 files changed, 1403 insertions(+), 164 deletions(-)
create mode 100644 docs/case-study.zh-CN.md
create mode 100644 docs/showcase/README.zh-CN.md
create mode 100644 docs/showcase/demo-poster.svg
create mode 100644 docs/showcase/evidence-manifest.json
create mode 100644 docs/showcase/evidence/demo-report.json
create mode 100644 docs/showcase/evidence/windows-uat-summary.json
create mode 100644 src/scriptorium/path_selection.py
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)
+
+
## 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)
+
+
## 当前真正可用的内容
@@ -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..20434cc
--- /dev/null
+++ b/docs/case-study.zh-CN.md
@@ -0,0 +1,157 @@
+# Scriptorium:把科研 Agent demo 做成可验证的本地系统
+
+> 项目状态:Public Alpha · Windows-first · 本地优先 · Agent 原生
+> 这是一份产品与工程案例,不是科研成果声明。
+
+
+
+## 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)可以交叉核验。
+干净根替换后的 GitHub-hosted Windows CI 将作为远端发布门禁重新生成,不沿用旧运行。
+
+## 用户与待完成任务
+
+目标用户是有一定 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 | 通过 |
+| Python 兼容 | Windows / Ubuntu,Python 3.11 / 3.12 | 通过 |
+| 跨组件契约 | 9 阶段机器报告与固定版本基线 | 通过 |
+| 无凭据合成 demo | 报告 `credentials_requested: []` | 通过 |
+| 当前旗舰证据包未检出私人内容 | 合成 fixture、历史/路径/邮箱/secret 扫描与人工复核 | 通过(限本发布候选) |
+| 完整 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..4bcac8b
--- /dev/null
+++ b/docs/showcase/demo-poster.svg
@@ -0,0 +1,82 @@
+
diff --git a/docs/showcase/evidence-manifest.json b/docs/showcase/evidence-manifest.json
new file mode 100644
index 0000000..e655d4b
--- /dev/null
+++ b/docs/showcase/evidence-manifest.json
@@ -0,0 +1,80 @@
+{
+ "format_version": 1,
+ "evidence_type": "scriptorium-public-alpha-synthetic-demo",
+ "evidence_state": "local-release-candidate",
+ "observed_on": "2026-07-20",
+ "source": {
+ "repository": "scriptorium-suite/scriptorium",
+ "commit": null,
+ "workflow_run": null,
+ "runner": "isolated Windows local acceptance",
+ "python": "3.12.3",
+ "remote_ci_status": "pending sanitized-root replacement"
+ },
+ "components": {
+ "scriptorium": {
+ "version": "0.1.0",
+ "commit": null,
+ "state": "verified working tree"
+ },
+ "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"
+ },
+ "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,
+ "os_network_isolation_observed": false
+ },
+ "release_blockers": [
+ "Replace the public main roots for scriptorium-spec and Lectern only after explicit approval.",
+ "Re-run GitHub-hosted Windows CI from the sanitized component commits.",
+ "Replace this local candidate source block with the final umbrella commit and workflow run."
+ ],
+ "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."
+ ]
+}
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..3ab814f 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,
@@ -123,6 +124,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 +158,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 +242,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 +265,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 +373,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 +661,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 +681,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 +710,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)
From 573d8d90bbf2ea14c0de5b2ab321778772946255 Mon Sep 17 00:00:00 2001
From: foxsplendid <62349701+foxsplendid@users.noreply.github.com>
Date: Tue, 21 Jul 2026 03:47:52 +0800
Subject: [PATCH 2/4] Record Public Alpha candidate evidence
---
docs/showcase/evidence-manifest.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/showcase/evidence-manifest.json b/docs/showcase/evidence-manifest.json
index e655d4b..cc0462f 100644
--- a/docs/showcase/evidence-manifest.json
+++ b/docs/showcase/evidence-manifest.json
@@ -5,7 +5,7 @@
"observed_on": "2026-07-20",
"source": {
"repository": "scriptorium-suite/scriptorium",
- "commit": null,
+ "commit": "b0e1a14757f7091173d66ac903dfb5e77eeb5bb4",
"workflow_run": null,
"runner": "isolated Windows local acceptance",
"python": "3.12.3",
@@ -14,8 +14,8 @@
"components": {
"scriptorium": {
"version": "0.1.0",
- "commit": null,
- "state": "verified working tree"
+ "commit": "b0e1a14757f7091173d66ac903dfb5e77eeb5bb4",
+ "state": "verified local commit"
},
"scriptorium-spec": {
"version": "2.2.0",
From 8069cbf1a2de4b31e8239740368799ff9519294e Mon Sep 17 00:00:00 2001
From: foxsplendid <62349701+foxsplendid@users.noreply.github.com>
Date: Tue, 21 Jul 2026 17:35:52 +0800
Subject: [PATCH 3/4] Make Codex adapter test environment-independent
---
tests/test_pull.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/tests/test_pull.py b/tests/test_pull.py
index 3ab814f..9b69fe0 100644
--- a/tests/test_pull.py
+++ b/tests/test_pull.py
@@ -68,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")),
@@ -92,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")
From ab56d1c7ade033dd33957125c1703fb0c2a89139 Mon Sep 17 00:00:00 2001
From: foxsplendid <62349701+foxsplendid@users.noreply.github.com>
Date: Tue, 21 Jul 2026 17:59:07 +0800
Subject: [PATCH 4/4] Record verified Public Alpha evidence
---
docs/case-study.zh-CN.md | 9 +++++--
docs/showcase/demo-poster.svg | 3 ++-
docs/showcase/evidence-manifest.json | 35 +++++++++++++++++-----------
3 files changed, 31 insertions(+), 16 deletions(-)
diff --git a/docs/case-study.zh-CN.md b/docs/case-study.zh-CN.md
index 20434cc..a8de1d9 100644
--- a/docs/case-study.zh-CN.md
+++ b/docs/case-study.zh-CN.md
@@ -21,7 +21,11 @@ Scriptorium 解决的不是“再做一个聊天框”,而是一个更基础
证据不是宣传数字:[机器报告](showcase/evidence/demo-report.json)、
[证据清单](showcase/evidence-manifest.json)和
[Windows 隔离验收摘要](showcase/evidence/windows-uat-summary.json)可以交叉核验。
-干净根替换后的 GitHub-hosted Windows CI 将作为远端发布门禁重新生成,不沿用旧运行。
+干净根替换后的远端发布门禁已经完成:经验证的代码提交
+[`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 服务端垃圾回收与缓存清理仍在单独处理。
## 用户与待完成任务
@@ -91,11 +95,12 @@ provider 路径。重复运行在功能上幂等,但带摄取时间戳的记
| 要证明的能力 | 公开证据 | 当前结论 |
|---|---|---|
-| Windows 首装与源码安装 | [隔离验收摘要](showcase/evidence/windows-uat-summary.json) + GitHub-hosted clean Windows E2E | 通过 |
+| 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 | 未验证 |
diff --git a/docs/showcase/demo-poster.svg b/docs/showcase/demo-poster.svg
index 4bcac8b..eb610ed 100644
--- a/docs/showcase/demo-poster.svg
+++ b/docs/showcase/demo-poster.svg
@@ -54,7 +54,8 @@
VERIFIED SYNTHETIC DEMO
- Windows · local release candidate · remote CI pending
+ commit 8069cbf1 · PR CI #29818934741 · PASS
+ artifact #8490533423 · synthetic only
3/396
diff --git a/docs/showcase/evidence-manifest.json b/docs/showcase/evidence-manifest.json
index cc0462f..63463a5 100644
--- a/docs/showcase/evidence-manifest.json
+++ b/docs/showcase/evidence-manifest.json
@@ -1,21 +1,22 @@
{
"format_version": 1,
"evidence_type": "scriptorium-public-alpha-synthetic-demo",
- "evidence_state": "local-release-candidate",
- "observed_on": "2026-07-20",
+ "evidence_state": "remote-ci-verified-release-candidate",
+ "observed_on": "2026-07-21",
"source": {
"repository": "scriptorium-suite/scriptorium",
- "commit": "b0e1a14757f7091173d66ac903dfb5e77eeb5bb4",
- "workflow_run": null,
- "runner": "isolated Windows local acceptance",
- "python": "3.12.3",
- "remote_ci_status": "pending sanitized-root replacement"
+ "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": "b0e1a14757f7091173d66ac903dfb5e77eeb5bb4",
- "state": "verified local commit"
+ "commit": "8069cbf1a2de4b31e8239740368799ff9519294e",
+ "state": "verified remote commit"
},
"scriptorium-spec": {
"version": "2.2.0",
@@ -47,6 +48,13 @@
"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": [
@@ -64,17 +72,18 @@
"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": [
- "Replace the public main roots for scriptorium-spec and Lectern only after explicit approval.",
- "Re-run GitHub-hosted Windows CI from the sanitized component commits.",
- "Replace this local candidate source block with the final umbrella commit and workflow run."
+ "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."
+ "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."
]
}