diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 3cb1cce4d6..adc3433b1b 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -573,6 +573,13 @@ jobs: tests/test_windows_atomic_writes.py tests/test_windows_install.py + - name: Reject junction redirects during local-state migration + run: >- + python -m pytest -q + tests/test_local_state_migration.py::test_windows_junction_goal_source_never_reads_or_moves_outside_project + tests/test_local_state_migration.py::test_windows_junction_rollback_destination_never_writes_outside_project + tests/test_local_state_migration.py::test_windows_junction_goal_destination_never_writes_outside_project + - name: Set up the qualified SQLite runtime uses: actions/setup-node@v6 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4adcbee65..aa8ef8738f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ request. LoopX coordinates local agent state, so some files are runtime data and must stay out of public contributions: -- do not commit `.loopx/`, `.codex/goals/`, or live +- do not commit `.loopx/`, legacy `.codex/goals/`, or live `ACTIVE_GOAL_STATE.md` files; - do not publish private benchmark traces, verifier output, raw agent sessions, credentials, internal document links, or local machine paths; diff --git a/README.md b/README.md index 869667912c..afe977cc47 100644 --- a/README.md +++ b/README.md @@ -350,8 +350,8 @@ missing, use the guided path: loopx start-goal --guided --project . --goal-text "Your long-running objective" ``` -LoopX should reuse existing state rather than overwrite it. Keep `.loopx/`, -`.codex/goals/`, and `.local/` ignored. +LoopX should reuse existing state rather than overwrite it. Keep `.loopx/` +and `.local/` ignored; keep legacy `.codex/goals/` ignored until migration. ### Start From Your Agent @@ -788,7 +788,7 @@ Project roles and public history are recorded in [Project History](docs/project/history.md). LoopX keeps local active state separate from the public repository. Do not -commit `.loopx/`, `.codex/goals/`, live `ACTIVE_GOAL_STATE.md`, raw benchmark +commit `.loopx/`, legacy `.codex/goals/`, live `ACTIVE_GOAL_STATE.md`, raw benchmark traces, credentials, private logs, or operator artifacts. ## Current Status diff --git a/docs/book/chapters/04-runtime-boundaries.md b/docs/book/chapters/04-runtime-boundaries.md index 38ecedb2d8..0060cbefe7 100644 --- a/docs/book/chapters/04-runtime-boundaries.md +++ b/docs/book/chapters/04-runtime-boundaries.md @@ -345,7 +345,7 @@ Host 应服从 current `interaction_contract` 与 `scheduler_hint`,不能把 ```text .loopx/ -.codex/goals/ +.loopx/goals/ .local/ ``` diff --git a/docs/book/chapters/05-connect-existing-project.md b/docs/book/chapters/05-connect-existing-project.md index 2146b20cca..48a3702761 100644 --- a/docs/book/chapters/05-connect-existing-project.md +++ b/docs/book/chapters/05-connect-existing-project.md @@ -17,10 +17,10 @@ - `loopx doctor` 报告安装可用; - 项目存在 `.loopx/registry.json`; -- 项目存在 `.codex/goals//ACTIVE_GOAL_STATE.md`; +- 项目存在 `.loopx/goals//ACTIVE_GOAL_STATE.md`; - `loopx status` 能显示 active state 和当前 frontier;首连不会生成 onboarding todo, 第一个交付 todo 由 Agent 与你确认后写入; -- `.loopx/` 与 `.codex/goals/` 不会进入 Git; +- `.loopx/` 与 `.loopx/goals/` 不会进入 Git; - 再次连接会按精确 `goal_id` 复用已有 Goal,而不是覆盖目标; - 新接入的执行者使用 fresh `agent_id`,除非用户明确授权 takeover。 @@ -40,13 +40,13 @@ 执行合同: 1. 先只读检查项目根目录、当前分支、git status、.gitignore,以及是否已有 - .loopx/registry.json、.codex/goals/ 或其他 LoopX 状态。不要覆盖、reset 或清理现有内容。 + .loopx/registry.json、.loopx/goals/ 或其他 LoopX 状态。不要覆盖、reset 或清理现有内容。 2. 运行 loopx --version、loopx doctor,并读取本次实际需要的 --help。不要依赖记忆中的旧参数。 如果 LoopX 尚未安装,先报告缺失和官方 installer 将写入的位置,得到我授权后再安装;不要把 “找到安装命令”写成“安装已完成”。 3. 如果已有 LoopX 状态,先读 loopx registry、loopx status 和相关 history。优先复用精确 goal_id;不要 force reconnect,不要按目标文字相似度选择 Goal。 -4. 确保 .loopx/、.codex/goals/ 和 .local/ 被 Git 忽略。如果这些目录已有项目用途或已被跟踪, +4. 确保 .loopx/、.loopx/goals/ 和 .local/ 被 Git 忽略。如果这些目录已有项目用途或已被跟踪, 停下来报告冲突,不要擅自删除或 untrack。 5. 对尚未连接的项目,先运行 loopx connect --dry-run,展示将创建或修改的状态;确认没有冲突后 再执行 loopx connect。已有 registry 时不要为了“重新开始”重复 bootstrap。 @@ -59,7 +59,7 @@ 9. 任何用户审批、外部写操作、凭据、权限扩大、Host 选择或 destructive Git 操作都必须停在 Gate,不能替我决定。 10. 完成后验证 loopx status、todo list、history、quota should-run、git status,以及 - git ls-files .loopx .codex/goals .local。 + git ls-files .loopx .loopx/goals .local。 11. 不要提交或推送。最后给我一份“接入回报”,列出 goal_id、agent_id、Host、创建或修改的文件、 当前 Todo/Gate、执行过的 mutation、验证结果、未解决问题和下一步。只完成 preview 时必须 明确写“尚未接入完成”。 @@ -158,7 +158,7 @@ loopx doctor --deep ```text .loopx/ -.codex/goals/ +.loopx/goals/ .local/ ``` @@ -169,7 +169,7 @@ state、registry、lease 和本地证据指针;`.local/` 还可能包含其他 ```bash git check-ignore -v .loopx/registry.json -git check-ignore -v .codex/goals/example/ACTIVE_GOAL_STATE.md +git check-ignore -v .loopx/goals/example/ACTIVE_GOAL_STATE.md ``` 文件尚不存在时,`git check-ignore` 可能需要 `--no-index`: @@ -284,7 +284,7 @@ loopx quota should-run --goal-id --agent-id ```bash git status --short -git ls-files .loopx .codex/goals .local +git ls-files .loopx .loopx/goals .local ``` 第二条命令应无输出。如果输出了路径,说明本地控制状态已经被 Git 跟踪;仅增加 `.gitignore` diff --git a/docs/book/chapters/06-codex-app.md b/docs/book/chapters/06-codex-app.md index 72d91e3d6f..74849ee036 100644 --- a/docs/book/chapters/06-codex-app.md +++ b/docs/book/chapters/06-codex-app.md @@ -24,7 +24,7 @@ Codex App 的职责是提供可见交互、Agent Turn 和 heartbeat automation ```text 检查当前项目的 LoopX 连接状态。先运行 loopx doctor、loopx registry 和 loopx status。复用已有 active state,不要覆盖现有目标。确认 .loopx/、 -.codex/goals/ 和 .local/ 已被 Git 忽略。 +.loopx/goals/ 和 .local/ 已被 Git 忽略。 ``` 如果 LoopX command facade 已安装,可以在 Codex surface 中选择 `LoopX` skill,或使用: diff --git a/docs/book/chapters/07-codex-cli.md b/docs/book/chapters/07-codex-cli.md index d2da3cb0da..856b2d4b72 100644 --- a/docs/book/chapters/07-codex-cli.md +++ b/docs/book/chapters/07-codex-cli.md @@ -25,7 +25,7 @@ codex ```text 连接当前项目到 LoopX。先运行 loopx doctor,复用已有 active state, -确认 .loopx/、.codex/goals/ 和 .local/ 已被 Git 忽略。不要使用隐藏的 +确认 .loopx/、.loopx/goals/ 和 .local/ 已被 Git 忽略。不要使用隐藏的 headless execution。连接完成后,生成 thin heartbeat task body,并把当前 Codex CLI task 设置为可见的 /goal 。最后报告 active state id、 当前 user gate、top agent todo 和 next safe action。 diff --git a/docs/book/chapters/11-engineering-boundaries.md b/docs/book/chapters/11-engineering-boundaries.md index 8296dc4a9b..b5a3b89ecd 100644 --- a/docs/book/chapters/11-engineering-boundaries.md +++ b/docs/book/chapters/11-engineering-boundaries.md @@ -158,7 +158,7 @@ loopx check \ - credentials、token、cookie; - 本机绝对路径; -- `.loopx/`、`.codex/goals/` 或 runtime state; +- `.loopx/`、`.loopx/goals/` 或 runtime state; - raw Agent transcript、trajectory、verifier output; - 私有 issue、内部链接和未经脱敏的组织叙事; - 临时探针和生成日志。 diff --git a/docs/book/chapters/source-protocol-map.md b/docs/book/chapters/source-protocol-map.md index b3cbc82e39..8507370be4 100644 --- a/docs/book/chapters/source-protocol-map.md +++ b/docs/book/chapters/source-protocol-map.md @@ -332,7 +332,7 @@ contract。`loopx/extensions/` 也不是“所有外部集成”的收纳箱: 不要从这些内容创建公开任务: -- `.loopx/`、`.codex/goals/` 或 live active state; +- `.loopx/`、`.loopx/goals/` 或 live active state; - private benchmark trace、raw agent session 或 verifier output; - 内部文档、生产凭据、本机路径; - `Maintainer-owned` live run 的推测性复刻。 diff --git a/docs/book/chapters/source-validation-to-pr.md b/docs/book/chapters/source-validation-to-pr.md index 4798cfe287..0ea8aa8cba 100644 --- a/docs/book/chapters/source-validation-to-pr.md +++ b/docs/book/chapters/source-validation-to-pr.md @@ -338,7 +338,7 @@ git ls-files --others --exclude-standard | Product code | protocol policy、writer、projection | 若属于本 PR,提交 | | Public docs | protocol、contributor guide | 若解释当前行为,提交 | | Durable validation | contract test、public-safe smoke | 若保护本规则,提交 | -| Local/private state | `.loopx/`、`.codex/goals/`、live state | 不提交 | +| Local/private state | `.loopx/`、`.loopx/goals/`、live state | 不提交 | | Generated/raw evidence | logs、transcript、verifier tail | 不提交 | | Unrelated artifact | 其他实验或格式化 | 留在 PR 外 | @@ -570,7 +570,7 @@ observed in target environment - [ ] Fake、fixture 与 snapshot 没有发明产品语义; - [ ] 验证失败被正确分类,没有把 infra failure 写成产品结论; - [ ] 所有变化路径已分类并通过显式 pathspec 暂存; -- [ ] `.loopx/`、`.codex/goals/`、live state、凭据、私有链接、raw logs 和本机路径未提交; +- [ ] `.loopx/`、`.loopx/goals/`、live state、凭据、私有链接、raw logs 和本机路径未提交; - [ ] Commit 与 PR 都以协议结果组织,不以函数列表组织; - [ ] Compatibility、recovery、未验证项和 owner gate 已明确; - [ ] PR 关联公开 Issue/任务,且未复制 maintainer-owned work; diff --git a/docs/book/en/chapters/04-runtime-boundaries.md b/docs/book/en/chapters/04-runtime-boundaries.md index e2ada64588..3bbca128df 100644 --- a/docs/book/en/chapters/04-runtime-boundaries.md +++ b/docs/book/en/chapters/04-runtime-boundaries.md @@ -368,7 +368,7 @@ The project-onboarding chapter requires these directories to stay outside Git: ```text .loopx/ -.codex/goals/ +.loopx/goals/ .local/ ``` diff --git a/docs/book/en/chapters/05-connect-existing-project.md b/docs/book/en/chapters/05-connect-existing-project.md index f11a735c8c..6e27538a24 100644 --- a/docs/book/en/chapters/05-connect-existing-project.md +++ b/docs/book/en/chapters/05-connect-existing-project.md @@ -19,10 +19,10 @@ When onboarding is complete: - `loopx doctor` reports a usable installation; - `.loopx/registry.json` exists in the project; -- `.codex/goals//ACTIVE_GOAL_STATE.md` exists; +- `.loopx/goals//ACTIVE_GOAL_STATE.md` exists; - `loopx status` can show the active state and the current frontier; a first connection creates no onboarding Todo, so the Agent writes the first delivery Todo after you confirm it; -- `.loopx/` and `.codex/goals/` do not enter Git; +- `.loopx/` and `.loopx/goals/` do not enter Git; - reconnecting reuses the exact existing `goal_id` instead of overwriting the Goal; - a new executor receives a fresh `agent_id` unless the user explicitly authorizes a takeover. @@ -42,7 +42,7 @@ Goal: Execution contract: 1. Begin with a read-only inspection of the project root, current branch, git status, .gitignore, and any - existing .loopx/registry.json, .codex/goals/, or other LoopX state. Do not overwrite, reset, or clean + existing .loopx/registry.json, .loopx/goals/, or other LoopX state. Do not overwrite, reset, or clean existing material. 2. Run loopx --version and loopx doctor, then read the current --help for every command you need. Do not rely on remembered arguments from an older version. If LoopX is not installed, report what is missing @@ -50,7 +50,7 @@ Execution contract: discovered install command as a completed installation. 3. If LoopX state exists, read loopx registry, loopx status, and relevant history first. Prefer the exact existing goal_id. Do not force a reconnect or select a Goal from objective similarity. -4. Ensure .loopx/, .codex/goals/, and .local/ are ignored by Git. If those paths already serve another +4. Ensure .loopx/, .loopx/goals/, and .local/ are ignored by Git. If those paths already serve another project purpose or are tracked, stop and report the conflict. Do not delete or untrack them yourself. 5. For a project that is not connected, run loopx connect --dry-run first and show the state it would create or change. Run loopx connect only after confirming there is no conflict. Do not bootstrap again @@ -66,7 +66,7 @@ Execution contract: 9. Stop at a Gate for user approval, external writes, credentials, wider permissions, Host selection, or destructive Git operations. Do not decide those for me. 10. Verify loopx status, todo list, history, quota should-run, git status, and - git ls-files .loopx .codex/goals .local. + git ls-files .loopx .loopx/goals .local. 11. Do not commit or push. Finish with an "onboarding report" that names goal_id, agent_id, Host, changed files, current Todos and Gates, executed mutations, verification, unresolved issues, and the next action. If you completed only a preview, explicitly say that onboarding is not complete. @@ -168,7 +168,7 @@ Before connecting, add local control state to the project's `.gitignore`: ```text .loopx/ -.codex/goals/ +.loopx/goals/ .local/ ``` @@ -180,7 +180,7 @@ Confirm the ignore rules: ```bash git check-ignore -v .loopx/registry.json -git check-ignore -v .codex/goals/example/ACTIVE_GOAL_STATE.md +git check-ignore -v .loopx/goals/example/ACTIVE_GOAL_STATE.md ``` For paths that do not yet exist, Git may need `--no-index`: @@ -296,7 +296,7 @@ After connecting: ```bash git status --short -git ls-files .loopx .codex/goals .local +git ls-files .loopx .loopx/goals .local ``` The second command should print nothing. If it lists a path, Git is already tracking local control state; diff --git a/docs/book/en/chapters/06-codex-app.md b/docs/book/en/chapters/06-codex-app.md index 3d9f95569d..b0a931b06f 100644 --- a/docs/book/en/chapters/06-codex-app.md +++ b/docs/book/en/chapters/06-codex-app.md @@ -24,7 +24,7 @@ Ask the App Agent to begin with read-only inspection: ```text Inspect the current project's LoopX connection. Run loopx doctor, loopx registry, and loopx status first. Reuse existing active state and do not overwrite the Goal. -Confirm that .loopx/, .codex/goals/, and .local/ are ignored by Git. +Confirm that .loopx/, .loopx/goals/, and .local/ are ignored by Git. ``` If the LoopX command facade is installed, select the `LoopX` skill or use: diff --git a/docs/book/en/chapters/07-codex-cli.md b/docs/book/en/chapters/07-codex-cli.md index 802768d0e6..0cea54545a 100644 --- a/docs/book/en/chapters/07-codex-cli.md +++ b/docs/book/en/chapters/07-codex-cli.md @@ -25,7 +25,7 @@ Send this setup request: ```text Connect the current project to LoopX. Run loopx doctor first, reuse existing -active state, and confirm that .loopx/, .codex/goals/, and .local/ are ignored. +active state, and confirm that .loopx/, .loopx/goals/, and .local/ are ignored. Do not use hidden headless execution. After connection, generate a thin heartbeat task body and set the current Codex CLI task to a visible /goal . Report the active state id, current user gate, top agent todo, and next safe action. diff --git a/docs/book/en/chapters/11-engineering-boundaries.md b/docs/book/en/chapters/11-engineering-boundaries.md index 328a177c07..8a838b67d0 100644 --- a/docs/book/en/chapters/11-engineering-boundaries.md +++ b/docs/book/en/chapters/11-engineering-boundaries.md @@ -163,7 +163,7 @@ Review manually for: - credentials, tokens, and cookies; - absolute machine paths; -- `.loopx/`, `.codex/goals/`, or runtime state; +- `.loopx/`, `.loopx/goals/`, or runtime state; - raw agent transcripts, trajectories, and verifier output; - private Issues, internal links, and unredacted organizational narrative; - temporary probes and generated logs. diff --git a/docs/book/en/chapters/source-protocol-map.md b/docs/book/en/chapters/source-protocol-map.md index 4549bca799..f9c1dd878e 100644 --- a/docs/book/en/chapters/source-protocol-map.md +++ b/docs/book/en/chapters/source-protocol-map.md @@ -349,7 +349,7 @@ Do not infer public work from maintainer-local state. Use public surfaces: Do not create public tasks from: -- `.loopx/`, `.codex/goals/`, or live active state; +- `.loopx/`, `.loopx/goals/`, or live active state; - private benchmark traces, raw Agent sessions, or verifier output; - internal documents, production credentials, or machine paths; - speculative duplication of `Maintainer-owned` live runs. diff --git a/docs/book/en/chapters/source-validation-to-pr.md b/docs/book/en/chapters/source-validation-to-pr.md index 537a6db9a6..7f985936db 100644 --- a/docs/book/en/chapters/source-validation-to-pr.md +++ b/docs/book/en/chapters/source-validation-to-pr.md @@ -343,7 +343,7 @@ Git hygiene is part of the public/private contract. | Product code | Protocol policy, writer, projection | Commit when required by this PR | | Public docs | Protocol, contributor guide | Commit when it explains current behavior | | Durable validation | Contract test, public-safe smoke | Commit when it protects the rule | -| Local/private state | `.loopx/`, `.codex/goals/`, live state | Never commit | +| Local/private state | `.loopx/`, `.loopx/goals/`, live state | Never commit | | Generated/raw evidence | Logs, transcripts, verifier tails | Never commit | | Unrelated artifact | Another experiment or formatter churn | Keep outside the PR | @@ -580,7 +580,7 @@ Before opening the PR, confirm: - [ ] Fakes, fixtures, and snapshots do not invent product semantics. - [ ] Validation failures are classified accurately. - [ ] Every changed path is classified and staged explicitly. -- [ ] `.loopx/`, `.codex/goals/`, live state, credentials, private links, raw logs, and machine paths are absent. +- [ ] `.loopx/`, `.loopx/goals/`, live state, credentials, private links, raw logs, and machine paths are absent. - [ ] Commits and PR text are organized around protocol results, not function lists. - [ ] Compatibility, recovery, unverified items, and owner Gates are explicit. - [ ] The PR links public work and does not duplicate maintainer-owned execution. diff --git a/docs/book/en/welcome-wagon.md b/docs/book/en/welcome-wagon.md index 02d3bfb895..425ae6934d 100644 --- a/docs/book/en/welcome-wagon.md +++ b/docs/book/en/welcome-wagon.md @@ -72,7 +72,7 @@ environment. Never put credentials, private project names, internal links, absolute machine paths, raw transcripts, -`.loopx/`, `.codex/goals/`, or unsanitized logs in public feedback or contributions. +`.loopx/`, `.loopx/goals/`, or unsanitized logs in public feedback or contributions. ## A. Run LoopX Once {#run-once} @@ -118,7 +118,7 @@ A successful first run should prove at least: Host-specific checks, first use `loopx agent-onboard --list-agent-types` to select the exact type, then run `loopx doctor --agent-type `; - `loopx status` shows the exact Goal, current Gate, and next Todo; -- `.loopx/`, `.codex/goals/`, and `.local/` remain outside Git; +- `.loopx/`, `.loopx/goals/`, and `.local/` remain outside Git; - the current Host loop driver is active, or the output gives an explicit manual start step; - Goal selection, identity takeover, credentials, and external writes stop at a Gate. diff --git a/docs/book/welcome-wagon.md b/docs/book/welcome-wagon.md index 5e6dcfb049..2114825c1c 100644 --- a/docs/book/welcome-wagon.md +++ b/docs/book/welcome-wagon.md @@ -68,7 +68,7 @@ git status --short --branch 在任何公开反馈或贡献中,都不要粘贴凭据、私有项目名、内部链接、本机绝对路径、raw transcript、 -`.loopx/`、`.codex/goals/` 或未脱敏日志。 +`.loopx/`、`.loopx/goals/` 或未脱敏日志。 ## A. 跑通一次 LoopX {#run-once} @@ -113,7 +113,7 @@ Host 没有原生 `/loopx` 入口时,使用 检查时,先用 `loopx agent-onboard --list-agent-types` 选择准确类型,再运行 `loopx doctor --agent-type `; - `loopx status` 能看到精确 Goal、当前 Gate 和下一项 Todo; -- `.loopx/`、`.codex/goals/` 与 `.local/` 没有进入 Git; +- `.loopx/`、`.loopx/goals/` 与 `.local/` 没有进入 Git; - 当前 Host 的 loop driver 已激活,或返回了明确的人工启动步骤; - 遇到 Goal 选择、identity takeover、凭据或外部写入时,流程停在 Gate。 diff --git a/docs/community/github-maintenance-ops-best-practices.md b/docs/community/github-maintenance-ops-best-practices.md index 6351491b6b..1360a2e422 100644 --- a/docs/community/github-maintenance-ops-best-practices.md +++ b/docs/community/github-maintenance-ops-best-practices.md @@ -187,7 +187,7 @@ loopx connect loopx start-goal --guided --project . --goal-text "Your long-running objective" ``` -Keep `.loopx/`, `.codex/goals/`, and `.local/` ignored. From your agent host, +Keep `.loopx/`, `.loopx/goals/`, and `.local/` ignored. From your agent host, drive the goal with `/loopx ` (Codex App/CLI), `/loopx` + `/loop` (Claude Code), the goal bridge (OpenCode), or the Pi goal extension. diff --git a/docs/community/github-maintenance-ops-best-practices.zh-CN.md b/docs/community/github-maintenance-ops-best-practices.zh-CN.md index 770338ece8..2dee1d99f2 100644 --- a/docs/community/github-maintenance-ops-best-practices.zh-CN.md +++ b/docs/community/github-maintenance-ops-best-practices.zh-CN.md @@ -153,7 +153,7 @@ loopx connect loopx start-goal --guided --project . --goal-text "你的长程目标" ``` -保持 `.loopx/`、`.codex/goals/`、`.local/` 忽略。接入后从宿主 agent 用 +保持 `.loopx/`、`.loopx/goals/`、`.local/` 忽略。接入后从宿主 agent 用 `/loopx `(Codex App/CLI)、`/loopx` + `/loop`(Claude Code)、goal bridge(OpenCode)或 Pi goal 扩展驱动目标。 diff --git a/docs/development/control-plane-course/04-state-substrate.md b/docs/development/control-plane-course/04-state-substrate.md index 52e22019a5..fa275e61c5 100644 --- a/docs/development/control-plane-course/04-state-substrate.md +++ b/docs/development/control-plane-course/04-state-substrate.md @@ -664,7 +664,7 @@ payload["agent_management_projection"] = ( 选一个 todo id,沿下面四处追同一个稳定标识: ```bash -rg -n "" .codex/goals .loopx "$HOME/.codex/loopx" 2>/dev/null +rg -n "" .loopx .codex/goals "$HOME/.loopx" "$HOME/.codex/loopx" 2>/dev/null loopx --format json todo list --goal-id loopx --format json status --goal-id loopx --format json quota should-run --goal-id --agent-id diff --git a/docs/guides/auto-research-stop-takeover-wake-walkthrough.md b/docs/guides/auto-research-stop-takeover-wake-walkthrough.md index 6b4a702d7b..9b16a202f0 100644 --- a/docs/guides/auto-research-stop-takeover-wake-walkthrough.md +++ b/docs/guides/auto-research-stop-takeover-wake-walkthrough.md @@ -46,8 +46,8 @@ that the stop/resume commands below could not address: export GOAL_ID="loopx-auto-research-demo" export WORKSPACE="$HOME/loopx-auto-research-demo" -export LOOPX_REGISTRY="$HOME/.codex/loopx/registry.global.json" -export LOOPX_RUNTIME_ROOT="$HOME/.codex/loopx" +export LOOPX_REGISTRY="$HOME/.loopx/registry.global.json" +export LOOPX_RUNTIME_ROOT="$HOME/.loopx" loopx --registry "$LOOPX_REGISTRY" \ --runtime-root "$LOOPX_RUNTIME_ROOT" \ diff --git a/docs/guides/codex-app-autonomous-goal-experience.md b/docs/guides/codex-app-autonomous-goal-experience.md index 427b9a137c..7231463a2a 100644 --- a/docs/guides/codex-app-autonomous-goal-experience.md +++ b/docs/guides/codex-app-autonomous-goal-experience.md @@ -40,7 +40,7 @@ $loopx 任务 - 你理解启用 Heartbeat 后会周期性消耗 Agent 计算。首次安装前必须审阅 预览并明确确认。 -不要把 `.loopx/`、`.codex/goals/`、运行记录、自动化数据库、凭证或原始 +不要把 `.loopx/`、`.loopx/goals/`、运行记录、自动化数据库、凭证或原始 Agent 日志提交到仓库。 ## 第 1 步:创建隔离的试验仓库 diff --git a/docs/guides/custom-agent-runner-integration.md b/docs/guides/custom-agent-runner-integration.md index fa8392ec07..4894c6b594 100644 --- a/docs/guides/custom-agent-runner-integration.md +++ b/docs/guides/custom-agent-runner-integration.md @@ -109,7 +109,7 @@ Use JSON for the machine path: ```bash loopx --format json \ - --registry "$HOME/.codex/loopx/registry.global.json" \ + --registry "$HOME/.loopx/registry.global.json" \ quota should-run \ --goal-id \ --agent-id \ diff --git a/docs/guides/custom-agent-runner-integration.zh-CN.md b/docs/guides/custom-agent-runner-integration.zh-CN.md index 4d739e5200..b6fee9e6b2 100644 --- a/docs/guides/custom-agent-runner-integration.zh-CN.md +++ b/docs/guides/custom-agent-runner-integration.zh-CN.md @@ -99,7 +99,7 @@ re-entry instruction,要求 Agent: ```bash loopx --format json \ - --registry "$HOME/.codex/loopx/registry.global.json" \ + --registry "$HOME/.loopx/registry.global.json" \ quota should-run \ --goal-id \ --agent-id \ diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index d9cabc9f27..78b1ba51b9 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -36,14 +36,15 @@ Then run `loopx doctor`. Work only from the current project root: goal or the active objective. 2. If the project is not connected, prefer `loopx connect`; use `loopx bootstrap` only when project state clearly needs initialization. -3. Ensure `.loopx/`, `.codex/goals/`, and `.local/` are ignored. +3. Ensure `.loopx/` and `.local/` are ignored; keep legacy `.codex/goals/` + ignored when it exists. 4. Set up the thin LoopX heartbeat for this surface. For Codex App, start the recurring automation at 3 minutes, then follow `quota should-run.scheduler_hint` for backoff and self-stop behavior. 5. Stop after setup and report the active state id, current user gate, top agent todo, and next safe action. -Do not commit `.loopx/`, `.codex/goals/`, `.local/`, live ACTIVE_GOAL_STATE +Do not commit `.loopx/`, legacy `.codex/goals/`, `.local/`, live ACTIVE_GOAL_STATE files, runtime registries, raw logs, credentials, or private local paths. Do not start longer delivery work in this setup turn. ``` @@ -63,10 +64,16 @@ Success looks like this: - `loopx doctor` passes; - the project has `.loopx/registry.json`; -- the project has `.codex/goals//ACTIVE_GOAL_STATE.md`; +- a new project has `.loopx/goals//ACTIVE_GOAL_STATE.md`; - `loopx status` shows the goal and who should act next; - local runtime state is ignored, not committed. +For existing installations, `loopx doctor --format json` reports +`local_state_route`. A legacy-only installation stays on its declared route +until the operator follows the [explicit local-state migration](../product/migrations/local-state-path-migration.md). +Keep the old project `.codex/goals/` directory ignored until migration and +readback are complete. + ## Command Skill Registration The installer also registers the LoopX command family for host surfaces that @@ -173,10 +180,10 @@ Write the archive only when the preview looks right: loopx backup-state --project . --execute ``` -The backup is written under `~/.codex/loopx/backups` by default. It captures the +The backup is written under `~/.loopx/backups` by default. It captures the shared LoopX runtime root, Codex App automations, installed `loopx-*` skills, the current project's state, and every reachable project's `.loopx`, -`.codex/goals`, `.claude/goals`, `.local/goals`, registry-declared active state, +`.loopx/goals`, `.claude/goals`, `.local/goals`, registry-declared active state, and source registry discovered from the global registry. Missing or stale project routes remain visible in the manifest. Use `--current-project-only` only when a deliberately narrow archive is sufficient. Treat the archive and @@ -216,7 +223,7 @@ loopx workflow-skills --install Then run `loopx doctor`. Work only from this project root: if LoopX state already exists, reuse it and do not create or overwrite a goal or the active objective; if the project is not connected, prefer `loopx connect`, and use `loopx bootstrap` only when -project state clearly needs initialization. Ensure `.loopx/`, `.codex/goals/`, +project state clearly needs initialization. Ensure `.loopx/`, `.loopx/goals/`, and `.local/` are ignored. Keep me in this TUI, do not use hidden headless execution. After the project is connected, generate the thin heartbeat prompt and set the current Codex CLI task body with `/goal `. Then @@ -617,8 +624,8 @@ Keep three layers separate: - **Global skill behavior** belongs in `skills/` and is installed under `~/.codex/skills`. -- **Project state** belongs in `.loopx/`, `.codex/goals/`, and - `~/.codex/loopx`; keep it local unless a sanitized fixture is +- **Project state** belongs in `.loopx/`, `.loopx/goals/`, and + `~/.loopx`; keep it local unless a sanitized fixture is intentionally committed. - **Repository rules** belong in `AGENTS.md`, `CONTRIBUTING.md`, and public docs. They can constrain contributors and agents in this repository, but they @@ -683,7 +690,7 @@ loopx uninstall-project --goal-id --archive-state --execute from the shared global registry only when the global entry's `source_registry` points back to this project. It does not uninstall the LoopX CLI and does not delete other projects' runtime history. Pass `--archive-state` to move this -project's `.codex/goals//` directory under +project's registered Goal state directory under `.loopx/archived-project-state/` instead of leaving it in place. For manual cleanup of the reusable LoopX CLI and skill surfaces, remove only @@ -700,7 +707,7 @@ rm -rf ~/.codex/skills/loopx-project \ ``` This does not archive connected project state or runtime history. Archive or -remove `.loopx/`, `.codex/goals/`, and `~/.codex/loopx` only when +remove `.loopx/` and `~/.loopx` only when you intentionally want to retire those local project records. ## Connect A Project Manually @@ -726,9 +733,9 @@ This creates or connects: ```text your-project/ .loopx/registry.json - .codex/goals/your-project-goal/ACTIVE_GOAL_STATE.md + .loopx/goals/your-project-goal/ACTIVE_GOAL_STATE.md -~/.codex/loopx/ +~/.loopx/ goals//runs/ ``` @@ -737,6 +744,7 @@ the connected project `.gitignore` before committing: ```gitignore .loopx/ +# Legacy projects only: .codex/goals/ .opencode/goals/ goals/**/ACTIVE_GOAL_STATE.md @@ -1002,7 +1010,7 @@ the completed migration again. `register-agent` resolves the existing global entry's `source_registry`, writes the project-local source of truth, and then syncs the shared global projection. -If `~/.codex/loopx/registry.global.json` is not writable, the command fails +If `~/.loopx/registry.global.json` is not writable, the command fails before changing the source registry and reports a `global_registry_write_denied` health error. Fix the shared runtime permission or run from a host that can write the LoopX runtime root, then rerun the command. Use `--no-global-sync` diff --git a/docs/guides/installing-loopx.md b/docs/guides/installing-loopx.md index a1f6a920d0..be35c71a8d 100644 --- a/docs/guides/installing-loopx.md +++ b/docs/guides/installing-loopx.md @@ -121,7 +121,7 @@ If installation used `-AddToUserPath`, remove the chosen `BinDir` from the Windows user PATH through Windows Environment Variables after uninstalling. Installation and PATH opt-in only expose local command and skill files. They do not grant repository, network, credential, external-system, or merge authority, -and uninstall does not delete project-local `.loopx/`, `.codex/goals/`, or +and uninstall does not delete project-local `.loopx/`, legacy `.codex/goals/`, or evidence state. ## Host Command Surfaces @@ -371,8 +371,12 @@ python3 -m pip uninstall loopx ``` Both host uninstallers preserve same-name files whose content changed after -LoopX installed them. Project-local `.loopx/`, `.codex/goals/`, evidence, and +LoopX installed them. Project-local `.loopx/`, legacy `.codex/goals/`, evidence, and runtime state are not deleted by package uninstall. +New installs place runtime state under `~/.loopx`. An existing legacy-only +installation continues to use its declared `~/.codex/loopx` route until the +operator follows the [explicit local-state migration](../product/migrations/local-state-path-migration.md). + Contributors who need a live canary should use a real checkout and `scripts/install-local.sh`; see [Getting Started](getting-started.md). diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index a55cfbfac5..9cc1833f72 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -415,7 +415,7 @@ if ! command -v loopx >/dev/null 2>&1; then fi fi loopx doctor >/dev/null -loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id --runtime-profile codex_app_heartbeat --turn-instance-id "${LOOPX_TURN:?}" +loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id --runtime-profile codex_app_heartbeat --turn-instance-id "${LOOPX_TURN:?}" If that preflight still fails, do not do implementation work, adapter work, file edits, research, project exploration, or quota spend in this turn. Return @@ -604,7 +604,7 @@ If the result says should_run=true: A plain state-only refresh is quota-neutral and cannot replace it. Then, for a minute-based heartbeat, spend one slot: - loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id --todo-id --slots 1 --source heartbeat --execute + loopx --format json --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id --todo-id --slots 1 --source heartbeat --execute Run it exactly once as rendered; no pipe/filter/retry. If spend output is ambiguous, verify with read-only quota status; never rerun. @@ -695,7 +695,7 @@ Advance using . Before any delivery work, export `$HOME/.local/bin` onto PATH and run `loopx doctor`; if the CLI is still unavailable, quietly report that preflight failure and do no work. Then copy this trigger's `` into `LOOPX_TURN` and run -`loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id --runtime-profile codex_app_heartbeat --turn-instance-id "${LOOPX_TURN:?}"`. If it +`loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id --runtime-profile codex_app_heartbeat --turn-instance-id "${LOOPX_TURN:?}"`. If it returns `should_run=false`, ask about operator gates with NOTIFY using `gate_prompt` unless the same unresolved gate was already surfaced recently. If the payload says `notify_user_on_open_todo=true`, ask up to three open @@ -741,7 +741,7 @@ boundary is already clear. Validate it, write back changed files / validation / critic / next action; for non-trivial feature slices, create a successor todo or write a compact no-follow-up rationale; append one accountable `refresh-state --delivery-outcome outcome_progress`, then exactly one -`loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id --todo-id --slots 1 --source heartbeat --execute` +`loopx --format json --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id --todo-id --slots 1 --source heartbeat --execute` event for the completed turn; run it as rendered, without pipes or filters, and never rerun it. Only an optional state-only refresh belongs after spend. Use `--slots 1` for minute-based heartbeats; for coarser intervals, spend the diff --git a/docs/integration.md b/docs/integration.md index b60130925e..f12fe06f69 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -121,7 +121,7 @@ The default files are: ```text .loopx/registry.json -.codex/goals//ACTIVE_GOAL_STATE.md +.loopx/goals//ACTIVE_GOAL_STATE.md ``` The generated registry entry also includes an `execution_profile`. This is the @@ -201,7 +201,7 @@ loopx connect \ Both entries live in the same local `.loopx/registry.json`, but each goal must own its own ignored active state under -`.codex/goals//ACTIVE_GOAL_STATE.md`. +`.loopx/goals//ACTIVE_GOAL_STATE.md`. Sharing the same `state_file` across two goal ids is treated as a registry health error because it lets one lane overwrite or summarize the other's state. Do not commit the live `ACTIVE_GOAL_STATE.md`; publish a sanitized template or @@ -217,7 +217,7 @@ loopx quota should-run --goal-id side-bypass `read-only-map` is goal-aware for same-repo setups. In addition to the generic project inventory, it reports whether the selected goal has a local project -registry, a `.codex/goals//` state directory, and the declared active +registry, a `.loopx/goals//` state directory, and the declared active state file. A missing side-lane state directory produces `project_goal_state_dir_not_detected:` plus the legacy `project_local_goal_state_not_detected` risk, while a healthy main lane in the @@ -316,7 +316,7 @@ work state or local evidence, add them to `.gitignore`: ```gitignore .loopx/ -.codex/goals/ +.loopx/goals/ ``` ## Project Adapter @@ -422,7 +422,7 @@ parallel when scopes permit. All adapters should save compact run history under: ```text -~/.codex/loopx/goals//runs/index.jsonl +~/.loopx/goals//runs/index.jsonl ``` This gives the app, CLI, heartbeats, and future UI one place to inspect goal @@ -431,7 +431,7 @@ history. Project-local registries should also sync into the shared global registry: ```text -~/.codex/loopx/registry.global.json +~/.loopx/registry.global.json ``` `loopx connect` and `loopx refresh-state` do this automatically. diff --git a/docs/integrations/codex-subagent-orchestration.md b/docs/integrations/codex-subagent-orchestration.md index f7ab71dd8a..d700915754 100644 --- a/docs/integrations/codex-subagent-orchestration.md +++ b/docs/integrations/codex-subagent-orchestration.md @@ -133,7 +133,7 @@ evidence only; the temporary task coordinator writes accepted state and spends. ```yaml subagent_control_plane_handoff_v0: parent_goal_id: example-peer-task-goal - authority_artifact: .codex/goals/example-peer-task-goal/ACTIVE_GOAL_STATE.md + authority_artifact: .loopx/goals/example-peer-task-goal/ACTIVE_GOAL_STATE.md latest_state_ref: state_hash_or_run_id quota_gate_snapshot: eligible evidence_boundary: public-safe read-only repository map diff --git a/docs/integrations/complex-project-readonly-adapter.md b/docs/integrations/complex-project-readonly-adapter.md index f1499f0321..68cd280356 100644 --- a/docs/integrations/complex-project-readonly-adapter.md +++ b/docs/integrations/complex-project-readonly-adapter.md @@ -193,7 +193,7 @@ The preview also returns `residual_risks`, using stable labels such as `project_local_goal_state_not_detected`, so the target controller can review one shared risk vocabulary. For repositories with more than one goal, the preview checks the selected -goal's own `.codex/goals//` directory. If a side bypass has not yet +goal's own `.loopx/goals//` directory. If a side bypass has not yet been connected locally, the risk list includes `project_goal_state_dir_not_detected:` even if the main control goal in the same repository is already healthy. diff --git a/docs/operations/attention-queue.md b/docs/operations/attention-queue.md index 8f02859bfe..b6a6a7b9fe 100644 --- a/docs/operations/attention-queue.md +++ b/docs/operations/attention-queue.md @@ -189,7 +189,7 @@ and exposes `loopx read-only-map --goal-id --dry-run` as appends nothing; a real map run still waits for the target controller to move the adapter to `read-only-map-ready` or `connected-read-only`. Agent executors should use -`loopx --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id ` +`loopx --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id ` as the hard compute gate. While the item is still planned, that guard stays `should_run=false` and omits `agent_command`, even though status displays the preview command for the human operator. diff --git a/docs/operations/new-project-codex-prompt.md b/docs/operations/new-project-codex-prompt.md index efecd5dc72..12c09a0ee2 100644 --- a/docs/operations/new-project-codex-prompt.md +++ b/docs/operations/new-project-codex-prompt.md @@ -107,14 +107,14 @@ loopx new-project-prompt \ active state/todo,必须同时加 `--preserve-todos`。 3. 确认 `.loopx/registry.json` 和 - `.codex/goals//ACTIVE_GOAL_STATE.md` 已创建或更新。 + `.loopx/goals//ACTIVE_GOAL_STATE.md` 已创建或更新。 接入输出里不再有 onboarding 扫描、候选 todo 或自主推进选择项;首连之后状态里 没有可执行的 agent todo。请只读核对目标文档和 registry 的 `execution_profile`, 用中文给出 1-3 个第一个交付 todo 的候选,问我确认后,用 `loopx todo add ...` 写入被接受的条目,再运行 `loopx refresh-state --goal-id ` 并汇报。在用户确认前不要开始 delivery。 - 如果目标状态包含私有证据,把 `.loopx/` 和 `.codex/goals/` + 如果目标状态包含私有证据,把 `.loopx/` 和 `.loopx/goals/` 加入该项目 `.gitignore`。 `loopx connect` 默认会同步到共享全局 registry;不要手动编辑其他 项目的 registry。 @@ -127,7 +127,7 @@ loopx new-project-prompt \ 先问 compute guard: ```bash - loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id --runtime-profile outer_controller + loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id --runtime-profile outer_controller ``` Codex App 使用紧凑别名 `--codex-app`;其他常见宿主只传一个 @@ -195,7 +195,7 @@ loopx new-project-prompt \ ```bash loopx heartbeat-prompt \ --goal-id \ - --active-state .codex/goals//ACTIVE_GOAL_STATE.md + --active-state .loopx/goals//ACTIVE_GOAL_STATE.md ``` 7. 生成一个 read-only project map 或 first pre-tick run。不要启动线上任务、 @@ -237,7 +237,7 @@ loopx new-project-prompt \ quota status 核对,不能重跑: ```bash - loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id --slots 1 --source adapter --execute + loopx --format json --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id --slots 1 --source adapter --execute ``` 如果 dashboard 或 controller 在 spend 后仍需状态更新,再运行第 8 步不带 diff --git a/docs/product/migrations/local-state-path-migration.md b/docs/product/migrations/local-state-path-migration.md new file mode 100644 index 0000000000..d594545351 --- /dev/null +++ b/docs/product/migrations/local-state-path-migration.md @@ -0,0 +1,88 @@ +# Local State Path Migration + +New LoopX installations use `$HOME/.loopx/registry.global.json` and +`/.loopx/goals//ACTIVE_GOAL_STATE.md`. An existing installation +with only `$HOME/.codex/loopx/registry.global.json` keeps using that single +legacy runtime route. Registered project Goals keep their declared `state_file` +until explicitly migrated. `loopx doctor --format json` reports the selected, +legacy, and target routes under `local_state_route`. + +This migration is distinct from `migrate-state`, which imports the older Goal +Harness product state. It moves only the LoopX runtime root and Goal directories +declared by project registries. Other files under `.codex` remain in place. + +## Preview and execute + +Stop LoopX workers, heartbeats, status servers, and other writers on this machine +before executing. Older versions do not honor a migration-wide writer fence. +Keep them stopped until status and project readback succeed. Run the preview: + +```bash +loopx --format json migrate-local-state +``` + +Review `source_runtime_root`, `target_runtime_root`, every `entries` path, +`project_count`, and `goal_directory_count`. The command validates each +registered project, state file, source registry, symlink boundary, and target +collision without writing. It stops if a registered legacy route is missing or +different from the default. Repair or retire stale registrations separately; +do not copy a guessed state directory into the target. +An explicitly configured Goal `state_file` stays where its owner placed it, but +its project `.loopx/registry.json` must still have no symlink or junction in +the directory route. Execution rechecks that route before copying, rewriting, +and restoring the registry. +The default backup parent and any explicit `--backup-dir` must have no symlink +or junction in their existing ancestors. Preview rejects such a route, and +execution checks it again before creating and copying the private backup. +Choose a real directory when a backup path is rejected. +Goal destinations use the same redirect check: preview rejects existing +symlink or Windows junction/reparse-point ancestors, and execution rechecks +the route before moving a Goal directory. +The legacy runtime tree and Goal source directories are checked before backup +and rename, and rollback rejects redirected legacy destinations or backup +snapshots. Stop other writers for the full preview, execution, and rollback. + +To apply the exact preview, use its `plan_id`: + +```bash +loopx --format json migrate-local-state \ + --execute --expected-plan-id +``` + +The plan id binds the source contents and paths. A changed source requires a new +preview. Before moving anything, LoopX copies and verifies the full legacy +runtime root, affected project registries, and each Goal directory in a private +backup next to the legacy runtime root. The successful response includes the +receipt under `backup_dir/migration-receipt.json`. Keep this backup private and +outside Git. If a write fails, LoopX attempts to restore the original routes +and reports the backup path if manual recovery is needed. + +After execution, run `loopx doctor --format json`, inspect the selected route, +and run `loopx --registry /.loopx/registry.json --format json status` +for each affected project. Existing custom `--runtime-root` and `LOOPX_REGISTRY` +configuration should be updated explicitly by its operator; migration does not +rewrite host automation settings or historic run receipts. +If the macOS dashboard LaunchAgent is in use, regenerate its command with +`bash scripts/macos-dashboard-launchagent.sh restart` from a LoopX checkout +after the route readback. Review existing Codex App automation task bodies for +an embedded legacy registry argument before resuming them. + +## Roll back + +Preview a rollback using the receipt path from execution: + +```bash +loopx --format json migrate-local-state \ + --rollback-receipt /migration-receipt.json +``` + +The rollback verifies that the migrated target content and private backup still +match the receipt, and that no legacy authority has reappeared. If any state has +changed since migration, rollback refuses to overwrite it. With a clean preview +and writers stopped, run the same command with `--execute`. Then read `doctor` +and the project registries again before restarting workers. + +If both default global registries exist, implicit CLI selection fails. Pass +explicit `--registry` and `--runtime-root` for read-only diagnosis, then resolve +the conflicting route before ordinary work. LoopX does not copy on read or keep +two writable defaults in sync. diff --git a/docs/product/migrations/loopx-rename-migration.md b/docs/product/migrations/loopx-rename-migration.md index cf70f65327..b885d41e8d 100644 --- a/docs/product/migrations/loopx-rename-migration.md +++ b/docs/product/migrations/loopx-rename-migration.md @@ -1,6 +1,8 @@ # LoopX Rename Migration -Status: active migration plan. +Status: historical Goal Harness rename plan. The current default paths and +explicit legacy migration are documented in +[Local State Path Migration](local-state-path-migration.md). LoopX is the canonical product name and `loopx` is the canonical CLI command. The rename is intentionally fail-fast at the product surface: new installs and @@ -14,7 +16,7 @@ missed migration work. - Legacy CLI command: none. `goal-harness` is not installed as an alias. - Python package/import: `loopx`. - Local project state: `.loopx/registry.json`. -- Global runtime state: `~/.codex/loopx`. +- Global runtime state at the time of this rename: `~/.codex/loopx`. - Skill names: `loopx-project`, `loopx-pr-review`, `loopx-doc-registry`, and `loopx-self-repair`. diff --git a/docs/product/migrations/loopx-state-migration-sop.md b/docs/product/migrations/loopx-state-migration-sop.md index 36c56062d1..71350a1b09 100644 --- a/docs/product/migrations/loopx-state-migration-sop.md +++ b/docs/product/migrations/loopx-state-migration-sop.md @@ -2,6 +2,10 @@ Status: draft for the LoopX rename PR. +This document describes the historical Goal Harness-to-LoopX import. For +LoopX installations moving their default runtime from `.codex/loopx` to +`.loopx`, use the [local state path migration](local-state-path-migration.md). + This SOP is for existing local users who already have Goal Harness state under the legacy runtime and want to move that state into LoopX without keeping a legacy CLI compatibility alias. diff --git a/docs/product/release-readiness.md b/docs/product/release-readiness.md index f7e667df07..a6bf76eaf5 100644 --- a/docs/product/release-readiness.md +++ b/docs/product/release-readiness.md @@ -804,7 +804,7 @@ host integrations when their focused smokes pass: - public slash command names: `/loopx`, `/loopx `, `/loopx-global-summary`, `/loopx-global-gates`, `/loopx-global-todos`, and `/loopx-global-risks`; -- ignored local state boundaries under `~/.codex/loopx`, project-local registry +- ignored local state boundaries under `~/.loopx`, project-local registry files, and project-local active-state workbench files recognized by `loopx doctor`, `loopx status`, and `loopx check`. diff --git a/docs/product/runtimes/codex-app/codex-app-control-plane-hook-cache.md b/docs/product/runtimes/codex-app/codex-app-control-plane-hook-cache.md index dbefbb9efd..695e31f73c 100644 --- a/docs/product/runtimes/codex-app/codex-app-control-plane-hook-cache.md +++ b/docs/product/runtimes/codex-app/codex-app-control-plane-hook-cache.md @@ -18,7 +18,7 @@ the fallback behavior. Today a trusted Codex App heartbeat usually runs: ```bash -loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" \ +loopx --format json --registry "$HOME/.loopx/registry.global.json" \ quota should-run --goal-id --agent-id ``` @@ -169,7 +169,7 @@ Before default candidate: The fallback remains: ```bash -loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" \ +loopx --format json --registry "$HOME/.loopx/registry.global.json" \ quota should-run --goal-id --agent-id ``` diff --git a/docs/product/runtimes/codex-cli/codex-cli-packaged-install.md b/docs/product/runtimes/codex-cli/codex-cli-packaged-install.md index 7ddc5b0b6f..c4d95ec4b0 100644 --- a/docs/product/runtimes/codex-cli/codex-cli-packaged-install.md +++ b/docs/product/runtimes/codex-cli/codex-cli-packaged-install.md @@ -99,7 +99,7 @@ The update command reports the installation owner before mutation. For a pip or pipx PyPI distribution, apply delegates the package transaction to that same environment and then refreshes skills, slash commands, doctor, enabled extensions, and managed services. For an archive install, apply plans the -source archive, preserves runtime state under `~/.codex/loopx`, and atomically +source archive, preserves runtime state under `~/.loopx`, and atomically refreshes the executable and host material. It never rewrites a live source checkout. diff --git a/docs/product/runtimes/codex-cli/codex-cli-tui-loop.md b/docs/product/runtimes/codex-cli/codex-cli-tui-loop.md index 07c2490855..030f2576c7 100644 --- a/docs/product/runtimes/codex-cli/codex-cli-tui-loop.md +++ b/docs/product/runtimes/codex-cli/codex-cli-tui-loop.md @@ -30,7 +30,7 @@ loopx workflow-skills --install Then run `loopx doctor`. Work only from this project root: if LoopX state already exists, reuse it and do not create or overwrite a goal; if the project is not connected, prefer `loopx connect`, and use `loopx bootstrap` only when -goal state clearly needs initialization. Ensure `.loopx/`, `.codex/goals/`, +goal state clearly needs initialization. Ensure `.loopx/`, `.loopx/goals/`, and `.local/` are ignored. Keep me in this TUI, do not use hidden headless execution. After the project is connected, generate the thin heartbeat prompt and set the current Codex CLI goal to `/goal `. Then stop and @@ -48,7 +48,7 @@ message should be enough for a terminal agent to: workflow-skill installer before asking the user to clone the LoopX repo; - reuse existing LoopX state without creating or overwriting a goal; - connect the repo when needed, using bootstrap only for clear initialization; -- ensure `.loopx/`, `.codex/goals/`, and `.local/` stay local; +- ensure `.loopx/`, `.loopx/goals/`, and `.local/` stay local; - generate `heartbeat-prompt --thin`; - set the current Codex CLI goal to `/goal `; - report the goal id, user gate, top agent todo, and next safe action; diff --git a/docs/public-private-boundary.md b/docs/public-private-boundary.md index bf162e1234..fd01188d88 100644 --- a/docs/public-private-boundary.md +++ b/docs/public-private-boundary.md @@ -175,7 +175,8 @@ The project repo should answer: "What is this specific goal currently doing?" The runtime root should answer: "What happened in recent goal ticks?" Real controller state belongs in ignored local files such as -`.codex/goals//ACTIVE_GOAL_STATE.md`, +`.loopx/goals//ACTIVE_GOAL_STATE.md`, +legacy `.codex/goals//ACTIVE_GOAL_STATE.md`, `.local/goals//ACTIVE_GOAL_STATE.md`, or the shared runtime root. A public repository may track sanitized templates, fixtures, and compact projections, but not the live file that a controller updates on every turn. diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 27ab1bde7c..a67f21cc66 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -687,7 +687,7 @@ For autonomous heartbeats, unchanged monitor polls can be recorded as no-spend stall evidence with: ```bash -loopx --registry "$HOME/.codex/loopx/registry.global.json" quota monitor-poll --goal-id --source heartbeat --execute +loopx --registry "$HOME/.loopx/registry.global.json" quota monitor-poll --goal-id --source heartbeat --execute ``` `quota monitor-poll` is valid when the current guard is a quiet monitor skip, @@ -871,9 +871,9 @@ The first read-only or preview commands are: ```bash loopx quota status loopx quota plan -loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id --runtime-profile codex_app_heartbeat -loopx --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id --slots 1 -loopx --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id --slots 1 --execute +loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id --runtime-profile codex_app_heartbeat +loopx --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id --slots 1 +loopx --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id --slots 1 --execute ``` These commands reuse the status contract, including contract health, global diff --git a/docs/reference/operator-model-credential.md b/docs/reference/operator-model-credential.md index 271cd66dfd..e75f7e00f1 100644 --- a/docs/reference/operator-model-credential.md +++ b/docs/reference/operator-model-credential.md @@ -18,7 +18,10 @@ The credential is its own file, not a machine-configuration namespace: /machine/credentials/operator_provider.json ``` -The canonical machine runtime is `~/.codex/loopx`, independent of `CODEX_HOME` +The canonical machine runtime is `~/.loopx` for new installations; a +legacy-only machine stays on `~/.codex/loopx` until the +[explicit migration](../product/migrations/local-state-path-migration.md). +This selection is independent of `CODEX_HOME` and any Goal's `common_runtime_root` or Turn `--runtime-root`. Turn planning, default host selection, dispatch and delegation readiness use this machine store. A Goal-local credential file does not override it. Explicit machine diff --git a/docs/reference/protocols/codex-app-host-command-registry-v0.md b/docs/reference/protocols/codex-app-host-command-registry-v0.md index e21ad080a0..45915bd493 100644 --- a/docs/reference/protocols/codex-app-host-command-registry-v0.md +++ b/docs/reference/protocols/codex-app-host-command-registry-v0.md @@ -293,7 +293,7 @@ loopx --format json start-goal --guided --project . --goal-text "" -- loopx bootstrap-command-pack --project . --goal-text "" loopx pr-review loopx global-summary -loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id --agent-id +loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id --agent-id ``` If host command parsing is unavailable, the user or a skill fallback can still diff --git a/docs/reference/protocols/host-integration-surface-v0.md b/docs/reference/protocols/host-integration-surface-v0.md index 3924c62fa9..0fd1c322db 100644 --- a/docs/reference/protocols/host-integration-surface-v0.md +++ b/docs/reference/protocols/host-integration-surface-v0.md @@ -280,7 +280,7 @@ Minimum fallback set: ```bash loopx doctor loopx --format json status --agent-id -loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id --agent-id +loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id --agent-id loopx todo claim --goal-id --todo-id --claimed-by loopx todo complete --goal-id --todo-id --claimed-by --evidence "" loopx refresh-state --goal-id --agent-id diff --git a/docs/reference/protocols/long-horizon-agent-state-protocol-v0.md b/docs/reference/protocols/long-horizon-agent-state-protocol-v0.md index 1de7f48ace..cb8f41f1d8 100644 --- a/docs/reference/protocols/long-horizon-agent-state-protocol-v0.md +++ b/docs/reference/protocols/long-horizon-agent-state-protocol-v0.md @@ -34,7 +34,7 @@ them directly. | --- | --- | --- | | `goal_identity` | registry, active state, agent profile docs | Stable `goal_id`, repo, registered peers, advisory profiles, and write boundary. | | `connection_state` | `loopx connect`, `bootstrap`, `doctor`, `sync-global` | Whether the repo is connected, read-only, bootstrapped, stale, or missing local state. | -| `local_state_boundary` | `.gitignore`, `loopx check`, getting-started docs | Keep `.loopx/`, `.codex/goals/`, `.local/`, raw logs, credentials, and private paths out of public commits. | +| `local_state_boundary` | `.gitignore`, `loopx check`, getting-started docs | Keep `.loopx/`, `.loopx/goals/`, `.local/`, raw logs, credentials, and private paths out of public commits. | | `todo_item_v0` | `loopx todo`, active-state todo sections, `loopx/status.py` | Formal work unit with role, status, task class, action kind, claim, dependency, resume, and evidence metadata. | | `interaction_contract_v0` | `loopx quota should-run`, `docs/quota-allocation.md` | Splits user, agent, and CLI obligations before an automated turn spends compute. | | `agent_lane_next_action_v0` | `loopx quota should-run --agent-id ...`, `docs/project-agent-todo-contract.md` | Per-agent selected runnable todo without replacing the goal-level next action. | diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index 7d54e73b0a..35b576dcca 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -17,8 +17,12 @@ debuggability matters. When a command is run outside a project-local `.loopx/registry.json`, the CLI falls back to the shared local global registry at -`~/.codex/loopx/registry.global.json` if it exists. That registry is -maintained automatically by `connect` and `refresh-state` so each project agent +`~/.loopx/registry.global.json` for a fresh installation. A legacy-only +installation continues to select `~/.codex/loopx/registry.global.json` until +its [explicit migration](product/migrations/local-state-path-migration.md). +If both default registries exist, implicit selection fails and `doctor` +reports a route conflict. The selected registry is maintained automatically by +`connect` and `refresh-state` so each project agent can update its own local state while dashboards still see the multi-project view. @@ -170,7 +174,7 @@ loopx --format json status \ For compute allocation, `loopx quota status` and `loopx quota plan` derive an agent-facing grouping from this same status -payload. `loopx --registry "$HOME/.codex/loopx/registry.global.json" +payload. `loopx --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id ` derives a per-goal automation guard from that grouping for project heartbeats. These are read-only views, not a separate source of truth. Scripts should treat `summary.next_automatic_turn` in the @@ -323,7 +327,7 @@ goals must stay out of the eligible lane even when they have a high "global_registry": { "available": true, "ok": true, - "registry": "~/.codex/loopx/registry.global.json", + "registry": "~/.loopx/registry.global.json", "current_registry": ".loopx/registry.json", "current_registry_is_global": false, "global_goal_count": 4, @@ -574,7 +578,7 @@ Fresh shape: { "ok": true, "registry": ".loopx/registry.json", - "runtime_root": "~/.codex/loopx", + "runtime_root": "~/.loopx", "gate": "promotion_readiness", "gate_state": "ready", "can_promote": true, @@ -1770,7 +1774,8 @@ For same-repo multi-goal projects, `project_registry_exists`, `goal_state_dir_exists`, and `active_state_file_exists` are goal-scoped health signals. A project can have both `main-control` and `side-bypass` in the same repo, but each selected `goal_id` should have its own -`.codex/goals//` directory. If that directory is missing, the map +registered Goal state directory (under `.loopx/goals` for new projects). If +that directory is missing, the map reports `project_goal_state_dir_not_detected:` and the legacy `project_local_goal_state_not_detected` risk even when another goal in the same repo is healthy. diff --git a/examples/claude-goalmode-lifecycle-smoke.py b/examples/claude-goalmode-lifecycle-smoke.py index 060e3dd7d1..ead71bbc29 100644 --- a/examples/claude-goalmode-lifecycle-smoke.py +++ b/examples/claude-goalmode-lifecycle-smoke.py @@ -29,7 +29,7 @@ def loopx(args, home=None, **kw): - # Isolate HOME so the global runtime root (~/.codex/loopx) is a temp dir — the + # Isolate HOME so the global runtime root (~/.loopx) is a temp dir — the # test never reads or pollutes the real global registry, and re-runs don't # collide on goal ids. env = {**os.environ, "PYTHONPATH": str(REPO_ROOT)} diff --git a/examples/control_plane/export-agent-management-status-example.py b/examples/control_plane/export-agent-management-status-example.py index 1ec5d5b23e..f9d30ecf58 100644 --- a/examples/control_plane/export-agent-management-status-example.py +++ b/examples/control_plane/export-agent-management-status-example.py @@ -31,8 +31,8 @@ "codex-product-capability", "codex-value-explorer", ) -PUBLIC_REGISTRY = "$HOME/.codex/loopx/registry.global.json" -PUBLIC_RUNTIME_ROOT = "$HOME/.codex/loopx" +PUBLIC_REGISTRY = "$HOME/.loopx/registry.global.json" +PUBLIC_RUNTIME_ROOT = "$HOME/.loopx" REDACTED_TEXT = "Public-safe redacted live LoopX text; inspect local status for the full row." HOME_TEXT = str(Path.home()) HOME_NAME = Path.home().name diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index dd260dda10..271a476425 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -317,12 +317,12 @@ def main() -> int: assert "full" not in payload, payload assert "Observed capabilities -> `--available-capability`; never user gates." in thin_task, thin_task assert payload["quota_guard_command"] == ( - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" ' + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" ' 'quota should-run --goal-id public-heartbeat-goal ' '--turn-instance-id "${LOOPX_TURN:?}"' ), payload assert payload["quota_spend_command"] == ( - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" ' + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" ' "quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute" ), payload assert compact_payload["compact"] is True, compact_payload @@ -465,7 +465,7 @@ def main() -> int: "Compact policy: registry/state/adapter/`goal_boundary`", "Detail:", "loopx heartbeat-prompt --full --goal-id public-heartbeat-goal --active-state /tmp/public-heartbeat-goal/ACTIVE_GOAL_STATE.md", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "state=operator_gate", "notify_user_on_open_todo=true", "`user_channel.notify=NOTIFY`", @@ -494,7 +494,7 @@ def main() -> int: "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_gate|user_action", "owner todos and `--role agent` for agent todos, not prose", "Done->successor; final->refresh/spend/no-follow-up", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute', "Account actual class/scale/outcome", "once unpiped; never retry", "Optional state-only post-spend", @@ -588,7 +588,7 @@ def main() -> int: "lifecycle/registry and `loopx-self-repair` for runtime/projection drift", "use selection_command when required", "heartbeat-prequota", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run ' + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run ' "--goal-id loopx-meta --agent-id codex-product-capability --available-capability network " "--available-capability external_evidence_poll", "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", @@ -646,7 +646,7 @@ def main() -> int: "Brief 详情:", "loopx heartbeat-prompt --compact --goal-id public-heartbeat-goal --active-state /tmp/public-heartbeat-goal/ACTIVE_GOAL_STATE.md", "Run assignment and guard as separate statements in one shell", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", "Due/peer非用户动作", "Todo验收非结算", @@ -734,7 +734,7 @@ def main() -> int: 'export PATH="$HOME/.local/bin:$PATH"', 'install_script="$HOME/loopx/scripts/install-local.sh"', "loopx doctor >/dev/null", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id ', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id ', "project non-basic capabilities that are actually present", "without guessing capabilities the host does not have", "If that preflight still fails", @@ -837,7 +837,7 @@ def main() -> int: "loopx todo add --goal-id --role user --task-class user_action", "Use `--role agent` for project-agent follow-up work", "docs/project-agent-todo-contract.md", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id --todo-id --slots 1 --source heartbeat --execute', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id --todo-id --slots 1 --source heartbeat --execute', "loopx refresh-state --goal-id ", "--classification ", "--delivery-batch-scale ", @@ -859,7 +859,7 @@ def main() -> int: 'export PATH="$HOME/.local/bin:$PATH"', 'install_script="$HOME/loopx/scripts/install-local.sh"', "loopx doctor >/dev/null", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "If that preflight still fails", "should_run=false", "state=operator_gate", @@ -940,7 +940,7 @@ def main() -> int: "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_gate", "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_action", "docs/project-agent-todo-contract.md", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute', "loopx refresh-state --goal-id public-heartbeat-goal", "--classification ", "--delivery-batch-scale ", @@ -962,7 +962,7 @@ def main() -> int: "Before spending delivery compute, first make the LoopX CLI reachable", 'export PATH="$HOME/.local/bin:$PATH"', "loopx doctor >/dev/null", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id ', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id ', "If that preflight still fails", "If the result says should_run=false", "state=operator_gate", @@ -993,7 +993,7 @@ def main() -> int: "Public-safe repo publication is not an operator gate by itself", "Run validation proportionate to the change and risk", "loopx refresh-state --goal-id ", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id --todo-id --slots 1 --source heartbeat --execute', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id --todo-id --slots 1 --source heartbeat --execute', "If the dashboard or controller needs a state-only update after spend", "Return a compact final report", ), diff --git a/examples/control_plane/quota-contract-smoke.py b/examples/control_plane/quota-contract-smoke.py index 69a5e2bb0f..4e1c419682 100644 --- a/examples/control_plane/quota-contract-smoke.py +++ b/examples/control_plane/quota-contract-smoke.py @@ -260,7 +260,7 @@ def main() -> int: ) assert_contains( status_contract, - 'loopx --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id ', + 'loopx --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id ', label="status contract", ) assert_contains( diff --git a/examples/dashboard-tracked-fixtures-smoke.py b/examples/dashboard-tracked-fixtures-smoke.py index 24d1282e41..65cb60446e 100644 --- a/examples/dashboard-tracked-fixtures-smoke.py +++ b/examples/dashboard-tracked-fixtures-smoke.py @@ -92,8 +92,8 @@ def main() -> int: assert_public_safe("/tmp/loopx-dashboard-smoke", label="temporary path") for private_text in [ - "/Users/alice/.codex/loopx/registry.global.json", - "/home/alice/.codex/loopx/registry.global.json", + "/Users/alice/.loopx/registry.global.json", + "/home/alice/.loopx/registry.global.json", r"C:\\Users\\alice\\.codex\\loopx\\registry.global.json", "https://code.byted.org/private/project", "Bearer " + "abcdef123456", diff --git a/examples/dev-book-welcome-wagon-smoke.py b/examples/dev-book-welcome-wagon-smoke.py index eee06d64a6..595e816127 100644 --- a/examples/dev-book-welcome-wagon-smoke.py +++ b/examples/dev-book-welcome-wagon-smoke.py @@ -63,7 +63,7 @@ "Python 3.11+", "Node.js 22.22.3+", ".loopx/", - ".codex/goals/", + ".loopx/goals/", ".local/", "Maintainer-owned", "DCO", diff --git a/examples/export-frontstage-share-bundle.mjs b/examples/export-frontstage-share-bundle.mjs index 9ad1afa251..36987a67af 100644 --- a/examples/export-frontstage-share-bundle.mjs +++ b/examples/export-frontstage-share-bundle.mjs @@ -410,7 +410,7 @@ async function scanPublicBoundary(outDir) { { label: "private temp path", pattern: /\/private\// }, { label: "workspace owner name", pattern: new RegExp("byte" + "dance", "i") }, { label: "internal doc host", pattern: new RegExp("lark" + "office", "i") }, - { label: "private goal state", pattern: new RegExp("\\.codex/goals|\\.goal-" + "harness") }, + { label: "private goal state", pattern: new RegExp("\\.(?:codex|loopx)/goals|\\.goal-" + "harness") }, { label: "raw internal key", pattern: new RegExp("raw_" + "internal_note") }, { label: "private key material", pattern: /BEGIN (?:RSA |OPENSSH |EC |)PRIVATE KEY/ }, { label: "token assignment", pattern: /\b(?:api[_-]?key|auth[_-]?token|access[_-]?token)\s*[:=]/i }, diff --git a/examples/frontstage-share-bundle-smoke.mjs b/examples/frontstage-share-bundle-smoke.mjs index b9789a2ce1..9c5da6ba05 100644 --- a/examples/frontstage-share-bundle-smoke.mjs +++ b/examples/frontstage-share-bundle-smoke.mjs @@ -57,7 +57,7 @@ function assertNoLeak(text, label) { /\/private\//, new RegExp("byte" + "dance", "i"), new RegExp("lark" + "office", "i"), - new RegExp("\\.codex/goals|\\.goal-" + "harness"), + new RegExp("\\.(?:codex|loopx)/goals|\\.goal-" + "harness"), new RegExp("raw_" + "internal_note"), /BEGIN (?:RSA |OPENSSH |EC |)PRIVATE KEY/, /\b(?:api[_-]?key|auth[_-]?token|access[_-]?token)\s*[:=]/i, diff --git a/examples/install-local-smoke.py b/examples/install-local-smoke.py index f8a0446a0a..2adf3b02ba 100644 --- a/examples/install-local-smoke.py +++ b/examples/install-local-smoke.py @@ -559,7 +559,7 @@ def main() -> int: ) cli_env = {**env, "PATH": f"{bin_dir}:{env['PATH']}"} - runtime_run_dir = home / ".codex" / "loopx" / "goals" / "loopx-meta" / "runs" + runtime_run_dir = home / ".loopx" / "goals" / "loopx-meta" / "runs" generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat() write_promotion_readiness(runtime_run_dir, generated_at=generated_at, label="fresh") @@ -790,7 +790,7 @@ def main() -> int: assert payload["ok"] is True, payload assert payload["schema_version"] == "heartbeat_agent_input_v1", payload expected_quota_guard = ( - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" ' + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" ' 'quota should-run --goal-id installer-smoke-goal ' '--turn-instance-id "${LOOPX_TURN:?}"' ) diff --git a/examples/peer-agent-task-orchestration.registry.example.json b/examples/peer-agent-task-orchestration.registry.example.json index 18efb4cf49..07844b9826 100644 --- a/examples/peer-agent-task-orchestration.registry.example.json +++ b/examples/peer-agent-task-orchestration.registry.example.json @@ -8,7 +8,7 @@ "domain": "complex-project-control-plane", "status": "active", "repo": "/path/to/project", - "state_file": ".codex/goals/example-peer-task-goal/ACTIVE_GOAL_STATE.md", + "state_file": ".loopx/goals/example-peer-task-goal/ACTIVE_GOAL_STATE.md", "authority_sources": [ { "kind": "goal_doc", diff --git a/examples/project/project-prompt-smoke.py b/examples/project/project-prompt-smoke.py index 03e787c477..3743567669 100644 --- a/examples/project/project-prompt-smoke.py +++ b/examples/project/project-prompt-smoke.py @@ -71,7 +71,7 @@ "普通 state-only refresh 不能替代它", "然后原样(不加管道或过滤)append 一次 quota spend", "不能重跑", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id', + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota spend-slot --goal-id', "--source adapter --execute", "在 spend 后仍需状态更新", "不要在 spend 后追加另一个 accountable progress refresh", @@ -83,7 +83,7 @@ "如果要给这个项目设置 recurring Codex App heartbeat", "默认每 3 分钟一次", "loopx heartbeat-prompt", - "--active-state .codex/goals/", + "--active-state .loopx/goals/", "再把输出复制进 automation", ) HANDOFF_MUST_HAVE = ( @@ -108,7 +108,7 @@ def assert_quota_guard(text: str) -> None: assert 'export PATH="$HOME/.local/bin:$PATH"' in text, text assert 'install_script="$HOME/loopx/scripts/install-local.sh"' in text, text assert "loopx doctor >/dev/null" in text, text - assert 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id' in text, text + assert 'loopx --format json --registry "$HOME/.loopx/registry.global.json" quota should-run --goal-id' in text, text assert "--runtime-profile outer_controller" in normalized, text positions = [] for phrase in MUST_HAVE: @@ -165,12 +165,12 @@ def main() -> int: write_scope=None, ) assert payload["quota_guard_command"] == ( - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" ' + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" ' "quota should-run --goal-id new-project-main-control " "--runtime-profile outer_controller" ), payload assert payload["quota_spend_command"] == ( - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" ' + 'loopx --format json --registry "$HOME/.loopx/registry.global.json" ' "quota spend-slot --goal-id new-project-main-control --slots 1 --source adapter --execute" ), payload assert payload["progress_refresh_command"] == ( diff --git a/examples/registry.example.json b/examples/registry.example.json index 42bd05aace..3f93173c16 100644 --- a/examples/registry.example.json +++ b/examples/registry.example.json @@ -8,7 +8,7 @@ "domain": "knowledge-work-control-plane", "status": "active", "repo": "/path/to/project", - "state_file": ".codex/goals/example-project-goal/ACTIVE_GOAL_STATE.md", + "state_file": ".loopx/goals/example-project-goal/ACTIVE_GOAL_STATE.md", "authority_sources": [ { "kind": "goal_doc", diff --git a/examples/showcase-animation-source-boundary-smoke.py b/examples/showcase-animation-source-boundary-smoke.py index 00fa6d51d0..c498003146 100644 --- a/examples/showcase-animation-source-boundary-smoke.py +++ b/examples/showcase-animation-source-boundary-smoke.py @@ -18,6 +18,7 @@ "registry.global.json", ".loopx/registry.json", ".codex/goals/", + ".loopx/goals/", "ACTIVE_GOAL_STATE.md", ) diff --git a/examples/status.example.json b/examples/status.example.json index 62104ebcdb..1ed7b104b9 100644 --- a/examples/status.example.json +++ b/examples/status.example.json @@ -1,7 +1,7 @@ { "ok": true, - "registry": "$HOME/.codex/loopx/registry.global.json", - "runtime_root": "$HOME/.codex/loopx", + "registry": "$HOME/.loopx/registry.global.json", + "runtime_root": "$HOME/.loopx", "goal_count": 1, "run_count": 8440, "status_contract": { @@ -119,8 +119,8 @@ }, "promotion_gate": { "ok": true, - "registry": "$HOME/.codex/loopx/registry.global.json", - "runtime_root": "$HOME/.codex/loopx", + "registry": "$HOME/.loopx/registry.global.json", + "runtime_root": "$HOME/.loopx", "gate": "promotion_readiness", "gate_state": "ready", "can_promote": true, @@ -137,7 +137,7 @@ "recommended_action": "Canary promotion-readiness smoke passed; promotion may proceed after doctor/status reports fresh evidence.", "json_exists": true, "markdown_exists": true, - "runtime_root": "$HOME/.codex/loopx", + "runtime_root": "$HOME/.loopx", "freshness_window_hours": 24, "freshness_status": "fresh", "is_fresh": true, @@ -547,7 +547,7 @@ "registry goals checked: 12", "registry boundary: shared_local_registry push_allowed=False tracked=False ignored=False", "user-gate scopes checked: 6 open multi-agent gates", - "runtime root resolved: $HOME/.codex/loopx", + "runtime root resolved: $HOME/.loopx", "run-history goals=28 runs=10210", "public boundary scan clean: 1218 files" ] @@ -555,8 +555,8 @@ "global_registry": { "available": true, "ok": true, - "registry": "$HOME/.codex/loopx/registry.global.json", - "current_registry": "$HOME/.codex/loopx/registry.global.json", + "registry": "$HOME/.loopx/registry.global.json", + "current_registry": "$HOME/.loopx/registry.global.json", "current_registry_is_global": true, "global_goal_count": 12, "current_goal_count": 12, diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index 8106dcc7bb..c36182a8ec 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -39,7 +39,12 @@ DEFAULT_ORCHESTRATION_MODE, MULTI_SUBAGENT_ORCHESTRATION_MODE, ) -from .paths import rel_or_abs, resolve_runtime_root +from .paths import ( + registered_goal_state_file, + rel_or_abs, + require_single_goal_state_route, + resolve_runtime_root, +) from .control_plane.goals.active_state_metadata import markdown_blockquote, markdown_frontmatter_string from .registry_writability import probe_registry_write_path @@ -328,7 +333,12 @@ def bootstrap_project( if not registry_path.is_absolute(): registry_path = project / registry_path goal_id = goal_id or default_goal_id(project) - state_file = state_file or (project / ".codex" / "goals" / goal_id / "ACTIVE_GOAL_STATE.md") + explicit_state_file = state_file is not None + state_file = state_file or registered_goal_state_file( + project, goal_id, read_json_if_exists(registry_path) + ) + if not explicit_state_file: + require_single_goal_state_route(project, goal_id, state_file) state_file = state_file.expanduser() if not state_file.is_absolute(): state_file = project / state_file @@ -493,7 +503,7 @@ def bootstrap_project( "If this local LoopX install is missing or stale, repair the PyPI distribution " "and packaged workflow skills, then confirm with loopx doctor before continuing." ), - "private_boundary_note": "Add .loopx/ and .codex/goals/ to the project .gitignore if the goal state contains private evidence.", + "private_boundary_note": "Add .loopx/ to the project .gitignore if the goal state contains private evidence; keep .codex/goals/ ignored while legacy state remains.", "error": str(global_writability.get("error") or "global registry is not writable"), } shadow_capture = None @@ -604,7 +614,7 @@ def bootstrap_project( "If this local LoopX install is missing or stale, repair the PyPI distribution " "and packaged workflow skills, then confirm with loopx doctor before continuing." ), - "private_boundary_note": "Add .loopx/ and .codex/goals/ to the project .gitignore if the goal state contains private evidence.", + "private_boundary_note": "Add .loopx/ to the project .gitignore if the goal state contains private evidence; keep .codex/goals/ ignored while legacy state remains.", } diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index d57bad1590..c05f05863c 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -6,6 +6,7 @@ from .agent_registry import registered_agent_ids_for_goal from .bootstrap import default_goal_id +from .paths import registered_goal_state_file from .capabilities.issue_fix.candidate_preflight import ( candidate_preflight_input_contract, ) @@ -568,7 +569,7 @@ def inspect_bootstrap_connection( registry_exists = registry_path.exists() registry, registry_error = _read_registry(registry_path) if registry_exists else (None, None) inferred_goal_id = goal_id or default_goal_id(resolved_project) - state_file = resolved_project / ".codex" / "goals" / inferred_goal_id / "ACTIVE_GOAL_STATE.md" + state_file = registered_goal_state_file(resolved_project, inferred_goal_id, registry) base_connection = { "input_project": str(input_project), "project": str(resolved_project), @@ -599,7 +600,7 @@ def inspect_bootstrap_connection( goals = registry_goals(registry) selected_goal_id, selected_goal = _select_goal(goals, goal_id) resolved_goal_id = selected_goal_id or inferred_goal_id - fallback_state_file = resolved_project / ".codex" / "goals" / resolved_goal_id / "ACTIVE_GOAL_STATE.md" + fallback_state_file = registered_goal_state_file(resolved_project, resolved_goal_id, registry) goal_state_file = ( resolve_state_file(resolved_project, str(selected_goal.get("state_file"))) if selected_goal and selected_goal.get("state_file") @@ -2108,7 +2109,7 @@ def render_loopx_bootstrap_command_pack_message(payload: dict[str, Any]) -> str: Rules: - This command pack preview is read-only. Do not run bootstrap/connect, create heartbeat automation, or spend quota while only previewing it. -- Bare `/loopx` is read/status-first: if the project is not fully connected, ask for explicit user confirmation before any command that writes `.loopx/` or `.codex/goals/`. +- Bare `/loopx` is read/status-first: if the project is not fully connected, ask for explicit user confirmation before any command that writes `.loopx/` (or legacy `.codex/goals/`). - `/loopx ` is explicit goal-start intent: it may create project-local LoopX state, but it must run the profile-appropriate planning checkpoint before writing todos, then activate the correct host loop if missing/stale. - Same-priority todos are ranked by planner order, then by `todo add` write order; preserve the order exactly. - If the project is connected, reuse the existing state and show the status/gate/todo snapshot. diff --git a/loopx/capabilities/benchmark_toolkit/artifacts.py b/loopx/capabilities/benchmark_toolkit/artifacts.py index e4df69b52e..3bc5f150b9 100644 --- a/loopx/capabilities/benchmark_toolkit/artifacts.py +++ b/loopx/capabilities/benchmark_toolkit/artifacts.py @@ -283,6 +283,7 @@ def block(reason: str) -> None: BENCHMARK_CANDIDATE_SOURCE_ACTIVE_STATE_MARKERS = ( "/active_goal_state.md", ".codex/goals/", + ".loopx/goals/", ".local/goals/", ) BENCHMARK_CANDIDATE_SOURCE_PRIVATE_RUN_MARKERS = ( diff --git a/loopx/capabilities/benchmark_toolkit/native_codex_profile.py b/loopx/capabilities/benchmark_toolkit/native_codex_profile.py index d853e50a5d..f8a3a2c9dd 100644 --- a/loopx/capabilities/benchmark_toolkit/native_codex_profile.py +++ b/loopx/capabilities/benchmark_toolkit/native_codex_profile.py @@ -32,7 +32,10 @@ "loopx", *PACKAGED_HOST_SKILL_IDS, ) -_DEFAULT_GLOBAL_REGISTRY_TOKEN = "$HOME/.codex/loopx/registry.global.json" +_DEFAULT_GLOBAL_REGISTRY_TOKENS = ( + "$HOME/.loopx/registry.global.json", + "$HOME/.codex/loopx/registry.global.json", # Explicit legacy route before migration. +) _SAFE_RELEASE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z") _SAFE_ENV_KEY = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z") _INSTALL_ENV_PASSTHROUGH = ( @@ -382,10 +385,9 @@ def render_native_codex_goal_prompt( runtime_registry_bound = runtime_registry_path is None if runtime_registry_path is not None: runtime_registry = str(Path(runtime_registry_path).expanduser().resolve()) - if _DEFAULT_GLOBAL_REGISTRY_TOKEN in task_body: - task_body = task_body.replace( - _DEFAULT_GLOBAL_REGISTRY_TOKEN, runtime_registry - ) + if any(token in task_body for token in _DEFAULT_GLOBAL_REGISTRY_TOKENS): + for token in _DEFAULT_GLOBAL_REGISTRY_TOKENS: + task_body = task_body.replace(token, runtime_registry) runtime_registry_bound = runtime_registry in task_body else: # Explicit runtime-root commands resolve their own global registry; diff --git a/loopx/capabilities/connector_registry/core.py b/loopx/capabilities/connector_registry/core.py index a19504d25c..777809313a 100644 --- a/loopx/capabilities/connector_registry/core.py +++ b/loopx/capabilities/connector_registry/core.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import Any, Mapping +from ...paths import select_default_runtime_root + CONNECTOR_REGISTRY_SCHEMA_VERSION = "connector_registry_v1" LAYER_LABELS = { @@ -86,7 +88,7 @@ def _now_iso() -> str: def default_registry_path() -> Path: - runtime_root = Path(os.environ.get("LOOPX_RUNTIME_ROOT") or (Path.home() / ".codex" / "loopx")) + runtime_root = Path(os.environ.get("LOOPX_RUNTIME_ROOT") or select_default_runtime_root()) return runtime_root / "connector-registry.json" diff --git a/loopx/capabilities/manager_context/ssh_evidence.py b/loopx/capabilities/manager_context/ssh_evidence.py index 3ad87ffe68..d3ec036089 100644 --- a/loopx/capabilities/manager_context/ssh_evidence.py +++ b/loopx/capabilities/manager_context/ssh_evidence.py @@ -207,7 +207,7 @@ def unavailable(code: str, reason: str) -> dict[str, Any]: if args.get("include_stopped"): argv += ["--include-stopped"] command = ( - 'exec "$HOME/.local/bin/loopx" --registry "$HOME/.codex/loopx/registry.global.json" --runtime-root "$HOME/.codex/loopx" --format json ' + 'exec "$HOME/.local/bin/loopx" --format json ' + shlex.join(argv) ) ssh = [ diff --git a/loopx/capabilities/public_safe_outbound/README.md b/loopx/capabilities/public_safe_outbound/README.md index bd5d6f21ae..84852d2f96 100644 --- a/loopx/capabilities/public_safe_outbound/README.md +++ b/loopx/capabilities/public_safe_outbound/README.md @@ -14,7 +14,7 @@ 本 capability 的源码不硬编码任何公司信息。公司内部标记(域名、内网链接、 私有路径前缀、凭据模式)维护在**本地 loopx 中心区域**(例如 -`~/.codex/loopx/public-safety/internal-markers.json`),运行时经 +`~/.loopx/public-safety/internal-markers.json`),运行时经 `PUBLIC_SAFE_CONFIG` 注入: ```json @@ -44,7 +44,7 @@ git diff --cached | python -m loopx.capabilities.public_safe_outbound.scan_cli \ 带公司门禁的完整用法: ```bash -PUBLIC_SAFE_CONFIG=~/.codex/loopx/public-safety/internal-markers.json \ +PUBLIC_SAFE_CONFIG=~/.loopx/public-safety/internal-markers.json \ python -m loopx.capabilities.public_safe_outbound.scan_cli \ --scan-root --format json ``` diff --git a/loopx/capabilities/repository_change_window/cli.py b/loopx/capabilities/repository_change_window/cli.py index e6d09453f5..2563f1ca6c 100644 --- a/loopx/capabilities/repository_change_window/cli.py +++ b/loopx/capabilities/repository_change_window/cli.py @@ -8,7 +8,7 @@ import sys from ...history import load_registry -from ...paths import DEFAULT_RUNTIME_ROOT, resolve_runtime_root +from ...paths import resolve_runtime_root, select_default_runtime_root from .git_hook import ( EnforcementLevel, git_hook_provider_status, @@ -214,7 +214,7 @@ def _runtime_root(registry_path: Path, runtime_root_arg: str | None) -> Path: return ( Path(runtime_root_arg).expanduser() if runtime_root_arg - else DEFAULT_RUNTIME_ROOT + else select_default_runtime_root() ) diff --git a/loopx/cli_commands/registry_admin_lifecycle.py b/loopx/cli_commands/registry_admin_lifecycle.py index 5acda02188..826488b11e 100644 --- a/loopx/cli_commands/registry_admin_lifecycle.py +++ b/loopx/cli_commands/registry_admin_lifecycle.py @@ -11,7 +11,18 @@ sync_project_registry_to_global, ) from ..history import load_registry -from ..paths import DEFAULT_RUNTIME_ROOT, global_registry_path, resolve_runtime_root +from ..local_state_migration import ( + migrate_local_state, + render_local_state_migration_markdown, + rollback_local_state_migration, +) +from ..paths import ( + DEFAULT_RUNTIME_ROOT, + LEGACY_RUNTIME_ROOT as LEGACY_LOCAL_RUNTIME_ROOT, + global_registry_path, + resolve_runtime_root, + select_default_runtime_root, +) from ..project_uninstall import render_project_uninstall_markdown, uninstall_project from ..runtime import archive_runtime_goal, render_archive_runtime_markdown from ..state_migration import ( @@ -35,12 +46,24 @@ "uninstall-project", "sync-global", "migrate-state", + "migrate-local-state", } def register_registry_lifecycle_commands( subparsers: argparse._SubParsersAction, ) -> None: + local_migration = subparsers.add_parser( + "migrate-local-state", + help="Preview or explicitly migrate default .codex LoopX state to .loopx; supports verified rollback.", + ) + local_migration.add_argument("--source-runtime-root", type=Path, help="Legacy runtime root; defaults to ~/.codex/loopx.") + local_migration.add_argument("--target-runtime-root", type=Path, help="New runtime root; defaults to ~/.loopx.") + local_migration.add_argument("--backup-dir", type=Path, help="Private backup directory; defaults to a content-bound directory beside the source.") + local_migration.add_argument("--expected-plan-id", help="Exact plan_id from a fresh dry-run preview; required with --execute.") + local_migration.add_argument("--rollback-receipt", type=Path, help="Preview or roll back a completed migration receipt.") + local_migration.add_argument("--execute", action="store_true", help="Apply the previewed migration or verified rollback.") + archive_runtime_parser = subparsers.add_parser( "archive-runtime", help="Move an obsolete runtime goal directory into the archive area. Defaults to dry-run.", @@ -93,7 +116,7 @@ def register_registry_lifecycle_commands( uninstall_project_parser.add_argument( "--archive-state", action="store_true", - help="Move each selected project-local .codex/goals/ state directory into .loopx/archived-project-state/.", + help="Archive each selected registered Goal state directory into .loopx/archived-project-state/.", ) uninstall_project_parser.add_argument( "--remove-empty-registry", @@ -134,7 +157,7 @@ def register_registry_lifecycle_commands( ) migrate_state_parser.add_argument( "--target-runtime-root", - help="LoopX runtime root. Defaults to --runtime-root or ~/.codex/loopx.", + help="LoopX runtime root. Defaults to --runtime-root or ~/.loopx.", ) migrate_goal_selector = migrate_state_parser.add_mutually_exclusive_group(required=True) migrate_goal_selector.add_argument( @@ -192,6 +215,37 @@ def handle_registry_lifecycle_command( if args.command not in REGISTRY_LIFECYCLE_COMMANDS: return None + if args.command == "migrate-local-state": + try: + if args.rollback_receipt and any(( + args.source_runtime_root, + args.target_runtime_root, + args.backup_dir, + args.expected_plan_id, + args.runtime_root, + )): + raise ValueError("--rollback-receipt cannot be combined with source, target, backup, or plan overrides") + if args.source_runtime_root and args.runtime_root: + raise ValueError("select one source root using --source-runtime-root or --runtime-root") + if args.rollback_receipt: + payload = rollback_local_state_migration( + args.rollback_receipt, execute=bool(args.execute) + ) + else: + payload = migrate_local_state( + source_runtime_root=args.source_runtime_root or ( + Path(args.runtime_root) if args.runtime_root else LEGACY_LOCAL_RUNTIME_ROOT + ), + target_runtime_root=args.target_runtime_root or DEFAULT_RUNTIME_ROOT, + backup_dir=args.backup_dir, + expected_plan_id=args.expected_plan_id, + execute=bool(args.execute), + ) + except Exception as exc: + payload = {"ok": False, "schema_version": "loopx_local_state_migration_v1", "dry_run": not bool(args.execute), "error": str(exc)} + print_payload(payload, args.format, render_local_state_migration_markdown) + return 0 if payload.get("ok") else 1 + if args.command == "archive-runtime": try: payload = archive_runtime_goal( @@ -294,7 +348,7 @@ def handle_registry_lifecycle_command( target_runtime_root = ( Path(args.target_runtime_root).expanduser() if args.target_runtime_root - else (Path(args.runtime_root).expanduser() if args.runtime_root else DEFAULT_RUNTIME_ROOT) + else (Path(args.runtime_root).expanduser() if args.runtime_root else select_default_runtime_root()) ) selected_goal_ids = ( legacy_registry_goal_ids(Path(args.legacy_registry)) @@ -344,7 +398,7 @@ def handle_registry_lifecycle_command( "legacy_registry": args.legacy_registry, "target_registry": str(registry_path), "legacy_runtime_root": args.legacy_runtime_root, - "target_runtime_root": args.target_runtime_root or args.runtime_root or str(DEFAULT_RUNTIME_ROOT), + "target_runtime_root": args.target_runtime_root or args.runtime_root or str(select_default_runtime_root()), "selected_goal_ids": args.goal_id or ([] if not getattr(args, "all_goals", False) else [""]), "error": str(exc), **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), diff --git a/loopx/cli_commands/support_control_registry.py b/loopx/cli_commands/support_control_registry.py index 13a9f2ac6f..69a77484e7 100644 --- a/loopx/cli_commands/support_control_registry.py +++ b/loopx/cli_commands/support_control_registry.py @@ -3,20 +3,20 @@ from pathlib import Path from ..history import load_registry -from ..paths import DEFAULT_RUNTIME_ROOT, global_registry_path +from ..paths import global_registry_path, select_default_runtime_root from ..registry import registry_goals, resolve_state_file def fallback_global_registry(registry_path: Path, runtime_root_arg: str | None) -> Path: if registry_path.exists(): return registry_path - runtime_root = Path(runtime_root_arg).expanduser() if runtime_root_arg else DEFAULT_RUNTIME_ROOT + runtime_root = Path(runtime_root_arg).expanduser() if runtime_root_arg else select_default_runtime_root() fallback_registry = global_registry_path(runtime_root) return fallback_registry if fallback_registry.exists() else registry_path def explicit_global_registry(runtime_root_arg: str | None) -> Path: - runtime_root = Path(runtime_root_arg).expanduser() if runtime_root_arg else DEFAULT_RUNTIME_ROOT + runtime_root = Path(runtime_root_arg).expanduser() if runtime_root_arg else select_default_runtime_root() return global_registry_path(runtime_root) diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index 9cd5ba29cf..b4720f1def 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -8,7 +8,7 @@ from pathlib import Path from . import __version__ -from .paths import DEFAULT_RUNTIME_ROOT, default_registry_path, global_registry_path +from .paths import default_registry_path, global_registry_path, select_default_runtime_root GLOBAL_OPTIONS_WITH_VALUE = frozenset({"--registry", "--runtime-root", "--format"}) @@ -53,6 +53,7 @@ "uninstall-project", "version", "host-mode-plan", + "migrate-local-state", } ) @@ -157,11 +158,14 @@ def resolve_cli_registry( and not registry_was_configured and not registry_path.exists() ): - runtime_root = ( - Path(args.runtime_root).expanduser() - if args.runtime_root - else DEFAULT_RUNTIME_ROOT - ) + try: + runtime_root = ( + Path(args.runtime_root).expanduser() + if args.runtime_root + else select_default_runtime_root() + ) + except ValueError as exc: + raise SystemExit(str(exc)) from exc fallback_registry = global_registry_path(runtime_root) if fallback_registry.exists(): registry_path = fallback_registry diff --git a/loopx/codex_cli_probe.py b/loopx/codex_cli_probe.py index bba5c6f34b..6197970e67 100644 --- a/loopx/codex_cli_probe.py +++ b/loopx/codex_cli_probe.py @@ -4,6 +4,7 @@ from typing import Any, Callable from .bootstrap import default_goal_id +from .paths import shell_selected_global_registry from .codex_cli_probe_markdown import ( render_codex_cli_bounded_visible_pilot_adapter_markdown as render_codex_cli_bounded_visible_pilot_adapter_markdown, render_codex_cli_local_driver_plan_markdown as render_codex_cli_local_driver_plan_markdown, @@ -391,7 +392,7 @@ def build_codex_cli_local_driver_plan( ) quota_guard_command = ( f"{_shell_arg(cli_bin)} --format json " - "--registry \"$HOME/.codex/loopx/registry.global.json\" " + f"--registry {shell_selected_global_registry()} " f"quota should-run --goal-id {_shell_arg(resolved_goal_id)}{agent_arg}" f"{CODEX_CLI_SCHEDULER_ARGS}" ) diff --git a/loopx/contract.py b/loopx/contract.py index 5436f6ad9e..e930d3613e 100644 --- a/loopx/contract.py +++ b/loopx/contract.py @@ -29,7 +29,7 @@ collect_history, load_registry, ) -from .paths import DEFAULT_RUNTIME_ROOT, rel_or_abs, resolve_runtime_root +from .paths import DEFAULT_RUNTIME_ROOT, LEGACY_RUNTIME_ROOT, rel_or_abs, resolve_runtime_root from .registry import inspect_registry, inspect_registry_boundary, registry_goals, resolve_state_file from .state_projection import state_projection_gap_warning from .control_plane.todos.contract import ( @@ -962,7 +962,7 @@ def add_global_error(code: str, message: str) -> None: runtime_root_override, registry_path=registry_path, ) - if runtime_root == DEFAULT_RUNTIME_ROOT or runtime_root.exists(): + if runtime_root in {DEFAULT_RUNTIME_ROOT, LEGACY_RUNTIME_ROOT} or runtime_root.exists(): checks.append(f"runtime root resolved: {runtime_root}") else: warnings.append(f"runtime root does not exist yet: {runtime_root}") diff --git a/loopx/control_plane/goals/ssh_lifecycle_transport.py b/loopx/control_plane/goals/ssh_lifecycle_transport.py index f0743c990c..52c2e090b0 100644 --- a/loopx/control_plane/goals/ssh_lifecycle_transport.py +++ b/loopx/control_plane/goals/ssh_lifecycle_transport.py @@ -11,7 +11,6 @@ REMOTE_GOAL_LIFECYCLE_SCHEMA_VERSION = "loopx_remote_goal_lifecycle_v1" -_REMOTE_REGISTRY = "$HOME/.codex/loopx/registry.global.json" _REMOTE_LOOPX_PREFIX = ( 'bin="$HOME/.local/bin/loopx"; ' '[ -x "$bin" ] || bin="$(command -v loopx || true)"; ' @@ -60,8 +59,6 @@ def apply_ssh_goal_lifecycle( '"$bin"', "--format", "json", - "--registry", - f'"{_REMOTE_REGISTRY}"', "goal-lifecycle", "--goal-id", shlex.quote(normalized_goal_id), diff --git a/loopx/control_plane/operator_provider.py b/loopx/control_plane/operator_provider.py index e3d41661f5..7f1d133b37 100644 --- a/loopx/control_plane/operator_provider.py +++ b/loopx/control_plane/operator_provider.py @@ -44,7 +44,7 @@ from pathlib import Path from typing import Any -from ..paths import DEFAULT_RUNTIME_ROOT +from ..paths import select_default_runtime_root from ..registry import atomic_write_json, read_json from .operator_credential import ( OPERATOR_CREDENTIAL_ENV_VARS, @@ -90,7 +90,7 @@ def machine_runtime_root(runtime_root: Path | None = None) -> Path: if runtime_root is not None: return Path(runtime_root).expanduser() - return DEFAULT_RUNTIME_ROOT + return select_default_runtime_root() def operator_provider_store_path(runtime_root: Path | None = None) -> Path: diff --git a/loopx/control_plane/projects/registry.py b/loopx/control_plane/projects/registry.py index e7edf1064d..4535070220 100644 --- a/loopx/control_plane/projects/registry.py +++ b/loopx/control_plane/projects/registry.py @@ -6,7 +6,12 @@ from ...bootstrap import build_goal_entry from ...control_plane.runtime.time import now_local_iso -from ...paths import resolve_runtime_root +from ...paths import ( + registered_goal_state_file, + require_single_goal_state_route, + resolve_runtime_root, + select_default_runtime_root, +) from ..todos.active_state_editing import atomic_write_state_text as _atomic_write_text from ..coordination.legacy_writer_fence import legacy_todo_write_transaction, require_legacy_state_replacement_allowed from ..goals.source_session_services import ( @@ -19,7 +24,6 @@ register_fresh_source_session_project, resolve_source_session_project, ) -from ...paths import DEFAULT_RUNTIME_ROOT from ...repository_identity import normalize_repository_identity from .contract import validate_project_record_bindings from .registration_state import ( @@ -171,7 +175,9 @@ def register_project_goal( knowledge_root = knowledge_root.expanduser().resolve() registry_path = registry_path.expanduser() - state_file = knowledge_root / ".codex" / "goals" / goal_id / "ACTIVE_GOAL_STATE.md" + existing_registry = load_project_registry(registry_path) if registry_path.exists() else None + state_file = registered_goal_state_file(knowledge_root, goal_id, existing_registry) + require_single_goal_state_route(knowledge_root, goal_id, state_file) updated_at = now_local_iso() project_record = { "project_id": project_id, @@ -226,7 +232,7 @@ def register_project_goal( return register_fresh_source_session_project( FreshSourceSessionRegistration( registry_path=registry_path, - runtime_root=(runtime_root or DEFAULT_RUNTIME_ROOT) + runtime_root=(runtime_root or select_default_runtime_root()) .expanduser() .resolve(), operation_id=operation_id, @@ -252,7 +258,7 @@ def register_project_goal( create=lambda: { "schema_version": "0.1", "registry_role": "project-local", - "common_runtime_root": str(runtime_root or DEFAULT_RUNTIME_ROOT), + "common_runtime_root": str(runtime_root or select_default_runtime_root()), }, ) as transaction: registry = transaction.payload_copy() diff --git a/loopx/control_plane/runtime/runtime_projection_route.py b/loopx/control_plane/runtime/runtime_projection_route.py index e1852f730d..ee003d5132 100644 --- a/loopx/control_plane/runtime/runtime_projection_route.py +++ b/loopx/control_plane/runtime/runtime_projection_route.py @@ -14,7 +14,7 @@ normalize_goal_activation_state, ) from ...history import load_index, load_registry -from ...paths import DEFAULT_RUNTIME_ROOT, global_registry_path, resolve_runtime_root +from ...paths import global_registry_path, resolve_runtime_root, select_default_runtime_root from ...registry import registry_goals @@ -240,7 +240,7 @@ def runtime_projection_candidate_roots( configured = str(os.environ.get("LOOPX_RUNTIME_ROOT") or "").strip() if configured: roots.append(Path(configured).expanduser()) - roots.append(DEFAULT_RUNTIME_ROOT) + roots.append(select_default_runtime_root()) else: roots.extend(Path(root).expanduser() for root in candidate_roots) roots.append(source_runtime_root.expanduser()) diff --git a/loopx/control_plane/status/ssh_tunnel.py b/loopx/control_plane/status/ssh_tunnel.py index f469fbc182..7dd4334d0b 100644 --- a/loopx/control_plane/status/ssh_tunnel.py +++ b/loopx/control_plane/status/ssh_tunnel.py @@ -26,11 +26,10 @@ # status server. Only the (already validated) SSH alias is interpolated as an # ssh argv; this script is never built from user text. _REMOTE_BOOTSTRAP = ( - "mkdir -p \"$HOME/.codex/loopx\" && " "bin=\"$HOME/.local/bin/loopx\"; " "[ -x \"$bin\" ] || bin=\"$(command -v loopx || true)\"; " "[ -n \"$bin\" ] || { echo 'loopx not found on remote' >&2; exit 1; }; " - "nohup \"$bin\" --registry \"$HOME/.codex/loopx/registry.global.json\" " + "nohup \"$bin\" " "serve-status --global-registry --host 127.0.0.1 --port 8766 --limit 80 " ">/tmp/loopx-serve-status.log 2>&1 &" ) diff --git a/loopx/control_plane/testing/actual_default_model_behavior_portfolio.py b/loopx/control_plane/testing/actual_default_model_behavior_portfolio.py index b3cb91cbae..b404f88a43 100644 --- a/loopx/control_plane/testing/actual_default_model_behavior_portfolio.py +++ b/loopx/control_plane/testing/actual_default_model_behavior_portfolio.py @@ -478,7 +478,7 @@ def _entry_scenario_packets(root: Path) -> dict[str, dict[str, Any]]: ) second_goal = "portfolio-second-goal" - second_state = project / ".codex" / "goals" / second_goal / "ACTIVE_GOAL_STATE.md" + second_state = project / ".loopx" / "goals" / second_goal / "ACTIVE_GOAL_STATE.md" second_state.parent.mkdir(parents=True) second_state.write_text("# Second Active Goal State\n", encoding="utf-8") registry["goals"].append( diff --git a/loopx/control_plane/testing/canary_harness.py b/loopx/control_plane/testing/canary_harness.py index fc7e97a1a4..c280b13f09 100644 --- a/loopx/control_plane/testing/canary_harness.py +++ b/loopx/control_plane/testing/canary_harness.py @@ -11,6 +11,8 @@ def default_state_file(goal_id: str) -> str: + # Legacy route is intentional: existing characterization fixtures create + # state there and declare it explicitly in their synthetic registries. return f".codex/goals/{goal_id}/ACTIVE_GOAL_STATE.md" diff --git a/loopx/control_plane/testing/selected_todo_tool_behavior.py b/loopx/control_plane/testing/selected_todo_tool_behavior.py index 0545425f39..feec494585 100644 --- a/loopx/control_plane/testing/selected_todo_tool_behavior.py +++ b/loopx/control_plane/testing/selected_todo_tool_behavior.py @@ -369,6 +369,9 @@ def _resolve_metadata_path( ( marker for marker in ( + "~/.loopx", + "$HOME/.loopx", + "${HOME}/.loopx", "~/.codex/loopx", "$HOME/.codex/loopx", "${HOME}/.codex/loopx", diff --git a/loopx/demo.py b/loopx/demo.py index e4b46e883b..9af7dcee64 100644 --- a/loopx/demo.py +++ b/loopx/demo.py @@ -7,7 +7,7 @@ DEFAULT_DOMAIN, bootstrap_project, ) -from .paths import DEFAULT_RUNTIME_ROOT +from .paths import select_default_runtime_root from .quota import build_quota_should_run from .state_refresh import refresh_state_run from .status import collect_status @@ -41,7 +41,7 @@ def run_demo( agent_todo: str, ) -> dict[str, Any]: project = project.expanduser().resolve() - runtime_root = runtime_root.expanduser().resolve() if runtime_root else DEFAULT_RUNTIME_ROOT + runtime_root = runtime_root.expanduser().resolve() if runtime_root else select_default_runtime_root() registry_path = project / ".loopx" / "registry.json" goal_doc = project / "GOAL.md" project.mkdir(parents=True, exist_ok=True) diff --git a/loopx/doctor.py b/loopx/doctor.py index df4fc19e2d..0140ff9c8b 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -18,7 +18,7 @@ ) from .control_plane.runtime.time import chronology_key from .install_contract import NO_CLONE_INSTALL_URL -from .paths import DEFAULT_RUNTIME_ROOT, global_registry_path +from .paths import default_runtime_route, global_registry_path from .python_install_owner import PythonInstallOwner, python_distribution_upgrade_command, resolve_python_install_owner from .capabilities.project_skill_delivery import discover_project_scoped_skill_ids from .registry_writability import probe_registry_write_path @@ -703,6 +703,9 @@ def collect_doctor( ) from .control_plane.effect_runtime import collect_effect_runtime_readiness + local_state_route = default_runtime_route() + selected_runtime_root = Path(str(local_state_route["selected_runtime_root"])) + from .host_loop_activation import ( agent_type_uses_host_managed_skills, normalize_agent_type, @@ -809,7 +812,7 @@ def collect_doctor( ) default_release["promotion_mode"] = release_manifest_source.get("promotion_mode") release_provenance = { - "runtime_root": str(DEFAULT_RUNTIME_ROOT), + "runtime_root": str(selected_runtime_root), "default_release": default_release, "live_canary": { **command_root_summary(canary_path, canary_realpath), @@ -827,7 +830,7 @@ def collect_doctor( ), }, "promotion_readiness": add_promotion_readiness_freshness( - latest_promotion_readiness_event(DEFAULT_RUNTIME_ROOT) + latest_promotion_readiness_event(selected_runtime_root) ), } install_freshness = build_install_freshness( @@ -888,18 +891,22 @@ def collect_doctor( else {} ), } - default_global_registry = global_registry_path(DEFAULT_RUNTIME_ROOT) - global_registry_writability = probe_registry_write_path(default_global_registry, create_parent=True) + default_global_registry = global_registry_path(selected_runtime_root) + global_registry_writability = ( + probe_registry_write_path(default_global_registry, create_parent=True) + if local_state_route["status"] not in {"conflict", "invalid"} + else {"ok": False, "error": local_state_route["recommended_action"]} + ) runtime_projection_routes = ( collect_runtime_projection_route_diagnostics( registry_path=default_global_registry, - runtime_root=DEFAULT_RUNTIME_ROOT, + runtime_root=selected_runtime_root, ) if default_global_registry.exists() else { "schema_version": "runtime_projection_route_diagnostics_v0", "registry": str(default_global_registry.resolve()), - "runtime_root": str(DEFAULT_RUNTIME_ROOT.resolve()), + "runtime_root": str(selected_runtime_root.resolve()), "goal_filter": None, "activation_state_filter": None, "available": False, @@ -1037,6 +1044,12 @@ def collect_doctor( ), }, *skill_install_doctor_checks(host_skill_install_readback), + { + "id": "local_state_route_unambiguous", + "required": True, + "ok": local_state_route["status"] not in {"conflict", "invalid"}, + "detail": str(local_state_route["recommended_action"] or local_state_route["status"]), + }, { "id": "global_registry_writable", "required": True, @@ -1112,6 +1125,7 @@ def collect_doctor( "release_manifest": release_manifest, "desktop_installation": desktop_installation, "release_provenance": release_provenance, + "local_state_route": local_state_route, "global_registry_writability": global_registry_writability, "runtime_projection_routes": runtime_projection_routes, "typescript_control_plane": typescript_control_plane, @@ -1197,6 +1211,9 @@ def render_doctor_markdown(payload: dict[str, Any]) -> str: f"- skill_delivery_mode: `{(payload.get('skill_delivery') or {}).get('mode')}`", f"- skill_delivery_status: `{(payload.get('skill_delivery') or {}).get('status')}`", f"- global_registry_writable: `{(payload.get('global_registry_writability') or {}).get('ok')}`", + f"- local_state_route: `{(payload.get('local_state_route') or {}).get('status')}`" + f" (selected=`{(payload.get('local_state_route') or {}).get('selected_runtime_root')}`;" + f" target=`{(payload.get('local_state_route') or {}).get('target_runtime_root')}`)", f"- runtime_projection_routes_healthy: `{(payload.get('runtime_projection_routes') or {}).get('healthy')}`" f" (registry=`{(payload.get('runtime_projection_routes') or {}).get('registry')}`," f" goals=`{(payload.get('runtime_projection_routes') or {}).get('goal_count')}`," diff --git a/loopx/extensions/runtime.py b/loopx/extensions/runtime.py index b5d2ac405e..9e4b9d6563 100644 --- a/loopx/extensions/runtime.py +++ b/loopx/extensions/runtime.py @@ -12,6 +12,7 @@ from typing import Any from ..file_lock import exclusive_file_lock +from ..paths import select_default_runtime_root from .manifest import load_extension_manifest from .process_runtime import run_capped_process from .runtime_location import located_runtime, probe_runtime_location, record_runtime_location @@ -70,7 +71,7 @@ def default_extension_state_file(runtime_root: str | Path | None = None) -> Path root = ( Path(runtime_root).expanduser() if runtime_root is not None - else Path.home() / ".codex" / "loopx" + else select_default_runtime_root() ) return root / "extensions" / "state.json" diff --git a/loopx/global_registry.py b/loopx/global_registry.py index d2dde65a0b..56565458e0 100644 --- a/loopx/global_registry.py +++ b/loopx/global_registry.py @@ -13,7 +13,7 @@ from .control_plane.projects.registry_codec import load_registry from .control_plane.runtime.time import now_local_iso from .file_lock import exclusive_file_lock -from .paths import DEFAULT_RUNTIME_ROOT, global_registry_path, resolve_runtime_root +from .paths import global_registry_path, resolve_runtime_root, select_default_runtime_root from .registry import read_json, registry_goals from .registry_writability import is_write_denied_error, probe_registry_write_path @@ -383,7 +383,7 @@ def _merge_global_registry_payload( payload.get("schema_version") or schema_version_fallback or "0.1" ) payload["updated_at"] = synced_at - payload["common_runtime_root"] = str(runtime_root or DEFAULT_RUNTIME_ROOT) + payload["common_runtime_root"] = str(runtime_root or select_default_runtime_root()) payload["registry_role"] = "global-local" if merged_projects: payload["projects"] = merged_projects @@ -538,7 +538,7 @@ def retire_global_registry_goals( runtime_root = ( Path(runtime_root_override).expanduser() if runtime_root_override - else DEFAULT_RUNTIME_ROOT + else select_default_runtime_root() ) global_path = global_registry_path(runtime_root) if not global_path.exists(): diff --git a/loopx/local_state_migration.py b/loopx/local_state_migration.py new file mode 100644 index 0000000000..ebaa3f67f1 --- /dev/null +++ b/loopx/local_state_migration.py @@ -0,0 +1,581 @@ +"""Offline, explicit migration of LoopX-owned default state paths. + +The preview is read-only. Execution requires its content-bound plan id and +keeps a verified private backup. Running hosts must be stopped by the operator: +older LoopX versions do not participate in a migration-wide writer fence. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import stat +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .control_plane.projects.registry_codec import load_registry, project_registry_transaction +from .paths import ( + DEFAULT_PROJECT_GOALS, + DEFAULT_RUNTIME_ROOT, + LEGACY_PROJECT_GOALS, + LEGACY_RUNTIME_ROOT, + GLOBAL_REGISTRY_FILENAME, +) +from .runtime import validate_goal_id_path_segment + + +LOCAL_STATE_MIGRATION_SCHEMA = "loopx_local_state_migration_v1" +RECEIPT_NAME = "migration-receipt.json" + + +def _absolute(path: Path) -> Path: + # Collapse lexical `..` without following a symlink before boundary checks. + return Path(os.path.abspath(path.expanduser())) + + +def _within(path: Path, parent: Path) -> bool: + return path == parent or parent in path.parents + + +def _is_redirected_path(path: Path) -> bool: + is_junction = getattr(path, "is_junction", lambda: False)() + # Python 3.11 lacks Path.is_junction; Windows exposes reparse-point + # attributes through lstat, so reject those as well. + reparse_point = False + if os.name == "nt": + try: + attributes = getattr(path.lstat(), "st_file_attributes", 0) + except FileNotFoundError: + attributes = 0 + reparse_point = bool( + attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + return path.is_symlink() or is_junction or reparse_point + + +def _require_unlinked_directory_chain(path: Path, *, label: str) -> None: + """Reject any existing directory ancestor that redirects an I/O route.""" + + for ancestor in (path, *path.parents): + if _is_redirected_path(ancestor): + raise ValueError(f"{label} has a symlink or junction ancestor: {ancestor}") + if ancestor.exists() and not ancestor.is_dir(): + raise ValueError(f"{label} ancestor is not a directory: {ancestor}") + + +def _require_backup_path(path: Path, *, must_be_absent: bool = False) -> None: + """Keep private backup writes on the declared, unlinked directory route.""" + + _require_unlinked_directory_chain(path.parent, label="backup path") + if _is_redirected_path(path): + raise ValueError(f"backup path has a symlink or junction ancestor: {path}") + if path.exists(): + if must_be_absent: + raise FileExistsError(f"backup directory already exists: {path}") + if not path.is_dir(): + raise ValueError(f"backup path is not a directory: {path}") + + +def _require_project_registry_path(path: Path) -> None: + """Keep project registry reads and writes inside their declared project.""" + + _require_unlinked_directory_chain(path.parent, label="project registry path") + if _is_redirected_path(path): + raise ValueError(f"project registry path has a symlink or junction leaf: {path}") + if not path.is_file(): + raise FileNotFoundError( + f"registered project registry is missing: {path}; " + "restore the project route or retire its global Goal before migration" + ) + + +def _read_registry(path: Path) -> dict[str, Any]: + payload = load_registry(path) + if not isinstance(payload, dict) or not isinstance(payload.get("goals"), list): + raise ValueError(f"registry must contain a goals list: {path}") + return payload + + +def _write_registry(path: Path, payload: dict[str, Any]) -> None: + temporary = path.with_name(f".{path.name}.{os.getpid()}.migration.tmp") + try: + temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def _write_project_registry(path: Path, payload: dict[str, Any]) -> None: + with project_registry_transaction( + path, operation="migrate_local_state_project_registry", + ) as transaction: + transaction.commit(payload) + + +def _digest(path: Path) -> str: + """Hash source bytes without traversing redirected files or directories.""" + + digest = hashlib.sha256() + if _is_redirected_path(path): + raise ValueError(f"migration source contains a symlink or junction: {path}") + if path.is_file(): + digest.update(b"file\0") + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + elif path.is_dir(): + digest.update(b"dir\0") + for child in sorted(path.iterdir(), key=lambda item: item.name): + digest.update(child.name.encode("utf-8") + b"\0") + digest.update(_digest(child).encode("ascii")) + else: + raise FileNotFoundError(path) + return digest.hexdigest() + + +def _declared_state_file(project: Path, goal: dict[str, Any]) -> Path: + goal_id = validate_goal_id_path_segment(str(goal.get("id") or "")) + state_text = goal.get("state_file") + if not isinstance(state_text, str) or not state_text: + raise ValueError(f"goal {goal_id} has no state_file") + declared = Path(state_text).expanduser() + return _absolute(declared if declared.is_absolute() else project / declared) + + +def _state_route(project: Path, goal: dict[str, Any]) -> tuple[Path, Path] | None: + goal_id = validate_goal_id_path_segment(str(goal.get("id") or "")) + resolved = _declared_state_file(project, goal) + source = project / LEGACY_PROJECT_GOALS / goal_id / "ACTIVE_GOAL_STATE.md" + target = project / DEFAULT_PROJECT_GOALS / goal_id / "ACTIVE_GOAL_STATE.md" + if resolved == source: + return source, target + if _within(resolved, project / LEGACY_PROJECT_GOALS): + raise ValueError(f"noncanonical legacy Goal state path requires manual review: {resolved}") + return None # Explicit custom state_file remains where its owner placed it. + + +def _require_goal_destination(project: Path, target_dir: Path) -> None: + """Reject a target whose existing ancestors can redirect a Goal rename.""" + + if project not in target_dir.parents: + raise ValueError(f"Goal migration target escapes its project: {target_dir}") + if _is_redirected_path(target_dir): + raise ValueError(f"Goal migration target is a symlink or junction: {target_dir}") + if target_dir.exists(): + raise ValueError(f"Goal migration target exists: {target_dir}") + _require_unlinked_directory_chain(target_dir.parent, label="Goal migration target") + + +def _require_goal_source(project: Path, source_dir: Path) -> None: + """Keep legacy Goal reads and renames on the declared physical route.""" + + if source_dir.parent != project / LEGACY_PROJECT_GOALS: + raise ValueError(f"legacy Goal source escapes its project: {source_dir}") + _require_unlinked_directory_chain(source_dir, label="legacy Goal source") + state_file = source_dir / "ACTIVE_GOAL_STATE.md" + if _is_redirected_path(state_file) or not state_file.is_file(): + raise ValueError(f"legacy state file is missing or linked: {state_file}") + + +def _require_unlinked_move_routes(source: Path, destination: Path, *, label: str) -> None: + """Reject redirected leaves or ancestors before a state-directory rename.""" + + _require_unlinked_directory_chain(source.parent, label=f"{label} source") + _require_unlinked_directory_chain(destination.parent, label=f"{label} destination") + if _is_redirected_path(source) or _is_redirected_path(destination): + raise ValueError(f"{label} has a symlink or junction leaf") + + +def _rewrite_registry( + registry: dict[str, Any], *, project: Path | None, source_root: Path, target_root: Path +) -> dict[str, Any]: + updated = json.loads(json.dumps(registry)) + declared_root = updated.get("common_runtime_root") + if declared_root and _absolute(Path(str(declared_root))) != source_root: + raise ValueError(f"registry declares another runtime root: {declared_root}") + updated["common_runtime_root"] = str(target_root) + for goal in updated["goals"]: + if not isinstance(goal, dict): + raise ValueError("registry goals must be objects") + repo_text = goal.get("repo") + goal_project = project or (_absolute(Path(str(repo_text))) if repo_text else None) + if goal_project is None: + raise ValueError(f"global goal {goal.get('id')} has no project route") + route = _state_route(goal_project, goal) + if route is None: + continue + source, target = route + declared = Path(str(goal["state_file"])).expanduser() + goal["state_file"] = ( + str(target) if declared.is_absolute() else str(DEFAULT_PROJECT_GOALS / target.parent.name / target.name) + ) + return updated + + +def plan_local_state_migration( + *, + source_runtime_root: Path = LEGACY_RUNTIME_ROOT, + target_runtime_root: Path = DEFAULT_RUNTIME_ROOT, + backup_dir: Path | None = None, +) -> dict[str, Any]: + source = _absolute(source_runtime_root) + target = _absolute(target_runtime_root) + requested_backup = _absolute(backup_dir) if backup_dir is not None else None + if source == target or _within(target, source) or _within(source, target): + raise ValueError("source and target runtime roots must be separate") + _require_unlinked_directory_chain(source, label="legacy runtime root") + _require_unlinked_directory_chain(target.parent, label="target runtime root") + if not source.is_dir(): + raise ValueError(f"legacy runtime root must be a real directory: {source}") + if target.exists() or _is_redirected_path(target): + raise FileExistsError(f"target runtime root already exists: {target}") + global_path = source / GLOBAL_REGISTRY_FILENAME + if _is_redirected_path(global_path): + raise ValueError(f"legacy global registry is a symlink or junction: {global_path}") + global_registry = _read_registry(global_path) + _rewrite_registry(global_registry, project=None, source_root=source, target_root=target) + + project_registries: dict[Path, dict[str, Any]] = {} + for goal in global_registry["goals"]: + if not isinstance(goal, dict) or not goal.get("repo"): + raise ValueError("each global goal needs a project repo for migration") + project = _absolute(Path(str(goal["repo"]))) + registry_text = goal.get("source_registry") + registry = _absolute(Path(str(registry_text))) if registry_text else project / ".loopx" / "registry.json" + if registry != project / ".loopx" / "registry.json": + raise ValueError(f"noncanonical project registry requires manual review: {registry}") + if registry not in project_registries: + _require_project_registry_path(registry) + project_registries[registry] = _read_registry(registry) + + moves: dict[Path, Path] = {} + for registry_path, registry in project_registries.items(): + project = registry_path.parent.parent + _rewrite_registry(registry, project=project, source_root=source, target_root=target) + for goal in registry["goals"]: + route = _state_route(project, goal) + if route is None: + continue + state_source, state_target = route + source_dir = state_source.parent + target_dir = state_target.parent + _require_goal_source(project, source_dir) + _require_goal_destination(project, target_dir) + moves[source_dir] = target_dir + + # A global-only Goal still needs a matching project-local registration. + for goal in global_registry["goals"]: + project = _absolute(Path(str(goal["repo"]))) + registry = project_registries[project / ".loopx" / "registry.json"] + local = next((item for item in registry["goals"] if isinstance(item, dict) and item.get("id") == goal.get("id")), None) + if local is None or _declared_state_file(project, local) != _declared_state_file(project, goal): + raise ValueError(f"global/local Goal route disagrees: {goal.get('id')}") + + items = [("runtime", source, target)] + [ + ("registry", path, path) for path in sorted(project_registries) + ] + [("goal", old, new) for old, new in sorted(moves.items())] + entries = [ + {"kind": kind, "source": str(old), "target": str(new), "digest": _digest(old)} + for kind, old, new in items + ] + binding = {"source": str(source), "target": str(target), "entries": entries} + plan_id = hashlib.sha256(json.dumps(binding, sort_keys=True).encode()).hexdigest() + backup = requested_backup or source.parent / "loopx-local-state-backups" / plan_id[:16] + if any(_within(backup, root) for root in (source, target, *moves, *moves.values())): + raise ValueError("backup must be outside runtime and Goal state directories") + _require_backup_path(backup, must_be_absent=True) + return { + "ok": True, + "schema_version": LOCAL_STATE_MIGRATION_SCHEMA, + "dry_run": True, + "plan_id": plan_id, + "source_runtime_root": str(source), + "target_runtime_root": str(target), + "backup_dir": str(backup), + "project_count": len(project_registries), + "goal_directory_count": len(moves), + "entries": entries, + "recommended_action": "Stop LoopX workers, inspect this preview, then run with --execute --expected-plan-id .", + } + + +def _copy(source: Path, target: Path) -> None: + _require_unlinked_directory_chain(source.parent, label="migration source") + if _is_redirected_path(source): + raise ValueError(f"migration source is a symlink or junction: {source}") + _require_backup_path(target.parent) + target.parent.mkdir(parents=True, exist_ok=True) + _require_backup_path(target.parent) + if target.exists() or _is_redirected_path(target): + raise FileExistsError(f"backup snapshot target already exists: {target}") + if source.is_dir(): + shutil.copytree(source, target, symlinks=True) + else: + shutil.copy2(source, target) + + +def migrate_local_state( + *, + source_runtime_root: Path = LEGACY_RUNTIME_ROOT, + target_runtime_root: Path = DEFAULT_RUNTIME_ROOT, + backup_dir: Path | None = None, + expected_plan_id: str | None = None, + execute: bool = False, +) -> dict[str, Any]: + plan = plan_local_state_migration( + source_runtime_root=source_runtime_root, + target_runtime_root=target_runtime_root, + backup_dir=backup_dir, + ) + if not execute: + return plan + if not expected_plan_id or expected_plan_id != plan["plan_id"]: + raise ValueError("migration preview changed; rerun preview and supply its exact --expected-plan-id") + + source = Path(plan["source_runtime_root"]) + target = Path(plan["target_runtime_root"]) + backup = Path(plan["backup_dir"]) + entries = plan["entries"] + _require_backup_path(backup, must_be_absent=True) + backup.mkdir(mode=0o700, parents=True, exist_ok=False) + _require_backup_path(backup) + try: + for index, entry in enumerate(entries): + _require_backup_path(backup) + original = Path(entry["source"]) + if entry["kind"] == "registry": + _require_project_registry_path(original) + elif entry["kind"] == "goal": + _require_goal_source(original.parent.parent.parent, original) + if _digest(original) != entry["digest"]: + raise ValueError(f"source changed before backup: {original}") + copied = backup / "snapshot" / str(index) + _copy(original, copied) + if _digest(copied) != entry["digest"]: + raise ValueError(f"backup verification failed for {original}") + _require_backup_path(backup) + (backup / "plan.json").write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + except Exception: + # Keep any partial backup for inspection. No authoritative state moved. + raise + + moved: list[tuple[Path, Path]] = [] + modified: list[Path] = [] + expected_project_registries: dict[Path, dict[str, Any]] = {} + global_modified = False + expected_global_registry: dict[str, Any] | None = None + try: + for entry in entries: + original = Path(entry["source"]) + if entry["kind"] == "registry": + _require_project_registry_path(original) + elif entry["kind"] == "goal": + _require_goal_source(original.parent.parent.parent, original) + if _digest(original) != entry["digest"]: + raise ValueError(f"source changed during backup: {entry['source']}") + for entry in entries: + if entry["kind"] != "goal": + continue + old, new = Path(entry["source"]), Path(entry["target"]) + project = new.parent.parent.parent + _require_goal_source(project, old) + _require_goal_destination(project, new) + new.parent.mkdir(parents=True, exist_ok=True) + _require_goal_destination(project, new) + _require_goal_source(project, old) + old.rename(new) + moved.append((old, new)) + for entry in entries: + if entry["kind"] != "registry": + continue + registry_path = Path(entry["source"]) + _require_project_registry_path(registry_path) + registry = _read_registry(registry_path) + updated = _rewrite_registry( + registry, + project=registry_path.parent.parent, + source_root=source, + target_root=target, + ) + _require_project_registry_path(registry_path) + _write_project_registry(registry_path, updated) + modified.append(registry_path) + expected_project_registries[registry_path] = updated + _require_unlinked_move_routes(source, target, label="runtime migration") + if target.exists(): + raise FileExistsError(f"target runtime root reappeared: {target}") + source.rename(target) + moved.append((source, target)) + global_path = target / GLOBAL_REGISTRY_FILENAME + _require_unlinked_directory_chain(target, label="migrated runtime root") + if _is_redirected_path(global_path): + raise ValueError(f"migrated global registry is a symlink or junction: {global_path}") + updated_global = _rewrite_registry( + _read_registry(global_path), project=None, source_root=source, target_root=target + ) + _write_registry(global_path, updated_global) + global_modified = True + expected_global_registry = updated_global + if _read_registry(global_path) != updated_global: + raise ValueError("global registry changed during migration") + for registry_path, expected in expected_project_registries.items(): + _require_project_registry_path(registry_path) + if _read_registry(registry_path) != expected: + raise ValueError(f"project registry changed during migration: {registry_path}") + for entry in entries: + if entry["kind"] == "goal": + goal_target = Path(entry["target"]) + _require_unlinked_directory_chain(goal_target.parent, label="migrated Goal state") + if _digest(goal_target) != entry["digest"]: + raise ValueError(f"Goal state changed during migration: {entry['target']}") + if entry["kind"] != "registry": + legacy = Path(entry["source"]) + _require_unlinked_directory_chain(legacy.parent, label="legacy authority") + if legacy.exists() or _is_redirected_path(legacy): + raise ValueError(f"legacy authority reappeared during migration: {legacy}") + before_runtime = backup / "snapshot" / "0" + before_children = {child.name for child in before_runtime.iterdir()} + after_children = {child.name for child in target.iterdir()} + if before_children != after_children: + raise ValueError("runtime files changed during migration") + for name in before_children - {GLOBAL_REGISTRY_FILENAME}: + if _digest(before_runtime / name) != _digest(target / name): + raise ValueError(f"runtime state changed during migration: {name}") + after = [ + {**entry, "after_digest": _digest(Path(entry["target"]))} + for entry in entries + ] + receipt = { + **plan, + "dry_run": False, + "status": "migrated", + "completed_at": datetime.now(timezone.utc).isoformat(), + "entries": after, + } + _require_backup_path(backup) + _write_registry(backup / RECEIPT_NAME, receipt) + return receipt + except Exception as exc: + rollback_errors: list[str] = [] + for old, new in reversed(moved): + try: + _require_unlinked_move_routes(new, old, label="automatic rollback") + if new.exists() and not old.exists(): + new.rename(old) + elif new.exists() and old.exists(): + rollback_errors.append(f"both routes exist: {old} and {new}") + except (OSError, ValueError) as rollback_exc: + rollback_errors.append(str(rollback_exc)) + for index, entry in enumerate(entries): + if entry["kind"] == "registry" and Path(entry["source"]) in modified: + try: + registry_path = Path(entry["source"]) + _require_project_registry_path(registry_path) + if _read_registry(registry_path) != expected_project_registries[registry_path]: + rollback_errors.append(f"project registry changed; kept for manual recovery: {registry_path}") + continue + _require_project_registry_path(registry_path) + _require_unlinked_directory_chain(backup / "snapshot", label="backup snapshot") + shutil.copy2(backup / "snapshot" / str(index), registry_path) + except (OSError, ValueError) as rollback_exc: + rollback_errors.append(str(rollback_exc)) + if global_modified: + try: + source_registry = source / GLOBAL_REGISTRY_FILENAME + _require_unlinked_directory_chain(source, label="restored runtime root") + if _is_redirected_path(source_registry): + raise ValueError(f"restored global registry is a symlink or junction: {source_registry}") + if _read_registry(source_registry) != expected_global_registry: + rollback_errors.append(f"global registry changed; kept for manual recovery: {source_registry}") + else: + _require_unlinked_directory_chain(backup / "snapshot", label="backup snapshot") + shutil.copy2(backup / "snapshot" / "0" / GLOBAL_REGISTRY_FILENAME, source_registry) + except (OSError, ValueError) as rollback_exc: + rollback_errors.append(str(rollback_exc)) + if rollback_errors: + raise RuntimeError( + f"migration failed: {exc}; automatic rollback incomplete; backup={backup}; " + + "; ".join(rollback_errors) + ) from exc + raise RuntimeError(f"migration failed and original routes were restored; backup={backup}: {exc}") from exc + + +def rollback_local_state_migration(receipt_path: Path, *, execute: bool = False) -> dict[str, Any]: + receipt_path = _absolute(receipt_path) + _require_backup_path(receipt_path.parent) + if _is_redirected_path(receipt_path): + raise ValueError(f"backup receipt is a symlink or junction: {receipt_path}") + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + if not isinstance(receipt, dict) or receipt.get("schema_version") != LOCAL_STATE_MIGRATION_SCHEMA or receipt.get("status") != "migrated": + raise ValueError("receipt does not describe a completed local state migration") + entries = receipt.get("entries") + if not isinstance(entries, list): + raise ValueError("migration receipt has no entries") + backup = receipt_path.parent + _require_unlinked_directory_chain(backup / "snapshot", label="backup snapshot") + for index, entry in enumerate(entries): + old, new = Path(entry["source"]), Path(entry["target"]) + if entry["kind"] == "registry": + _require_project_registry_path(new) + else: + _require_unlinked_move_routes(new, old, label="migration rollback") + if old.exists(): + raise FileExistsError(f"legacy path has reappeared: {old}") + if _digest(new) != entry["after_digest"]: + raise ValueError(f"migrated state changed; automatic rollback is unsafe: {new}") + if _digest(backup / "snapshot" / str(index)) != entry["digest"]: + raise ValueError(f"migration backup changed: {backup / 'snapshot' / str(index)}") + result = {"ok": True, "schema_version": LOCAL_STATE_MIGRATION_SCHEMA, "dry_run": not execute, "status": "rollback_ready", "receipt": str(receipt_path)} + if not execute: + return result + for entry in reversed(entries): + if entry["kind"] == "runtime": + old, new = Path(entry["source"]), Path(entry["target"]) + _require_unlinked_move_routes(new, old, label="migration rollback") + if old.exists(): + raise FileExistsError(f"legacy path has reappeared: {old}") + new.rename(old) + for entry in reversed(entries): + if entry["kind"] == "goal": + old, new = Path(entry["source"]), Path(entry["target"]) + _require_unlinked_move_routes(new, old, label="migration rollback") + if old.exists(): + raise FileExistsError(f"legacy path has reappeared: {old}") + new.rename(old) + for index, entry in enumerate(entries): + if entry["kind"] == "registry": + _require_project_registry_path(Path(entry["source"])) + _require_unlinked_directory_chain(backup / "snapshot", label="backup snapshot") + shutil.copy2(backup / "snapshot" / str(index), entry["source"]) + source_registry = Path(receipt["source_runtime_root"]) / GLOBAL_REGISTRY_FILENAME + _require_unlinked_directory_chain(source_registry.parent, label="restored runtime root") + if _is_redirected_path(source_registry): + raise ValueError(f"restored global registry is a symlink or junction: {source_registry}") + _require_unlinked_directory_chain(backup / "snapshot", label="backup snapshot") + shutil.copy2(backup / "snapshot" / "0" / GLOBAL_REGISTRY_FILENAME, source_registry) + result["dry_run"] = False + result["status"] = "rolled_back" + _require_backup_path(backup) + _write_registry(receipt_path, {**receipt, "status": "rolled_back"}) + return result + + +def render_local_state_migration_markdown(payload: dict[str, Any]) -> str: + lines = [ + "# LoopX Local State Migration", + f"- status: `{payload.get('status') or ('preview' if payload.get('ok') else 'failed')}`", + f"- dry_run: `{payload.get('dry_run')}`", + f"- plan_id: `{payload.get('plan_id')}`", + f"- source: `{payload.get('source_runtime_root')}`", + f"- target: `{payload.get('target_runtime_root')}`", + f"- backup: `{payload.get('backup_dir')}`", + f"- projects: `{payload.get('project_count')}`; Goal directories: `{payload.get('goal_directory_count')}`", + ] + if payload.get("error"): + lines.append(f"- error: {payload['error']}") + if payload.get("recommended_action"): + lines.append(f"- next: {payload['recommended_action']}") + return "\n".join(lines) + "\n" diff --git a/loopx/paths.py b/loopx/paths.py index cb29245e4c..d24dcb0253 100644 --- a/loopx/paths.py +++ b/loopx/paths.py @@ -4,9 +4,60 @@ from pathlib import Path -DEFAULT_RUNTIME_ROOT = Path.home() / ".codex" / "loopx" +DEFAULT_RUNTIME_ROOT = Path.home() / ".loopx" +LEGACY_RUNTIME_ROOT = Path.home() / ".codex" / "loopx" DEFAULT_PROJECT_REGISTRY = Path(".loopx") / "registry.json" +DEFAULT_PROJECT_GOALS = Path(".loopx") / "goals" +LEGACY_PROJECT_GOALS = Path(".codex") / "goals" GLOBAL_REGISTRY_FILENAME = "registry.global.json" +SHELL_DEFAULT_GLOBAL_REGISTRY = '"$HOME/.loopx/registry.global.json"' +SHELL_LEGACY_GLOBAL_REGISTRY = '"$HOME/.codex/loopx/registry.global.json"' + + +def default_goal_state_file(project: Path, goal_id: str) -> Path: + return project / DEFAULT_PROJECT_GOALS / goal_id / "ACTIVE_GOAL_STATE.md" + + +def legacy_goal_state_file(project: Path, goal_id: str) -> Path: + return project / LEGACY_PROJECT_GOALS / goal_id / "ACTIVE_GOAL_STATE.md" + + +def registered_goal_state_file( + project: Path, goal_id: str, registry: dict[str, object] | None = None +) -> Path: + """Keep an existing registration on its declared path until migration.""" + + if isinstance(registry, dict): + goals = registry.get("goals") + if isinstance(goals, list): + for goal in goals: + if not isinstance(goal, dict) or goal.get("id") != goal_id: + continue + value = goal.get("state_file") + if isinstance(value, str) and value: + path = Path(value).expanduser() + return path if path.is_absolute() else project / path + declared_root = registry.get("common_runtime_root") + if declared_root and Path(str(declared_root)).expanduser() == LEGACY_RUNTIME_ROOT: + return legacy_goal_state_file(project, goal_id) + return default_goal_state_file(project, goal_id) + + +def require_single_goal_state_route(project: Path, goal_id: str, selected: Path) -> None: + """Avoid bootstrapping a second default state file for the same Goal.""" + + current = default_goal_state_file(project, goal_id) + legacy = legacy_goal_state_file(project, goal_id) + if selected == current and legacy.exists(): + raise ValueError( + f"legacy Goal state exists at {legacy}; restore its registration or " + "migrate it explicitly before bootstrapping this Goal" + ) + if selected == legacy and current.exists(): + raise ValueError( + f"new Goal state already exists at {current}; resolve the route " + "conflict before bootstrapping this Goal" + ) def default_public_scan_root() -> str: @@ -22,8 +73,78 @@ def default_registry_path() -> Path: return DEFAULT_PROJECT_REGISTRY -def global_registry_path(runtime_root: Path = DEFAULT_RUNTIME_ROOT) -> Path: - return runtime_root / GLOBAL_REGISTRY_FILENAME +def default_runtime_route() -> dict[str, object]: + """Inspect the two default routes without creating either one.""" + + current = global_registry_path(DEFAULT_RUNTIME_ROOT) + legacy = global_registry_path(LEGACY_RUNTIME_ROOT) + current_exists = current.exists() or current.is_symlink() + legacy_exists = legacy.exists() or legacy.is_symlink() + invalid = any( + path.is_symlink() or not path.is_file() + for path, present in ((current, current_exists), (legacy, legacy_exists)) + if present + ) + if invalid: + status = "invalid" + elif current_exists and legacy_exists: + status = "conflict" + elif legacy_exists: + status = "legacy" + elif current_exists: + status = "current" + else: + status = "fresh" + selected = LEGACY_RUNTIME_ROOT if status == "legacy" else DEFAULT_RUNTIME_ROOT + recommended_action = None + if status == "conflict": + recommended_action = ( + "Select one registry with --registry/--runtime-root; " + "inspect both roots before migration." + ) + elif status == "invalid": + recommended_action = ( + "A default registry path is not a regular file; inspect it before continuing." + ) + elif status == "legacy": + recommended_action = ( + "Preview `loopx migrate-local-state`; existing state remains on its legacy route." + ) + return { + "status": status, + "selected_runtime_root": str(selected), + "target_runtime_root": str(DEFAULT_RUNTIME_ROOT), + "legacy_runtime_root": str(LEGACY_RUNTIME_ROOT), + "target_registry_exists": current_exists, + "legacy_registry_exists": legacy_exists, + "recommended_action": recommended_action, + } + + +def select_default_runtime_root() -> Path: + route = default_runtime_route() + if route["status"] == "conflict": + raise ValueError( + "Both default LoopX registries exist. Select an explicit --registry and " + "--runtime-root; resolve the route conflict before using implicit defaults." + ) + if route["status"] == "invalid": + raise ValueError(str(route["recommended_action"])) + return Path(str(route["selected_runtime_root"])) + + +def shell_selected_global_registry() -> str: + selected = select_default_runtime_root() + return ( + SHELL_LEGACY_GLOBAL_REGISTRY + if selected == LEGACY_RUNTIME_ROOT + else SHELL_DEFAULT_GLOBAL_REGISTRY + ) + + +def global_registry_path(runtime_root: Path | None = None) -> Path: + selected = runtime_root if runtime_root is not None else select_default_runtime_root() + return selected / GLOBAL_REGISTRY_FILENAME def registry_project_root(registry_path: Path) -> Path: @@ -50,7 +171,7 @@ def resolve_runtime_root( if not value: value = registry.get("common_runtime_root") if isinstance(registry, dict) else None if not value: - return DEFAULT_RUNTIME_ROOT + return select_default_runtime_root() runtime_root = Path(str(value)).expanduser() if runtime_root.is_absolute() or registry_path is None: diff --git a/loopx/project_alias.py b/loopx/project_alias.py index 55ca47d8da..996bbc5ef8 100644 --- a/loopx/project_alias.py +++ b/loopx/project_alias.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from .paths import DEFAULT_RUNTIME_ROOT, global_registry_path +from .paths import global_registry_path, select_default_runtime_root from .registry import registry_goals @@ -65,7 +65,7 @@ def _primary_repo_from_common_dir(common_dir: Path | None) -> Path | None: def _default_global_registry_path() -> Path: runtime_env = os.environ.get("LOOPX_RUNTIME_ROOT") - runtime_root = Path(runtime_env).expanduser() if runtime_env else DEFAULT_RUNTIME_ROOT + runtime_root = Path(runtime_env).expanduser() if runtime_env else select_default_runtime_root() return global_registry_path(runtime_root) diff --git a/loopx/project_map.py b/loopx/project_map.py index b43e821fc0..b5f930f342 100644 --- a/loopx/project_map.py +++ b/loopx/project_map.py @@ -7,7 +7,7 @@ from .authority import compact_authority_registry from .feedback import validate_local_control_text, validate_public_safe_text from .history import load_registry -from .paths import rel_or_abs, resolve_runtime_root +from .paths import DEFAULT_PROJECT_GOALS, LEGACY_PROJECT_GOALS, rel_or_abs, resolve_runtime_root from .state_refresh import ( derive_recommended_action, extract_section_lines, @@ -38,7 +38,7 @@ "README.md", "AGENTS.md", ".loopx/registry.json", - ".codex/goals", + ".loopx/goals", "docs", "tests", "package.json", @@ -92,8 +92,18 @@ def collect_project_inventory(project: Path | None, *, goal_id: str | None = Non "files_checked": len(PROJECT_INVENTORY_PATHS), "checks": [], } + selected_goal_root = DEFAULT_PROJECT_GOALS + if state_file is not None: + try: + relative_state = state_file.relative_to(project) + except ValueError: + relative_state = None + if relative_state is not None and relative_state.parts[:2] == LEGACY_PROJECT_GOALS.parts: + selected_goal_root = LEGACY_PROJECT_GOALS checks = [] for rel_path in PROJECT_INVENTORY_PATHS: + if rel_path == str(DEFAULT_PROJECT_GOALS): + rel_path = str(selected_goal_root) path = project / rel_path checks.append( { @@ -104,10 +114,10 @@ def collect_project_inventory(project: Path | None, *, goal_id: str | None = Non } ) if goal_id: - goal_state_dir = project / ".codex" / "goals" / goal_id + goal_state_dir = project / selected_goal_root / goal_id checks.append( { - "path": f".codex/goals/{goal_id}", + "path": str(selected_goal_root / goal_id), "exists": goal_state_dir.exists(), "kind": file_kind(goal_state_dir), "role": "goal_state_dir", @@ -268,12 +278,13 @@ def derive_residual_risks(record: dict[str, Any], *, opt_in_required: bool) -> l goal_id = str(record.get("goal_id") or "") if ".loopx/registry.json" in missing_paths: risks.append("project_local_registry_not_detected") - if ".codex/goals" in missing_paths: + if ".loopx/goals" in missing_paths or ".codex/goals" in missing_paths: risks.append("project_goal_root_not_detected") if "goal_state_dir" in missing_roles: risks.append(f"project_goal_state_dir_not_detected:{goal_id}" if goal_id else "project_goal_state_dir_not_detected") if ( ".loopx/registry.json" in missing_paths + or ".loopx/goals" in missing_paths or ".codex/goals" in missing_paths or "goal_state_dir" in missing_roles ): diff --git a/loopx/project_prompt.py b/loopx/project_prompt.py index 1b742189ad..fc070b75d9 100644 --- a/loopx/project_prompt.py +++ b/loopx/project_prompt.py @@ -5,6 +5,13 @@ from typing import Any from .bootstrap import default_goal_id +from .control_plane.projects.registry_codec import load_registry +from .paths import ( + registered_goal_state_file, + rel_or_abs, + resolve_runtime_root, + shell_selected_global_registry, +) from .control_plane.scheduler.execution_context import ( GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, SchedulerRuntimeProfile, @@ -19,7 +26,6 @@ DEFAULT_HANDOFF_ADAPTER_KIND = "read_only_project_map_v0" DEFAULT_HANDOFF_ADAPTER_STATUS = "connected-read-only" DEFAULT_HANDOFF_NEXT_PROBE = "(omit --next-probe until a read-only pre-tick command exists)" -SHARED_GLOBAL_REGISTRY = '"$HOME/.codex/loopx/registry.global.json"' CODEX_CLI_VISIBLE_SCHEDULER_CONTEXT = { "host_surface": "codex_cli", "scheduler_owner": "agent_cli_loop", @@ -51,7 +57,7 @@ def render_cli_command_prefix( def _render_global_registry_arg(runtime_root: str | Path | None) -> str: if runtime_root is not None: return "" - return f"--registry {SHARED_GLOBAL_REGISTRY} " + return f"--registry {shell_selected_global_registry()} " def render_goal_start_bootstrap_command( @@ -368,10 +374,11 @@ def render_connect_command( allowed_domains: list[str], write_scope: list[str], cli_bin: str = "loopx", + runtime_root: str | Path | None = None, ) -> str: lines = [ f"cd {shell_arg(project)}", - f"{shell_arg(cli_bin)} connect \\", + f"{render_cli_command_prefix(cli_bin=cli_bin, runtime_root=runtime_root)} connect \\", f" --goal-id {shell_arg(goal_id)} \\", f" --objective {shell_arg(objective)} \\", f" --domain {shell_arg(domain)} \\", @@ -414,6 +421,21 @@ def build_new_project_prompt( resolved_objective = objective or DEFAULT_HANDOFF_OBJECTIVE resolved_domain = domain or DEFAULT_HANDOFF_DOMAIN resolved_next_probe = next_probe or DEFAULT_HANDOFF_NEXT_PROBE + project_registry_path = project.expanduser() / ".loopx" / "registry.json" + existing_registry = ( + load_registry(project_registry_path) + if project_registry_path.is_file() + else None + ) + declared_runtime_root = ( + str(resolve_runtime_root(existing_registry, registry_path=project_registry_path)) + if isinstance(existing_registry, dict) and existing_registry.get("common_runtime_root") + else None + ) + active_state_path = rel_or_abs( + registered_goal_state_file(project.expanduser(), resolved_goal_id, existing_registry), + project.expanduser(), + ) allowed_domains = allowed_domains or [] write_scope = write_scope or [] connect_command = render_connect_command( @@ -429,22 +451,25 @@ def build_new_project_prompt( allowed_domains=allowed_domains, write_scope=write_scope, cli_bin="loopx", + runtime_root=declared_runtime_root, ) quota_guard_command = render_quota_guard_command( resolved_goal_id, + runtime_root=declared_runtime_root, scheduler_execution_context=( GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT ), ) - quota_spend_command = render_quota_spend_command(resolved_goal_id) - refresh_command = render_refresh_state_command(resolved_goal_id) + quota_spend_command = render_quota_spend_command(resolved_goal_id, runtime_root=declared_runtime_root) + refresh_command = render_refresh_state_command(resolved_goal_id, runtime_root=declared_runtime_root) progress_refresh_command = render_accountable_progress_refresh_command( - resolved_goal_id + resolved_goal_id, runtime_root=declared_runtime_root ) prompt = render_prompt_text( project=project_text, goal_doc=goal_doc_text, goal_id=resolved_goal_id, + active_state_path=active_state_path, objective=resolved_objective, domain=resolved_domain, adapter_kind=adapter_kind, @@ -456,7 +481,7 @@ def build_new_project_prompt( quota_spend_command=quota_spend_command, refresh_command=refresh_command, progress_refresh_command=progress_refresh_command, - cli_bin="loopx", + cli_bin=render_cli_command_prefix(runtime_root=declared_runtime_root), spawn_allowed=spawn_allowed, allowed_domains=allowed_domains, write_scope=write_scope, @@ -898,6 +923,7 @@ def render_prompt_text( project: str, goal_doc: str, goal_id: str, + active_state_path: str, objective: str, domain: str, adapter_kind: str, @@ -966,13 +992,13 @@ def render_prompt_text( {connect_command} ``` -4. 确认 `.loopx/registry.json` 和 `.codex/goals/{goal_id}/ACTIVE_GOAL_STATE.md` 已创建或更新。 +4. 确认 `.loopx/registry.json` 和 `{active_state_path}` 已创建或更新。 接入输出里不再有 onboarding 扫描、候选 todo 或自主推进选择项;首连之后状态里 没有可执行的 agent todo。请只读核对目标文档和 registry 的 `execution_profile`, 用中文给出 1-3 个第一个交付 todo 的候选,问用户确认后,用 `{cli_bin} todo add ...` 写入被接受的条目,再运行 `{refresh_command}` 并汇报。 在用户确认前不要开始 delivery。 - 如果目标状态包含私有证据,把 `.loopx/` 和 `.codex/goals/` 加入该项目 `.gitignore`。 + 如果目标状态包含私有证据,把 `.loopx/` 加入该项目 `.gitignore`;旧项目仍需忽略 `.codex/goals/`。 `{cli_bin} connect` 默认会同步到共享全局 registry;不要手动编辑其他项目的 registry。 接入后检查 registry 里的 `execution_profile`:它是本项目后续 heartbeat / adapter 的执行画像。 默认 cadence 是 `bounded_progress_segment`,连续小步达到阈值后,下一轮必须扩展到 @@ -1038,7 +1064,7 @@ def render_prompt_text( 7. 如果要给这个项目设置 recurring Codex App heartbeat,默认每 3 分钟一次,后续跟随 `quota should-run.scheduler_hint` 降频;不要手抄 guard 和 spend 协议;先生成 task body,再把输出复制进 automation: ```bash -{cli_bin} heartbeat-prompt --goal-id {goal_id} --active-state .codex/goals/{goal_id}/ACTIVE_GOAL_STATE.md +{cli_bin} heartbeat-prompt --goal-id {goal_id} --active-state {shell_arg(active_state_path)} ``` 8. 生成一个 read-only project map 或 first pre-tick run。不要启动线上任务、不同步外部系统、不要写生产状态,除非目标文档明确授权。通用接入优先跑: diff --git a/loopx/project_uninstall.py b/loopx/project_uninstall.py index bdddfc127c..5880c5661a 100644 --- a/loopx/project_uninstall.py +++ b/loopx/project_uninstall.py @@ -8,7 +8,7 @@ from .control_plane.runtime.time import now_local_iso, utc_timestamp from .global_registry import GlobalRegistryReduction, mutate_global_registry from .history import load_registry -from .paths import DEFAULT_RUNTIME_ROOT, global_registry_path, resolve_runtime_root +from .paths import global_registry_path, resolve_runtime_root, select_default_runtime_root from .registry import read_json, registry_goals from .runtime import validate_goal_id_path_segment @@ -345,7 +345,7 @@ def uninstall_project( "dry_run": dry_run, "execute": execute, "registry": str(registry_path), - "runtime_root": str(runtime_root or DEFAULT_RUNTIME_ROOT), + "runtime_root": str(runtime_root or select_default_runtime_root()), "global_registry": str(global_path), "goal_ids": sorted(target_goal_ids), "local_registry_goal_count_before": local_before, diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index edaf093668..01eaf4cada 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -71,7 +71,7 @@ }, { "site": "loopx/bootstrap.py::.bootstrap_project::codec_transaction:project_registry_transaction#1", - "line": 502, + "line": 512, "column": 14, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -79,7 +79,7 @@ }, { "site": "loopx/bootstrap.py::.read_json_if_exists::codec_read:load_project_registry#1", - "line": 74, + "line": 79, "column": 15, "kind": "codec_read", "api": "load_project_registry", @@ -87,7 +87,7 @@ }, { "site": "loopx/bootstrap_command_pack.py::._read_registry::codec_read:load_registry#1", - "line": 505, + "line": 506, "column": 19, "kind": "codec_read", "api": "load_registry", @@ -671,7 +671,7 @@ }, { "site": "loopx/cli_commands/registry_admin_lifecycle.py::.handle_registry_lifecycle_command::codec_read:load_registry#1", - "line": 278, + "line": 332, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -1207,15 +1207,23 @@ }, { "site": "loopx/control_plane/projects/registry.py::.bind_session::codec_write:mutate_project_registry#1", - "line": 442, + "line": 448, "column": 12, "kind": "codec_write", "api": "mutate_project_registry", "classification": "codec_api" }, + { + "site": "loopx/control_plane/projects/registry.py::.register_project_goal::codec_read:load_project_registry#1", + "line": 178, + "column": 25, + "kind": "codec_read", + "api": "load_project_registry", + "classification": "codec_api" + }, { "site": "loopx/control_plane/projects/registry.py::.register_project_goal::codec_transaction:project_registry_transaction#1", - "line": 249, + "line": 255, "column": 10, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -1223,7 +1231,7 @@ }, { "site": "loopx/control_plane/projects/registry.py::.resolve_project::codec_read:load_project_registry#1", - "line": 565, + "line": 571, "column": 16, "kind": "codec_read", "api": "load_project_registry", @@ -1231,7 +1239,7 @@ }, { "site": "loopx/control_plane/projects/registry.py::.unbind_session::codec_write:mutate_project_registry#1", - "line": 525, + "line": 531, "column": 12, "kind": "codec_write", "api": "mutate_project_registry", @@ -1359,7 +1367,7 @@ }, { "site": "loopx/control_plane/todos/event_writeback.py::._registry_goal::codec_read:load_registry#1", - "line": 63, + "line": 53, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1653,6 +1661,22 @@ "api": "load_project_registry", "classification": "codec_api" }, + { + "site": "loopx/local_state_migration.py::._read_registry::codec_read:load_registry#1", + "line": 96, + "column": 15, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, + { + "site": "loopx/local_state_migration.py::._write_project_registry::codec_transaction:project_registry_transaction#1", + "line": 112, + "column": 10, + "kind": "codec_transaction", + "api": "project_registry_transaction", + "classification": "codec_api" + }, { "site": "loopx/operator_gate.py::.record_operator_gate::codec_read:load_registry#1", "line": 315, @@ -1663,12 +1687,20 @@ }, { "site": "loopx/project_map.py::.read_only_project_map_run::codec_read:load_registry#1", - "line": 466, + "line": 477, "column": 16, "kind": "codec_read", "api": "load_registry", "classification": "codec_api" }, + { + "site": "loopx/project_prompt.py::.build_new_project_prompt::codec_read:load_registry#1", + "line": 426, + "column": 9, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/project_uninstall.py::.uninstall_project::codec_read:load_registry#1", "line": 245, @@ -1767,7 +1799,7 @@ }, { "site": "loopx/state_refresh.py::.refresh_state_run::codec_read:load_registry#1", - "line": 899, + "line": 903, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/state_backup.py b/loopx/state_backup.py index 38e63f0063..0789f1eceb 100644 --- a/loopx/state_backup.py +++ b/loopx/state_backup.py @@ -11,7 +11,7 @@ from typing import Any from . import __version__ -from .paths import DEFAULT_RUNTIME_ROOT +from .paths import select_default_runtime_root STATE_BACKUP_SCHEMA_VERSION = "loopx_state_backup_v0" @@ -369,7 +369,7 @@ def build_state_backup_plan( include_registry_projects: bool = True, ) -> dict[str, Any]: resolved_project = _resolved(Path(project)) - resolved_runtime_root = _resolved(Path(runtime_root).expanduser() if runtime_root else DEFAULT_RUNTIME_ROOT) + resolved_runtime_root = _resolved(Path(runtime_root).expanduser() if runtime_root else select_default_runtime_root()) resolved_output_dir = _resolved(Path(output_dir).expanduser() if output_dir else resolved_runtime_root / "backups") resolved_backup_id = backup_id or _utc_timestamp() archive_path = resolved_output_dir / f"loopx-state-{resolved_backup_id}.tar.gz" diff --git a/loopx/upgrade.py b/loopx/upgrade.py index 1f812aa74c..d6f2602514 100644 --- a/loopx/upgrade.py +++ b/loopx/upgrade.py @@ -16,7 +16,7 @@ from .heartbeat_prompt import build_heartbeat_prompt from .control_plane.reward_memory import reward_memory_goal_policy from .history import load_registry -from .paths import DEFAULT_RUNTIME_ROOT, global_registry_path, resolve_runtime_root +from .paths import global_registry_path, resolve_runtime_root, select_default_runtime_root from .registry import registry_goals, resolve_state_file from .control_plane.agents.legacy_migration import ( completed_peer_agent_runtime_migration, @@ -741,7 +741,7 @@ def build_upgrade_plan( registry = load_registry(registry_path) runtime_root = resolve_runtime_root(registry, runtime_root_override) if not registry_path.exists(): - fallback = global_registry_path(runtime_root or DEFAULT_RUNTIME_ROOT) + fallback = global_registry_path(runtime_root or select_default_runtime_root()) if fallback.exists(): registry_path = fallback registry = load_registry(registry_path) diff --git a/packages/dsh-loopx-plugin/smoke/dsh-client-artifact-smoke.mjs b/packages/dsh-loopx-plugin/smoke/dsh-client-artifact-smoke.mjs index eef7d2ddbc..a79f4f10ff 100644 --- a/packages/dsh-loopx-plugin/smoke/dsh-client-artifact-smoke.mjs +++ b/packages/dsh-loopx-plugin/smoke/dsh-client-artifact-smoke.mjs @@ -53,6 +53,7 @@ const packedStaticEntries = new Set([ 'package/lib/types/driver.d.ts', 'package/lib/types/goalbar/connection-rpc.d.ts', 'package/lib/types/goalbar/events.d.ts', + 'package/lib/types/goalbar/project-registry-wire.d.ts', 'package/lib/types/goalbar/protocol.d.ts', 'package/lib/types/goalbar/read-model.d.ts', 'package/lib/types/goalbar/service.d.ts', diff --git a/packages/dsh-loopx-plugin/smoke/dsh-goalbar-runtime-smoke.mjs b/packages/dsh-loopx-plugin/smoke/dsh-goalbar-runtime-smoke.mjs index c047eff896..2c654c522e 100644 --- a/packages/dsh-loopx-plugin/smoke/dsh-goalbar-runtime-smoke.mjs +++ b/packages/dsh-loopx-plugin/smoke/dsh-goalbar-runtime-smoke.mjs @@ -56,6 +56,7 @@ const packedStaticEntries = new Set([ 'package/lib/types/driver.d.ts', 'package/lib/types/goalbar/connection-rpc.d.ts', 'package/lib/types/goalbar/events.d.ts', + 'package/lib/types/goalbar/project-registry-wire.d.ts', 'package/lib/types/goalbar/protocol.d.ts', 'package/lib/types/goalbar/read-model.d.ts', 'package/lib/types/goalbar/service.d.ts', @@ -539,9 +540,9 @@ async function exercisePackedService(installed) { expected: { goalId, loopxAgentId }, agentStatus: 'running', }) - await mkdir(join(installed, '.codex', 'goals', goalId), { recursive: true }) + await mkdir(join(installed, '.loopx', 'goals', goalId), { recursive: true }) await writeFile( - join(installed, '.codex', 'goals', goalId, 'ACTIVE_GOAL_STATE.md'), + join(installed, '.loopx', 'goals', goalId, 'ACTIVE_GOAL_STATE.md'), 'runtime smoke state revision\n', ) assert.deepEqual((await externalWatch).value.result, { diff --git a/packages/dsh-loopx-plugin/smoke/dsh-profile-smoke.mjs b/packages/dsh-loopx-plugin/smoke/dsh-profile-smoke.mjs index 52c258bb6a..1fcf535771 100755 --- a/packages/dsh-loopx-plugin/smoke/dsh-profile-smoke.mjs +++ b/packages/dsh-loopx-plugin/smoke/dsh-profile-smoke.mjs @@ -36,6 +36,7 @@ const packedStaticEntries = new Set([ 'package/lib/types/driver.d.ts', 'package/lib/types/goalbar/connection-rpc.d.ts', 'package/lib/types/goalbar/events.d.ts', + 'package/lib/types/goalbar/project-registry-wire.d.ts', 'package/lib/types/goalbar/protocol.d.ts', 'package/lib/types/goalbar/read-model.d.ts', 'package/lib/types/goalbar/service.d.ts', diff --git a/packages/dsh-loopx-plugin/src/goalbar/project-registry-wire.ts b/packages/dsh-loopx-plugin/src/goalbar/project-registry-wire.ts new file mode 100644 index 0000000000..b2bfc92f05 --- /dev/null +++ b/packages/dsh-loopx-plugin/src/goalbar/project-registry-wire.ts @@ -0,0 +1,203 @@ +/** Read the two project-registry wire forms used by the Python registry codec. */ + +import { createHash } from 'node:crypto' +import { TextDecoder } from 'node:util' + +export class ProjectRegistryWireError extends Error {} + +interface JsonNode { + readonly value: unknown + readonly canonical: string + readonly items?: readonly JsonNode[] +} + +function fail(message = 'strict project registry is invalid'): never { + throw new ProjectRegistryWireError(message) +} + +function pythonNumber(raw: string): string { + if (!/[.eE]/u.test(raw)) return BigInt(raw).toString() + const number = Number(raw) + if (!Number.isFinite(number)) fail('strict project registry contains non-finite number') + if (Object.is(number, -0)) return '-0.0' + if (number === 0) return '0.0' + + // Python json.dumps uses the shortest binary64 representation, switching + // to exponent notation below 1e-4 and at 1e16. Keep its two-digit exponent. + const sign = number < 0 ? '-' : '' + const [coefficient, exponentText] = Math.abs(number).toExponential().split('e') + if (coefficient === undefined || exponentText === undefined) fail() + const exponent = Number(exponentText) + if (exponent < -4 || exponent >= 16) { + const magnitude = String(Math.abs(exponent)).padStart(2, '0') + return `${sign}${coefficient}e${exponent < 0 ? '-' : '+'}${magnitude}` + } + const digits = coefficient.replace('.', '') + const decimalAt = exponent + 1 + if (decimalAt <= 0) return `${sign}0.${'0'.repeat(-decimalAt)}${digits}` + if (decimalAt >= digits.length) { + return `${sign}${digits}${'0'.repeat(decimalAt - digits.length)}.0` + } + return `${sign}${digits.slice(0, decimalAt)}.${digits.slice(decimalAt)}` +} + +function compareCodePoints(left: string, right: string): number { + const a = Array.from(left, character => character.codePointAt(0) as number) + const b = Array.from(right, character => character.codePointAt(0) as number) + for (let index = 0; index < Math.min(a.length, b.length); index += 1) { + if (a[index] !== b[index]) return (a[index] as number) - (b[index] as number) + } + return a.length - b.length +} + +/** Parse strict JSON without losing duplicate keys, large integers, or float kind. */ +function parseStrictJson(text: string): JsonNode { + let cursor = 0 + const whitespace = () => { + while (/[\t\n\r ]/u.test(text[cursor] ?? '')) cursor += 1 + } + const string = (): string => { + if (text[cursor] !== '"') fail() + const start = cursor + cursor += 1 + while (cursor < text.length) { + if (text[cursor] === '\\') { + cursor += 2 + continue + } + if (text[cursor] === '"') { + cursor += 1 + const value: unknown = JSON.parse(text.slice(start, cursor)) + if (typeof value !== 'string') fail() + return value + } + cursor += 1 + } + return fail() + } + const value = (): JsonNode => { + whitespace() + const token = text[cursor] + if (token === '"') { + const parsed = string() + return { value: parsed, canonical: JSON.stringify(parsed) } + } + if (token === '[') { + cursor += 1 + whitespace() + const items: JsonNode[] = [] + if (text[cursor] !== ']') { + while (true) { + items.push(value()) + whitespace() + if (text[cursor] !== ',') break + cursor += 1 + } + } + if (text[cursor] !== ']') fail() + cursor += 1 + return { + value: items.map(item => item.value), + canonical: `[${items.map(item => item.canonical).join(',')}]`, + items, + } + } + if (token === '{') { + cursor += 1 + whitespace() + const entries: Array = [] + const object: Record = Object.create(null) as Record + if (text[cursor] !== '}') { + while (true) { + const key = string() + if (Object.hasOwn(object, key)) fail('strict project registry contains duplicate key') + whitespace() + if (text[cursor] !== ':') fail() + cursor += 1 + const child = value() + object[key] = child.value + entries.push([key, child]) + whitespace() + if (text[cursor] !== ',') break + cursor += 1 + whitespace() + } + } + if (text[cursor] !== '}') fail() + cursor += 1 + entries.sort(([left], [right]) => compareCodePoints(left, right)) + return { + value: object, + canonical: `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${child.canonical}`).join(',')}}`, + } + } + for (const [literal, parsed] of [ + ['true', true], ['false', false], ['null', null], + ] as const) { + if (text.startsWith(literal, cursor)) { + cursor += literal.length + return { value: parsed, canonical: literal } + } + } + const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(text.slice(cursor)) + if (match === null) fail() + cursor += match[0].length + return { value: Number(match[0]), canonical: pythonNumber(match[0]) } + } + + const root = value() + whitespace() + if (cursor !== text.length) fail() + return root +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function decodeGoalBarProjectRegistry(raw: Buffer): unknown { + let text: string + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(raw) + } catch { + return fail('project registry is invalid UTF-8') + } + if (text.trimStart().startsWith('{')) { + try { + return JSON.parse(text) as unknown + } catch { + return fail('project registry is invalid') + } + } + let root: JsonNode + try { + root = parseStrictJson(text) + } catch (error: unknown) { + if (error instanceof ProjectRegistryWireError) throw error + return fail('strict project registry is invalid') + } + if (!Array.isArray(root.value) || root.items?.length !== 2) fail() + const [header, payload] = root.value as unknown[] + if (!isRecord(header) || !isRecord(payload)) fail() + if (Object.keys(header).sort().join(',') + !== 'minimum_writer_protocol,payload_sha256,schema_version') fail() + if (header.schema_version !== 'loopx_project_registry_envelope_v1' + && header.schema_version !== 'loopx_project_registry_envelope_v2') { + fail('strict project registry schema_version is unsupported') + } + if (typeof header.minimum_writer_protocol !== 'string' + || !header.minimum_writer_protocol) fail() + if (typeof header.payload_sha256 !== 'string' + || !/^sha256:[0-9a-f]{64}$/u.test(header.payload_sha256)) fail() + const canonicalPayload = root.items[1]?.canonical + if (canonicalPayload === undefined) fail() + const digest = `sha256:${createHash('sha256').update(canonicalPayload).digest('hex')}` + if (header.payload_sha256 !== digest) { + fail('strict project registry payload digest does not match') + } + if (header.schema_version === 'loopx_project_registry_envelope_v2' + || payload.profile_id === 'source_session_v1') { + fail('lifecycle-only project registry is not a Goal runtime registry') + } + return payload +} diff --git a/packages/dsh-loopx-plugin/src/goalbar/read-model.ts b/packages/dsh-loopx-plugin/src/goalbar/read-model.ts index 0288bfd7db..fe150c1291 100644 --- a/packages/dsh-loopx-plugin/src/goalbar/read-model.ts +++ b/packages/dsh-loopx-plugin/src/goalbar/read-model.ts @@ -19,10 +19,14 @@ import type { GoalBarReadFaultCode, GoalBarSnapshotV1, } from './protocol.ts' +import { + decodeGoalBarProjectRegistry, + ProjectRegistryWireError, +} from './project-registry-wire.ts' export const GOALBAR_HOST_SURFACE = 'deepseek-harness-native' as const export const GOALBAR_PROJECT_REGISTRY = '.loopx/registry.json' as const -export const GOALBAR_ACTIVE_STATE_ROOT = '.codex/goals' as const +export const GOALBAR_ACTIVE_STATE_ROOT = '.loopx/goals' as const export const GOALBAR_ACTIVE_STATE_FILE = 'ACTIVE_GOAL_STATE.md' as const const SOURCE_REVISION_FAILURE = `sha256:${createHash('sha256') @@ -93,12 +97,6 @@ function validatedSourcePaths(options: GoalBarSourceRevisionOptions): string[] { || options.goalId.includes('\\')) { throw new GoalBarSourceRevisionError('goal id is not a safe path segment') } - paths.push(resolve( - options.cwd, - GOALBAR_ACTIVE_STATE_ROOT, - options.goalId, - GOALBAR_ACTIVE_STATE_FILE, - )) } if ((options.goalId === undefined) !== (options.loopxAgentId === undefined) || (options.loopxAgentId !== undefined @@ -111,6 +109,38 @@ function validatedSourcePaths(options: GoalBarSourceRevisionOptions): string[] { return paths } +function registeredActiveStatePath(cwd: string, goalId: string, registry: Buffer): string { + let payload: unknown + try { + payload = decodeGoalBarProjectRegistry(registry) + } catch (error: unknown) { + if (error instanceof ProjectRegistryWireError) { + throw new GoalBarSourceRevisionError(error.message) + } + throw new GoalBarSourceRevisionError('project registry is invalid') + } + if (typeof payload !== 'object' || payload === null + || !('goals' in payload) || !Array.isArray(payload.goals)) { + throw new GoalBarSourceRevisionError('project registry has no Goal list') + } + const selected = payload.goals.find((goal: unknown) => + typeof goal === 'object' && goal !== null && 'id' in goal && goal.id === goalId) + let declared = join(GOALBAR_ACTIVE_STATE_ROOT, goalId, GOALBAR_ACTIVE_STATE_FILE) + if (selected !== undefined) { + if (!('state_file' in selected) + || typeof selected.state_file !== 'string' + || !selected.state_file.trim()) { + throw new GoalBarSourceRevisionError('registered Goal has no state_file') + } + declared = selected.state_file + } + const path = resolve(cwd, declared) + if (!isContained(cwd, path)) { + throw new GoalBarSourceRevisionError('registered Goal state path escaped project cwd') + } + return path +} + async function readRevisionSource( cwd: string, path: string, @@ -132,22 +162,27 @@ async function readRevisionSource( } } -/** Hash only the fixed authoritative paths; contents and local paths never cross the wire. */ +/** Hash the registry-declared active state; contents and local paths never cross the wire. */ export async function computeGoalBarSourceRevision( options: GoalBarSourceRevisionOptions, ): Promise { const paths = validatedSourcePaths(options) const hash = createHash('sha256') - frame(hash, 'contract', Buffer.from('loopx-goalbar-source-revision-v1')) + frame(hash, 'contract', Buffer.from('loopx-goalbar-source-revision-v2')) if (options.goalId !== undefined && options.loopxAgentId !== undefined) { frame(hash, 'binding.goal-id', Buffer.from(options.goalId, 'utf8')) frame(hash, 'binding.agent-id', Buffer.from(options.loopxAgentId, 'utf8')) } - for (let index = 0; index < paths.length; index += 1) { - const source = await readRevisionSource(options.cwd, paths[index] as string) - frame(hash, index === 0 ? 'registry.exists' : 'active-state.exists', - Buffer.from(source.exists ? '1' : '0')) - frame(hash, index === 0 ? 'registry.content' : 'active-state.content', source.content) + const registry = await readRevisionSource(options.cwd, paths[0] as string) + frame(hash, 'registry.exists', Buffer.from(registry.exists ? '1' : '0')) + frame(hash, 'registry.content', registry.content) + if (options.goalId !== undefined) { + const statePath = registry.exists + ? registeredActiveStatePath(options.cwd, options.goalId, registry.content) + : resolve(options.cwd, GOALBAR_ACTIVE_STATE_ROOT, options.goalId, GOALBAR_ACTIVE_STATE_FILE) + const source = await readRevisionSource(options.cwd, statePath) + frame(hash, 'active-state.exists', Buffer.from(source.exists ? '1' : '0')) + frame(hash, 'active-state.content', source.content) } return `sha256:${hash.digest('hex')}` } @@ -681,4 +716,4 @@ export async function readGoalBarModel( } import { createHash } from 'node:crypto' import { readFile, realpath } from 'node:fs/promises' -import { isAbsolute, relative, resolve, sep } from 'node:path' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' diff --git a/packages/dsh-loopx-plugin/tests/fixtures/project-registry-strict-v1.json b/packages/dsh-loopx-plugin/tests/fixtures/project-registry-strict-v1.json new file mode 100644 index 0000000000..0457706bdb --- /dev/null +++ b/packages/dsh-loopx-plugin/tests/fixtures/project-registry-strict-v1.json @@ -0,0 +1,25 @@ +[ + { + "schema_version": "loopx_project_registry_envelope_v1", + "minimum_writer_protocol": "goal_instance_v1", + "payload_sha256": "sha256:701242ccd7a668c02de2aef1d72abf923d9f6504f4518277a639fa5f66635098" + }, + { + "common_runtime_root": "legacy-runtime", + "goals": [ + { + "id": "goal-fixture", + "state_file": ".codex/goals/goal-fixture/ACTIVE_GOAL_STATE.md" + } + ], + "meta": { + "label": "目标", + "fraction": 1.0, + "large": 123456789012345678901, + "nested": { + "z": 2, + "a": 1 + } + } + } +] diff --git a/packages/dsh-loopx-plugin/tests/goalbar-read-model.spec.ts b/packages/dsh-loopx-plugin/tests/goalbar-read-model.spec.ts index 3abb3ceab8..5443207278 100644 --- a/packages/dsh-loopx-plugin/tests/goalbar-read-model.spec.ts +++ b/packages/dsh-loopx-plugin/tests/goalbar-read-model.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -17,13 +17,14 @@ const sessionId = 'dsh-session-1' const goalId = 'goal-one' const loopxAgentId = 'codex-main-control' const hostilePath = ['', 'sensitive-host', 'project'].join('/') +const strictRegistryFixture = new URL('./fixtures/project-registry-strict-v1.json', import.meta.url) describe('GoalBar source revision', () => { it('covers existence, equal-size replacement, and the bound active state', async () => { const cwd = await mkdtemp(join(tmpdir(), 'loopx-goalbar-revision-')) try { const registryDir = join(cwd, '.loopx') - const stateDir = join(cwd, '.codex', 'goals', goalId) + const stateDir = join(cwd, '.loopx', 'goals', goalId) await mkdir(registryDir, { recursive: true }) await mkdir(stateDir, { recursive: true }) const registry = join(registryDir, 'registry.json') @@ -52,6 +53,81 @@ describe('GoalBar source revision', () => { } }) + it('follows the registered legacy state and the migrated state without dual reads', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'loopx-goalbar-route-')) + try { + const registry = join(cwd, '.loopx', 'registry.json') + const oldState = join(cwd, '.codex', 'goals', goalId, 'ACTIVE_GOAL_STATE.md') + const newState = join(cwd, '.loopx', 'goals', goalId, 'ACTIVE_GOAL_STATE.md') + await mkdir(join(cwd, '.loopx', 'goals', goalId), { recursive: true }) + await mkdir(join(cwd, '.codex', 'goals', goalId), { recursive: true }) + const binding = { cwd, goalId, loopxAgentId } + await writeFile(oldState, 'old state', 'utf8') + await writeFile(newState, 'new state', 'utf8') + await writeFile(registry, JSON.stringify({ goals: [{ id: goalId, + state_file: `.codex/goals/${goalId}/ACTIVE_GOAL_STATE.md` }] }), 'utf8') + const legacy = await computeGoalBarSourceRevision(binding) + await writeFile(newState, 'newer state', 'utf8') + expect(await computeGoalBarSourceRevision(binding)).toBe(legacy) + await writeFile(registry, JSON.stringify({ goals: [{ id: goalId, + state_file: `.loopx/goals/${goalId}/ACTIVE_GOAL_STATE.md` }] }), 'utf8') + const migrated = await computeGoalBarSourceRevision(binding) + expect(migrated).not.toBe(legacy) + await writeFile(newState, 'final state', 'utf8') + expect(await computeGoalBarSourceRevision(binding)).not.toBe(migrated) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('accepts the Python strict registry wire form and hashes only its declared legacy state', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'loopx-goalbar-strict-route-')) + const registry = join(cwd, '.loopx', 'registry.json') + const oldState = join(cwd, '.codex', 'goals', 'goal-fixture', 'ACTIVE_GOAL_STATE.md') + const newState = join(cwd, '.loopx', 'goals', 'goal-fixture', 'ACTIVE_GOAL_STATE.md') + try { + await mkdir(join(cwd, '.loopx', 'goals', 'goal-fixture'), { recursive: true }) + await mkdir(join(cwd, '.codex', 'goals', 'goal-fixture'), { recursive: true }) + const fixture = await readFile(strictRegistryFixture) + await writeFile(registry, fixture) + await writeFile(oldState, 'legacy state', 'utf8') + await writeFile(newState, 'decoy state', 'utf8') + const binding = { cwd, goalId: 'goal-fixture', loopxAgentId } + const before = await computeGoalBarSourceRevision(binding) + await writeFile(newState, 'changed decoy', 'utf8') + expect(await computeGoalBarSourceRevision(binding)).toBe(before) + await writeFile(oldState, 'changed legacy', 'utf8') + const changedState = await computeGoalBarSourceRevision(binding) + expect(changedState).not.toBe(before) + await writeFile(registry, Buffer.concat([fixture, Buffer.from(' ')])) + expect(await computeGoalBarSourceRevision(binding)).not.toBe(changedState) + await writeFile(registry, fixture.toString('utf8').replace('"fraction": 1.0', '"fraction": 1.00')) + expect(await computeGoalBarSourceRevision(binding)).toMatch(/^sha256:[0-9a-f]{64}$/u) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('rejects tampered and duplicate-key strict registry payloads', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'loopx-goalbar-strict-invalid-')) + try { + const registry = join(cwd, '.loopx', 'registry.json') + await mkdir(join(cwd, '.loopx'), { recursive: true }) + const fixture = await readFile(strictRegistryFixture, 'utf8') + const binding = { cwd, goalId: 'goal-fixture', loopxAgentId } + await writeFile(registry, fixture.replace('goal-fixture', 'other-goal'), 'utf8') + await expect(computeGoalBarSourceRevision(binding)).rejects.toThrow('digest') + await writeFile(registry, fixture.replace( + '"id": "goal-fixture",', '"id": "goal-fixture", "id": "goal-fixture",', + ), 'utf8') + await expect(computeGoalBarSourceRevision(binding)).rejects.toThrow('duplicate') + await writeFile(registry, fixture.replace('envelope_v1', 'envelope_v2'), 'utf8') + await expect(computeGoalBarSourceRevision(binding)).rejects.toThrow('lifecycle-only') + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + it('rejects unsafe goal path segments and non-normalized cwd values', async () => { await expect(computeGoalBarSourceRevision({ cwd: '/fixture/project', diff --git a/packages/dsh-loopx-plugin/tests/goalbar-service.spec.ts b/packages/dsh-loopx-plugin/tests/goalbar-service.spec.ts index 05424ab081..58e571eee1 100644 --- a/packages/dsh-loopx-plugin/tests/goalbar-service.spec.ts +++ b/packages/dsh-loopx-plugin/tests/goalbar-service.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -17,6 +17,7 @@ import type { import { GoalBarCoordinator, } from '../src/goalbar/events.ts' +import { computeGoalBarSourceRevision } from '../src/goalbar/read-model.ts' import { createGoalBarService, decodeGoalBarLifecycleExecutionV1, @@ -26,6 +27,9 @@ import type { GoalBarRequestV1 } from '../src/goalbar/protocol.ts' const sessionId = 'session-fixture' const goalId = 'goal-fixture' const loopxAgentId = 'agent-fixture' +const defaultCwd = '/fixture/project' +const defaultSourceRevision = await computeGoalBarSourceRevision({ cwd: defaultCwd }) +const strictRegistryFixture = new URL('./fixtures/project-registry-strict-v1.json', import.meta.url) const command: LoopXCommand = { file: 'loopx', prefix: [], @@ -56,7 +60,7 @@ function agentFixture( id = sessionId, initialStatus: 'idle' | 'running' = 'idle', eventSource?: (() => SessionEvent[]) | undefined, - cwd = '/fixture/project', + cwd = defaultCwd, ): AgentFixture { const events: SessionEvent[] = [] let status = initialStatus @@ -366,8 +370,7 @@ function watchRequest( op: 'watch', sessionId, afterSessionEventSeq, - sourceRevision: options.sourceRevision - ?? 'sha256:04284a0332528476ac54e743cb76d5c0731985225b77926e7f6a32941db96c42', + sourceRevision: options.sourceRevision ?? defaultSourceRevision, expected: options.expected ?? null, agentStatus: options.agentStatus ?? 'idle', } @@ -655,7 +658,7 @@ describe('GoalBar Host read/watch', () => { it('retries a read when the active state is atomically replaced during CLI reads', async () => { const cwd = await mkdtemp(join(tmpdir(), 'loopx-goalbar-stable-read-')) - const stateDir = join(cwd, '.codex', 'goals', goalId) + const stateDir = join(cwd, '.loopx', 'goals', goalId) await mkdir(join(cwd, '.loopx'), { recursive: true }) await mkdir(stateDir, { recursive: true }) await writeFile(join(cwd, '.loopx', 'registry.json'), '{"goals":[]}', 'utf8') @@ -691,6 +694,36 @@ describe('GoalBar Host read/watch', () => { } }) + it('reads a strict registry with an unmigrated Goal through the stable service path', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'loopx-goalbar-strict-service-')) + const legacy = join(cwd, '.codex', 'goals', goalId, 'ACTIVE_GOAL_STATE.md') + const decoy = join(cwd, '.loopx', 'goals', goalId, 'ACTIVE_GOAL_STATE.md') + await mkdir(join(cwd, '.codex', 'goals', goalId), { recursive: true }) + await mkdir(join(cwd, '.loopx', 'goals', goalId), { recursive: true }) + await writeFile(join(cwd, '.loopx', 'registry.json'), await readFile(strictRegistryFixture)) + await writeFile(legacy, 'legacy state', 'utf8') + await writeFile(decoy, 'decoy state', 'utf8') + const fixture = harness({ cwd }) + try { + const first = await fixture.service.handle(readRequest(), new AbortController().signal) + expect(first.result.kind).toBe('present') + if (first.result.kind !== 'present') throw new Error('expected present fixture') + await writeFile(decoy, 'changed decoy', 'utf8') + const unchanged = await fixture.service.handle(readRequest(), new AbortController().signal) + expect(unchanged.result.kind).toBe('present') + if (unchanged.result.kind !== 'present') throw new Error('expected present fixture') + expect(unchanged.result.sourceRevision).toBe(first.result.sourceRevision) + await writeFile(legacy, 'changed legacy', 'utf8') + const changed = await fixture.service.handle(readRequest(), new AbortController().signal) + expect(changed.result.kind).toBe('present') + if (changed.result.kind !== 'present') throw new Error('expected present fixture') + expect(changed.result.sourceRevision).not.toBe(first.result.sourceRevision) + } finally { + await fixture.service.dispose() + await rm(cwd, { recursive: true, force: true }) + } + }) + it('fails closed when a watch cursor is beyond the exact Session log', async () => { const fixture = harness() expect((await fixture.service.handle( diff --git a/scripts/codex_app_apply_rrule.py b/scripts/codex_app_apply_rrule.py index 6b13e41a93..b94352a96e 100644 --- a/scripts/codex_app_apply_rrule.py +++ b/scripts/codex_app_apply_rrule.py @@ -31,6 +31,7 @@ from typing import Any from loopx.turn_identity import normalize_turn_instance_id +from loopx.paths import global_registry_path, select_default_runtime_root _FAILURE_OUTPUT_LIMIT = 2_000 @@ -462,7 +463,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument( "--registry", type=Path, - default=Path.home() / ".codex/loopx/registry.global.json", + help="Global registry; defaults to the selected LoopX local state route.", ) parser.add_argument("--automation-id", default="loopx") parser.add_argument( @@ -490,7 +491,10 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: ), ) parser.add_argument("--dry-run", action="store_true") - return parser.parse_args(argv) + args = parser.parse_args(argv) + if args.registry is None: + args.registry = global_registry_path(select_default_runtime_root()) + return args def main(argv: list[str] | None = None) -> int: diff --git a/scripts/install-local.sh b/scripts/install-local.sh index c344d1c77d..7b9108c3ab 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -217,10 +217,8 @@ acquire_install_lock() { warn_stale_promotion_readiness() { local python_bin="${LOOPX_PYTHON:-python3}" - local runtime_root="${LOOPX_RUNTIME_ROOT:-$codex_home/loopx}" # Reuse the same collector and registry resolution without importing every CLI. - LOOPX_PROMOTION_WARNING_RUNTIME_ROOT="$runtime_root" \ - PYTHONSAFEPATH=1 PYTHONPATH="$repo_root${PYTHONPATH:+:$PYTHONPATH}" \ + PYTHONSAFEPATH=1 PYTHONPATH="$repo_root${PYTHONPATH:+:$PYTHONPATH}" \ "$python_bin" - <<'PY_WARNING' || true import argparse import os @@ -230,7 +228,7 @@ from loopx.cli_runtime import resolve_cli_registry from loopx.paths import default_registry_path from loopx.promotion_gate import build_promotion_gate -runtime_root = os.environ["LOOPX_PROMOTION_WARNING_RUNTIME_ROOT"] +runtime_root = os.environ.get("LOOPX_RUNTIME_ROOT") or None args = argparse.Namespace(command="promotion-gate", registry=str(default_registry_path()), runtime_root=runtime_root) registry_path, _ = resolve_cli_registry(args, []) try: diff --git a/scripts/macos-dashboard-launchagent.sh b/scripts/macos-dashboard-launchagent.sh index 1665370944..4357bfc5a6 100755 --- a/scripts/macos-dashboard-launchagent.sh +++ b/scripts/macos-dashboard-launchagent.sh @@ -4,7 +4,7 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="${LOOPX_REPO_ROOT:-$(cd "$script_dir/.." && pwd)}" bin_dir="${LOOPX_BIN_DIR:-$HOME/.local/bin}" -registry="${LOOPX_GLOBAL_REGISTRY:-$HOME/.codex/loopx/registry.global.json}" +registry_override="${LOOPX_GLOBAL_REGISTRY:-}" status_port="${LOOPX_STATUS_PORT:-8766}" status_limit="${LOOPX_STATUS_LIMIT:-80}" status_contract_min_version="${LOOPX_STATUS_CONTRACT_MIN_VERSION:-2}" @@ -103,6 +103,15 @@ resolve_loopx_python() { resolve_python_command } +resolve_global_registry() { + local python_command="$1" + if [[ -n "$registry_override" ]]; then + printf '%s\n' "$registry_override" + return 0 + fi + "$python_command" -c 'from loopx.paths import global_registry_path, select_default_runtime_root; print(global_registry_path(select_default_runtime_root()))' +} + resolve_optional_command() { local command_name="$1" if command -v "$command_name" >/dev/null 2>&1; then @@ -192,10 +201,11 @@ PY } write_plists() { - local status_command python_command codex_command claude_command lark_cli_command + local status_command python_command codex_command claude_command lark_cli_command registry local path_prefix command_path command_dir status_shell chat_shell control_plane_write_arg lark_cli_arg codex_home_export chat_codex_home status_command="$(resolve_status_command)" python_command="$(resolve_loopx_python)" + registry="$(resolve_global_registry "$python_command")" codex_command="$(resolve_optional_command codex)" claude_command="$(resolve_optional_command claude)" lark_cli_command="$(resolve_lark_cli_command "$python_command" 2>/dev/null || true)" diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 77f8339376..819fa36db7 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `local_state_physical_route_guard_fragmentation` | Migration reviews repeatedly find a new symlink or Windows junction route after earlier target/backup fixes passed. | Every source, destination, backup, receipt, and rollback read/copy/rename path; the shared redirect predicate; preview and near-effect negative cases on the native OS. | Individual phases used `is_symlink()` or existence checks instead of one physical-route rule, so green happy-path receipts hid reads or moves outside the declared tree. | Reuse one symlink/junction/reparse classifier at each I/O boundary, recheck immediately before effects, and fail closed with the backup retained. Cover source, target, nested runtime content, backup snapshots and rollback using synthetic substitution plus native platform negatives; keep active user state untouched. | | `capability_catalog_editor_kind_drift` | Machine or Goal settings report an empty capability list even though the configuration API returns registered capabilities. | Live API catalog IDs and editor kinds, the dashboard's accepted field-kind schema, and the page's load-error state. | One new descriptor emits an unsupported field kind; strict validation rejects the shared catalog and the machine page presents the failed load as an empty registry. | Keep the published editor vocabulary aligned with the browser contract, check every built-in descriptor together, and show a retryable error when catalog loading or validation fails. Only a successfully loaded empty catalog may show the empty state. | | `acceptance_scope_capture` | A bounded validation experiment leaves unrelated existing/new work unbound; a recorded blocker quiets replan without repairing admission. | Canonical contract scope/bindings, exact held generation, authorized configuration source, ordinary task validators and post-correction claim/lease readback. | Omitted scope silently imposed Goal-wide acceptance; repeated per-task binding masked the missing scope contract. | Require explicit scope on new owner configuration, preserve legacy persisted semantics/replay, and enforce one typed scope across admission, completion and verification freshness. Expose scope on existing read surfaces. Diagnose scope before proposing rebinding; a blocker ACK is neither a repair nor a handoff. Apply authorized corrections through CAS and validate independent work resumes while selected holds and ordinary validation remain. | | `acceptance_hold_recovery_selection_split` | Newly created advancement work is acceptance-unbound, repeated vision replans never expose its hold, or a replan packet also selects an unrelated due monitor. | Canonical acceptance tasks, scoped source Todos, bounded trigger checkpoints, effective action and original Turn receipt. | Recovery covered stale associations only; generic vision gaps displaced hold identities; candidate inventory leaked into the selected execution target. | Route missing and stale associations through the existing bounded replan lane, retain exact hold checkpoints before generic gaps, and separate replan from candidate selection. Keep owner association and completion validation enforced. A new unbound repair Todo is not runnable recovery; only a qualified successor or concrete blocker settles the exact hold. Validate real File/SQLite CLI paths and receipt reentry without mutating an active Goal. | diff --git a/tests/cli_commands/test_project_registry.py b/tests/cli_commands/test_project_registry.py index 95b669308f..c1c501251e 100644 --- a/tests/cli_commands/test_project_registry.py +++ b/tests/cli_commands/test_project_registry.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -242,7 +243,7 @@ def register(registry_path: Path) -> dict[str, object]: results = list(executor.map(register, registry_paths)) state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) assert all(result["ok"] is True for result in results) assert all(path.exists() for path in registry_paths) @@ -335,7 +336,7 @@ def test_project_register_repeated_identical_request_is_a_noop( registry_before = registry_path.read_bytes() state_file = ( knowledge_root - / ".codex" + / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" @@ -385,6 +386,49 @@ def test_project_register_repeated_identical_request_is_a_noop( assert registry_path.read_bytes() == registry_before +def test_project_register_reuses_legacy_state_from_strict_registry( + tmp_path: Path, capsys, +) -> None: + project = tmp_path / "atlas" + registry_path = project / ".loopx" / "registry.json" + args = [ + "--format", "json", "--registry", str(registry_path), + "--runtime-root", str(tmp_path / "runtime"), + "project", "register", "--project-id", "atlas", "--project-kind", "work", + "--knowledge-root", str(project), "--goal-id", "atlas-import", + "--objective", "Continue the Atlas import pipeline.", + "--acceptance", "Preserve the registered Goal state.", + "--next-effect", "Inspect the existing registration.", + "--stop-condition", "Stop before changing the import contract.", + ] + assert main(args) == 0 + capsys.readouterr() + + new_state = project / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + legacy_state = project / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + legacy_state.parent.mkdir(parents=True) + new_state.rename(legacy_state) + registry = registry_codec.load_project_registry(registry_path) + registry["goals"][0]["state_file"] = ".codex/goals/atlas-import/ACTIVE_GOAL_STATE.md" + digest = hashlib.sha256(json.dumps( + registry, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":"), + ).encode("utf-8")).hexdigest() + envelope = [ + {"schema_version": "loopx_project_registry_envelope_v1", + "minimum_writer_protocol": "goal_instance_v1", "payload_sha256": f"sha256:{digest}"}, + registry, + ] + registry_path.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + before = registry_path.read_bytes() + + assert main(args) == 0 + result = json.loads(capsys.readouterr().out) + assert result["changed"] is False + assert result["state_file"] == str(legacy_state) + assert registry_path.read_bytes() == before + assert legacy_state.exists() and not new_state.exists() + + @pytest.mark.parametrize("field", ["projects", "goals"]) def test_project_register_rejects_malformed_registry_collections( tmp_path: Path, @@ -541,7 +585,7 @@ def test_project_register_changed_goal_brief_fails_without_writing( registry_before = registry_path.read_bytes() state_file = ( knowledge_root - / ".codex" + / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" @@ -567,7 +611,7 @@ def test_project_register_removes_new_state_when_registry_write_fails( knowledge_root = tmp_path / "atlas" registry_path = knowledge_root / ".loopx" / "registry.json" state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) arguments = [ "--format", @@ -633,7 +677,7 @@ def test_project_register_removes_partial_state_when_state_write_fails( knowledge_root = tmp_path / "atlas" registry_path = knowledge_root / ".loopx" / "registry.json" state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) arguments = [ "--format", @@ -689,7 +733,7 @@ def test_project_register_recovers_exact_state_after_interruption( knowledge_root = tmp_path / "atlas" registry_path = knowledge_root / ".loopx" / "registry.json" state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) arguments = [ "--format", @@ -771,7 +815,7 @@ def test_project_register_repairs_missing_state_for_matching_records( knowledge_root = tmp_path / "atlas" registry_path = knowledge_root / ".loopx" / "registry.json" state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) arguments = [ "--format", @@ -815,7 +859,7 @@ def test_project_register_rejects_tampered_matching_state( knowledge_root = tmp_path / "atlas" registry_path = knowledge_root / ".loopx" / "registry.json" state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) arguments = [ "--format", @@ -898,7 +942,7 @@ def test_project_register_rejects_second_goal_for_existing_project( assert registry_path.read_bytes() == registry_before assert not ( knowledge_root - / ".codex" + / ".loopx" / "goals" / "atlas-export" / "ACTIVE_GOAL_STATE.md" diff --git a/tests/cli_commands/test_source_session_lifetime.py b/tests/cli_commands/test_source_session_lifetime.py index 5817e8a3a2..8cd4cbe53f 100644 --- a/tests/cli_commands/test_source_session_lifetime.py +++ b/tests/cli_commands/test_source_session_lifetime.py @@ -206,7 +206,7 @@ def test_registration_reuses_reserved_instance_after_interruption( knowledge_root = tmp_path / "atlas" registry_path = knowledge_root / ".loopx" / "registry.json" state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) arguments = _registration_arguments(registry_path, knowledge_root) original_commit = registry_codec.ProjectRegistryTransaction.commit @@ -365,7 +365,7 @@ def test_registration_recovers_same_instance_after_process_kill( knowledge_root = tmp_path / "atlas" registry_path = knowledge_root / ".loopx" / "registry.json" state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) ready = tmp_path / "registration-ready" resume = tmp_path / "registration-resume" @@ -445,7 +445,7 @@ def test_registration_rejects_state_without_its_reservation_journal( knowledge_root = tmp_path / "atlas" registry_path = knowledge_root / ".loopx" / "registry.json" state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) arguments = _registration_arguments(registry_path, knowledge_root) monkeypatch.setattr( @@ -502,7 +502,7 @@ def test_registration_rejects_a_competing_reserved_operation( knowledge_root = tmp_path / "atlas" registry_path = knowledge_root / ".loopx" / "registry.json" state_file = ( - knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + knowledge_root / ".loopx" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" ) arguments = _registration_arguments(registry_path, knowledge_root) original_commit = registry_codec.ProjectRegistryTransaction.commit diff --git a/tests/control_plane/test_project_registry_codec.py b/tests/control_plane/test_project_registry_codec.py index 2f8f9e0d14..dc06753b26 100644 --- a/tests/control_plane/test_project_registry_codec.py +++ b/tests/control_plane/test_project_registry_codec.py @@ -12,6 +12,7 @@ ProjectRegistryError, ProjectRegistryMutationError, ProjectRegistryProtocolError, + decode_project_registry, load_project_registry, mutate_project_registry, source_session_registry_transaction, @@ -114,6 +115,21 @@ def test_strict_decode_validates_canonical_payload_digest(tmp_path: Path) -> Non load_project_registry(path) +def test_dsh_strict_registry_fixture_uses_the_python_wire_contract() -> None: + fixture = ( + Path(__file__).resolve().parents[2] + / "packages/dsh-loopx-plugin/tests/fixtures/project-registry-strict-v1.json" + ) + + payload = decode_project_registry(fixture.read_bytes()) + + assert payload["goals"][0]["state_file"] == ( + ".codex/goals/goal-fixture/ACTIVE_GOAL_STATE.md" + ) + assert payload["meta"]["fraction"] == 1.0 + assert payload["meta"]["label"] == "目标" + + def test_future_protocol_is_readable_but_not_mutable(tmp_path: Path) -> None: path = tmp_path / "registry.json" payload: dict[str, object] = {"schema_version": "0.1", "goals": []} diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index cb9e642aeb..33e4b9a66b 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -26,6 +26,7 @@ from loopx.control_plane.quota.settlement import render_settlement_progress_markdown from loopx.control_plane.todos.active_state_todo_parser import parse_active_state_todos from loopx.heartbeat_prompt import build_heartbeat_prompt +from loopx.paths import shell_selected_global_registry from loopx.rollout_event_log import build_rollout_event REPO_ROOT = Path(__file__).resolve().parents[2] @@ -2797,7 +2798,7 @@ def test_visible_goal_continuation_begins_turn_and_executes_returned_selection( thin=True, ) guard_command = prompt["quota_guard_command"].replace( - "$HOME/.codex/loopx/registry.global.json", + shell_selected_global_registry().strip('"'), str(registry_path), ) @@ -2860,7 +2861,7 @@ def test_visible_goal_capability_reentry_preserves_turn_through_selection( thin=True, ) guard_command = prompt["quota_guard_command"].replace( - "$HOME/.codex/loopx/registry.global.json", + shell_selected_global_registry().strip('"'), str(registry_path), ) diff --git a/tests/control_plane/test_refresh_checkpoint_isolation.py b/tests/control_plane/test_refresh_checkpoint_isolation.py index 387add9f6c..c82944278b 100644 --- a/tests/control_plane/test_refresh_checkpoint_isolation.py +++ b/tests/control_plane/test_refresh_checkpoint_isolation.py @@ -13,7 +13,6 @@ build_explore_node_event, explore_result_log_path, ) -from loopx.control_plane.runtime import runtime_projection_route from loopx.extensions.lark import goal_channel_contracts, goal_channel_runtime from loopx.extensions.lark.presentation import explore_results from loopx.extensions.runtime import install_extension @@ -109,7 +108,8 @@ def test_stdout_recovery_requires_confirmation_to_resume_external_delivery( shared = tmp_path / "shared-runtime" monkeypatch.setenv("LOOPX_RUNTIME_ROOT", str(shared)) monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) - monkeypatch.setattr(runtime_projection_route, "DEFAULT_RUNTIME_ROOT", shared) + monkeypatch.setattr("loopx.paths.DEFAULT_RUNTIME_ROOT", shared) + monkeypatch.setattr("loopx.paths.LEGACY_RUNTIME_ROOT", tmp_path / "absent-legacy-runtime") monkeypatch.chdir(project) prefix = ["--registry", str(registry), "--runtime-root", str(runtime)] diff --git a/tests/control_plane/test_vision_checkpoint_runtime.py b/tests/control_plane/test_vision_checkpoint_runtime.py index c5b0ade003..14cae278e9 100644 --- a/tests/control_plane/test_vision_checkpoint_runtime.py +++ b/tests/control_plane/test_vision_checkpoint_runtime.py @@ -120,6 +120,7 @@ def call(method: str, params: dict[str, object]) -> dict[str, object]: "todo_id": "todo_current001", "completion_todo_id": None, "autonomous_replan_recorded": False, + "blocked_retry": None, } diff --git a/tests/test_local_state_migration.py b/tests/test_local_state_migration.py new file mode 100644 index 0000000000..474ac869db --- /dev/null +++ b/tests/test_local_state_migration.py @@ -0,0 +1,1015 @@ +from __future__ import annotations + +import hashlib +import json +import os +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx import paths +from loopx.control_plane.projects.registry_codec import ( + ProjectRegistryProtocolError, + load_project_registry, +) +from loopx.local_state_migration import ( + RECEIPT_NAME, + migrate_local_state, + rollback_local_state_migration, +) +from loopx.project_prompt import build_new_project_prompt + + +def _write_json(path: Path, payload: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload) + "\n", encoding="utf-8") + + +def _fixture(tmp_path: Path, *, projects: int = 2) -> tuple[Path, Path, list[Path]]: + source = tmp_path / "home" / ".codex" / "loopx" + target = tmp_path / "home" / ".loopx" + global_goals = [] + project_roots = [] + for index in range(projects): + project = tmp_path / f"project-{index}" + goal_id = f"goal-{index}" + state = project / ".codex" / "goals" / goal_id / "ACTIVE_GOAL_STATE.md" + state.parent.mkdir(parents=True) + state.write_text(f"# {goal_id}\n", encoding="utf-8") + local_registry = project / ".loopx" / "registry.json" + goal = { + "id": goal_id, + "repo": str(project), + "state_file": f".codex/goals/{goal_id}/ACTIVE_GOAL_STATE.md", + } + _write_json(local_registry, {"common_runtime_root": str(source), "goals": [goal]}) + global_goals.append({**goal, "source_registry": str(local_registry)}) + run = source / "goals" / goal_id / "runs" / "run.json" + _write_json(run, {"goal_id": goal_id}) + project_roots.append(project) + _write_json(source / "registry.global.json", {"common_runtime_root": str(source), "goals": global_goals}) + return source, target, project_roots + + +def _custom_state_fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path]: + source, target, projects = _fixture(tmp_path, projects=1) + project = projects[0] + custom = project / "custom" / "STATE.md" + custom.parent.mkdir() + custom.write_text("custom state\n", encoding="utf-8") + local_registry = project / ".loopx" / "registry.json" + for path in (local_registry, source / "registry.global.json"): + payload = _read(path) + payload["goals"][0]["state_file"] = str(custom.relative_to(project)) + _write_json(path, payload) + return source, target, project, custom + + +def test_default_route_keeps_one_existing_legacy_registry(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + source, target, projects = _fixture(tmp_path, projects=1) + monkeypatch.setattr(paths, "LEGACY_RUNTIME_ROOT", source) + monkeypatch.setattr(paths, "DEFAULT_RUNTIME_ROOT", target) + assert paths.default_runtime_route()["status"] == "legacy" + assert paths.resolve_runtime_root({}) == source + assert paths.resolve_runtime_root({}, str(tmp_path / "custom")) == tmp_path / "custom" + assert paths.registered_goal_state_file(projects[0], "goal-0", _read(projects[0] / ".loopx" / "registry.json")) == projects[0] / ".codex" / "goals" / "goal-0" / "ACTIVE_GOAL_STATE.md" + assert paths.registered_goal_state_file(tmp_path / "fresh", "new") == tmp_path / "fresh" / ".loopx" / "goals" / "new" / "ACTIVE_GOAL_STATE.md" + _write_json(target / "registry.global.json", {"goals": []}) + assert paths.default_runtime_route()["status"] == "conflict" + with pytest.raises(ValueError, match="Both default LoopX registries"): + paths.resolve_runtime_root({}) + (target / "registry.global.json").unlink() + (target / "registry.global.json").mkdir() + assert paths.default_runtime_route()["status"] == "invalid" + with pytest.raises(ValueError, match="not a regular file"): + paths.resolve_runtime_root({}) + + +def test_existing_project_prompt_keeps_registered_goal_and_runtime_routes(tmp_path: Path) -> None: + source, _target, projects = _fixture(tmp_path, projects=1) + project = projects[0] + payload = build_new_project_prompt( + project=project, + goal_doc=project / "GOAL.md", + goal_id="goal-0", + objective="Continue the registered Goal", + domain="example", + adapter_kind="read_only_project_map_v0", + adapter_status="connected-read-only", + next_probe=None, + spawn_allowed=False, + allowed_domains=None, + write_scope=None, + ) + assert f"--runtime-root {source}" in payload["quota_guard_command"] + assert f"--runtime-root {source}" in payload["quota_spend_command"] + assert f"--runtime-root {source}" in payload["connect_command"] + assert ".codex/goals/goal-0/ACTIVE_GOAL_STATE.md" in payload["prompt"] + + +def test_project_prompt_rejects_lifecycle_only_registry(tmp_path: Path) -> None: + _source, _target, projects = _fixture(tmp_path, projects=1) + project = projects[0] + registry_path = project / ".loopx" / "registry.json" + registry = _read(registry_path) + registry["profile_id"] = "source_session_v1" + _write_json(registry_path, registry) + + with pytest.raises(ProjectRegistryProtocolError, match="lifecycle-only profile"): + build_new_project_prompt( + project=project, + goal_doc=project / "GOAL.md", + goal_id="goal-0", + objective="Continue the registered Goal", + domain="example", + adapter_kind="read_only_project_map_v0", + adapter_status="connected-read-only", + next_probe=None, + spawn_allowed=False, + allowed_domains=None, + write_scope=None, + ) + + +def test_new_default_route_rejects_orphaned_legacy_state(tmp_path: Path) -> None: + project = tmp_path / "project" + legacy = paths.legacy_goal_state_file(project, "goal-one") + legacy.parent.mkdir(parents=True) + legacy.write_text("old authority\n", encoding="utf-8") + with pytest.raises(ValueError, match="legacy Goal state exists"): + paths.require_single_goal_state_route( + project, "goal-one", paths.default_goal_state_file(project, "goal-one") + ) + result = subprocess.run( + [ + sys.executable, "-m", "loopx.cli", + "--runtime-root", str(tmp_path / "runtime"), "--format", "json", + "bootstrap", "--project", str(project), "--goal-id", "goal-one", + "--objective", "Continue the old Goal", "--dry-run", + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "legacy Goal state exists" in result.stdout + assert not paths.default_goal_state_file(project, "goal-one").exists() + + +def _read(path: Path) -> dict[str, object]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_strict_project_registry(path: Path, payload: dict[str, object]) -> None: + encoded = json.dumps( + payload, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":"), + ).encode("utf-8") + envelope = [ + {"schema_version": "loopx_project_registry_envelope_v1", + "minimum_writer_protocol": "goal_instance_v1", + "payload_sha256": "sha256:" + hashlib.sha256(encoded).hexdigest()}, + payload, + ] + path.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def test_strict_project_registry_keeps_legacy_route_and_wire_format(tmp_path: Path) -> None: + source, target, projects = _fixture(tmp_path, projects=1) + project = projects[0] + local_registry = project / ".loopx" / "registry.json" + _write_strict_project_registry(local_registry, _read(local_registry)) + before = local_registry.read_bytes() + + prompt = build_new_project_prompt( + project=project, goal_doc=project / "GOAL.md", goal_id="goal-0", + objective="Continue the registered Goal", domain="example", + adapter_kind="read_only_project_map_v0", adapter_status="connected-read-only", + next_probe=None, spawn_allowed=False, allowed_domains=None, write_scope=None, + ) + assert f"--runtime-root {source}" in prompt["quota_guard_command"] + assert ".codex/goals/goal-0/ACTIVE_GOAL_STATE.md" in prompt["prompt"] + + preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) + receipt = migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + migrated = load_project_registry(local_registry) + assert migrated["common_runtime_root"] == str(target) + assert migrated["goals"][0]["state_file"] == ".loopx/goals/goal-0/ACTIVE_GOAL_STATE.md" + assert isinstance(json.loads(local_registry.read_text(encoding="utf-8")), list) + + rollback_local_state_migration(Path(receipt["backup_dir"]) / RECEIPT_NAME, execute=True) + assert local_registry.read_bytes() == before + assert source.exists() and not target.exists() + + +def _extension_cli_result( + home: Path, *arguments: str, runtime_root: Path | None = None, +) -> subprocess.CompletedProcess[str]: + command = [sys.executable, "-m", "loopx.cli", "--format", "json"] + if runtime_root is not None: + command.extend(("--runtime-root", str(runtime_root))) + command.extend(("extension", *arguments)) + env = { + key: value for key, value in os.environ.items() + if key not in {"LOOPX_RUNTIME_ROOT", "LOOPX_REGISTRY"} + } + env["HOME"] = str(home) + return subprocess.run( + command, cwd=home, env=env, text=True, capture_output=True, check=False, + ) + + +def _extension_cli( + home: Path, *arguments: str, runtime_root: Path | None = None, +) -> dict[str, object]: + result = _extension_cli_result(home, *arguments, runtime_root=runtime_root) + assert result.returncode == 0, result.stderr or result.stdout + payload = json.loads(result.stdout) + assert payload["ok"] is True + return payload + + +def _list_extensions(home: Path, *, runtime_root: Path | None = None) -> list[dict[str, object]]: + return _extension_cli(home, "list", runtime_root=runtime_root)["extensions"] + + +def test_extension_cli_follows_legacy_execute_and_rollback_routes(tmp_path: Path) -> None: + source, target, _projects = _fixture(tmp_path, projects=1) + home = tmp_path / "home" + extension_state = { + "schema_version": "loopx_extension_state_v0", + "extensions": {"example": { + "id": "example", "enabled": True, "active_revision": "rev-1", "revisions": [], + }}, + } + _write_json(source / "extensions" / "state.json", extension_state) + assert _list_extensions(home) == _list_extensions(home, runtime_root=source) + assert _list_extensions(home)[0]["id"] == "example" + + preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) + receipt = migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert not source.exists() + assert _read(target / "extensions" / "state.json") == extension_state + assert _list_extensions(home) == _list_extensions(home, runtime_root=target) + assert _list_extensions(home)[0]["id"] == "example" + assert not (source / "extensions" / "state.json").exists() + + rollback_local_state_migration(Path(receipt["backup_dir"]) / RECEIPT_NAME, execute=True) + assert _list_extensions(home) == _list_extensions(home, runtime_root=source) + assert _list_extensions(home)[0]["id"] == "example" + assert not target.exists() + + +def test_extension_cli_uses_fresh_loopx_default(tmp_path: Path) -> None: + home = tmp_path / "fresh-home" + _write_json(home / ".loopx" / "extensions" / "state.json", { + "schema_version": "loopx_extension_state_v0", + "extensions": {"fresh": { + "id": "fresh", "enabled": True, "active_revision": "rev-1", "revisions": [], + }}, + }) + assert _list_extensions(home)[0]["id"] == "fresh" + disabled = _extension_cli(home, "disable", "fresh", "--execute") + assert disabled["changed"] is True + assert _list_extensions(home)[0]["enabled"] is False + assert _read(home / ".loopx" / "extensions" / "state.json")["extensions"]["fresh"]["enabled"] is False + assert not (home / ".codex" / "loopx").exists() + + +def test_extension_cli_conflicting_defaults_require_an_explicit_route(tmp_path: Path) -> None: + home = tmp_path / "home" + _write_json(home / ".codex" / "loopx" / "registry.global.json", {"goals": []}) + _write_json(home / ".loopx" / "registry.global.json", {"goals": []}) + _write_json(home / ".loopx" / "extensions" / "state.json", { + "schema_version": "loopx_extension_state_v0", + "extensions": {"selected": {"id": "selected", "enabled": False}}, + }) + + result = _extension_cli_result(home, "list") + assert result.returncode != 0 + assert "Both default LoopX registries exist" in result.stderr + assert _list_extensions(home, runtime_root=home / ".loopx")[0]["id"] == "selected" + + +def test_preview_execute_and_verified_rollback_cover_all_registered_projects(tmp_path: Path) -> None: + source, target, projects = _fixture(tmp_path) + before = (source / "registry.global.json").read_bytes() + preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert preview["dry_run"] is True + assert preview["project_count"] == 2 + assert preview["goal_directory_count"] == 2 + assert source.exists() and not target.exists() + assert (source / "registry.global.json").read_bytes() == before + + receipt = migrate_local_state( + source_runtime_root=source, + target_runtime_root=target, + expected_plan_id=preview["plan_id"], + execute=True, + ) + receipt_path = Path(receipt["backup_dir"]) / RECEIPT_NAME + assert receipt_path.exists() + assert not source.exists() + assert _read(target / "registry.global.json")["common_runtime_root"] == str(target) + for index, project in enumerate(projects): + goal_id = f"goal-{index}" + assert not (project / ".codex" / "goals" / goal_id).exists() + assert (project / ".loopx" / "goals" / goal_id / "ACTIVE_GOAL_STATE.md").exists() + local = _read(project / ".loopx" / "registry.json") + assert local["common_runtime_root"] == str(target) + assert local["goals"][0]["state_file"] == f".loopx/goals/{goal_id}/ACTIVE_GOAL_STATE.md" + assert paths.resolve_runtime_root(local) == target + + assert rollback_local_state_migration(receipt_path)["status"] == "rollback_ready" + assert rollback_local_state_migration(receipt_path, execute=True)["status"] == "rolled_back" + assert source.exists() and not target.exists() + assert (source / "registry.global.json").read_bytes() == before + for index, project in enumerate(projects): + assert (project / ".codex" / "goals" / f"goal-{index}" / "ACTIVE_GOAL_STATE.md").exists() + + +def test_stale_plan_and_target_conflict_leave_source_unchanged(tmp_path: Path) -> None: + source, target, projects = _fixture(tmp_path, projects=1) + preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) + state = projects[0] / ".codex" / "goals" / "goal-0" / "ACTIVE_GOAL_STATE.md" + state.write_text("changed\n", encoding="utf-8") + with pytest.raises(ValueError, match="preview changed"): + migrate_local_state(source_runtime_root=source, target_runtime_root=target, expected_plan_id=preview["plan_id"], execute=True) + assert source.exists() and not target.exists() + target.mkdir() + with pytest.raises(FileExistsError, match="target runtime root already exists"): + migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert source.exists() and state.read_text() == "changed\n" + + +def test_failed_write_restores_original_authority(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from loopx import local_state_migration as migration + + source, target, projects = _fixture(tmp_path, projects=1) + preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) + original_write = migration._write_registry + + def fail_global_target(path: Path, payload: dict[str, object]) -> None: + if path == target / "registry.global.json": + raise OSError("synthetic write failure") + original_write(path, payload) + + monkeypatch.setattr(migration, "_write_registry", fail_global_target) + with pytest.raises(RuntimeError, match="original routes were restored"): + migrate_local_state(source_runtime_root=source, target_runtime_root=target, expected_plan_id=preview["plan_id"], execute=True) + assert source.exists() and not target.exists() + assert (projects[0] / ".codex" / "goals" / "goal-0" / "ACTIVE_GOAL_STATE.md").exists() + assert _read(source / "registry.global.json")["common_runtime_root"] == str(source) + assert _read(projects[0] / ".loopx" / "registry.json")["common_runtime_root"] == str(source) + + +def test_symlink_and_changed_target_block_unsafe_migration_or_rollback(tmp_path: Path) -> None: + source, target, _projects = _fixture(tmp_path, projects=1) + link = source / "linked-state" + link.symlink_to(source / "goals", target_is_directory=True) + with pytest.raises(ValueError, match="contains a symlink"): + migrate_local_state(source_runtime_root=source, target_runtime_root=target) + link.unlink() + preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) + receipt = migrate_local_state( + source_runtime_root=source, + target_runtime_root=target, + expected_plan_id=preview["plan_id"], + execute=True, + ) + (target / "goals" / "goal-0" / "runs" / "run.json").write_text("new run\n", encoding="utf-8") + with pytest.raises(ValueError, match="automatic rollback is unsafe"): + rollback_local_state_migration(Path(receipt["backup_dir"]) / RECEIPT_NAME, execute=True) + assert target.exists() and not source.exists() + + +def test_symlinked_goal_destination_ancestor_never_writes_outside_project( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, projects = _fixture(tmp_path, projects=1) + goal_parent = projects[0] / ".loopx" / "goals" + outside = tmp_path / "outside" + outside.mkdir() + goal_parent.symlink_to(outside, target_is_directory=True) + with pytest.raises(ValueError, match="symlink"): + migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert source.exists() and not target.exists() + assert list(outside.iterdir()) == [] + + goal_parent.unlink() + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + goal_parent.symlink_to(outside, target_is_directory=True) + with pytest.raises(ValueError, match="symlink"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert source.exists() and not target.exists() + assert list(outside.iterdir()) == [] + + goal_parent.unlink() + original_copy = migration._copy + injected = False + + def inject_after_backup(original: Path, copied: Path) -> None: + nonlocal injected + original_copy(original, copied) + if not injected: + goal_parent.symlink_to(outside, target_is_directory=True) + injected = True + + monkeypatch.setattr(migration, "_copy", inject_after_backup) + with pytest.raises(RuntimeError, match="original routes were restored"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert source.exists() and not target.exists() + assert list(outside.iterdir()) == [] + + +def test_goal_destination_uses_shared_redirect_classifier( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, projects = _fixture(tmp_path, projects=1) + goal_parent = projects[0] / ".loopx" / "goals" + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + original = migration._is_redirected_path + monkeypatch.setattr( + migration, "_is_redirected_path", + lambda path: path == goal_parent or original(path), + ) + + with pytest.raises(ValueError, match="symlink or junction"): + migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + with pytest.raises(ValueError, match="symlink or junction"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert source.exists() and not target.exists() + + +def test_goal_source_uses_shared_redirect_classifier( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, projects = _fixture(tmp_path, projects=1) + goal_root = projects[0] / ".codex" / "goals" + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + original = migration._is_redirected_path + monkeypatch.setattr( + migration, "_is_redirected_path", + lambda path: path == goal_root or original(path), + ) + + with pytest.raises(ValueError, match="legacy Goal source.*symlink or junction"): + migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + with pytest.raises(ValueError, match="legacy Goal source.*symlink or junction"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert source.exists() and not target.exists() + + +def test_goal_source_is_rechecked_immediately_before_rename( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, projects = _fixture(tmp_path, projects=1) + project = projects[0] + goal_root = project / ".codex" / "goals" + target_parent = project / ".loopx" / "goals" + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + original_redirect = migration._is_redirected_path + original_mkdir = Path.mkdir + redirected = False + + def classify(path: Path) -> bool: + return (redirected and path == goal_root) or original_redirect(path) + + def inject_after_target_parent( + self: Path, mode: int = 0o777, parents: bool = False, exist_ok: bool = False, + ) -> None: + nonlocal redirected + original_mkdir(self, mode=mode, parents=parents, exist_ok=exist_ok) + if self == target_parent: + redirected = True + + monkeypatch.setattr(migration, "_is_redirected_path", classify) + monkeypatch.setattr(Path, "mkdir", inject_after_target_parent) + with pytest.raises(RuntimeError, match="original routes were restored"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert redirected + assert source.exists() and not target.exists() + + +def test_goal_source_is_rechecked_before_backup_copy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, projects = _fixture(tmp_path, projects=1) + goal_root = projects[0] / ".codex" / "goals" + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + backup = Path(preview["backup_dir"]) + original_redirect = migration._is_redirected_path + original_mkdir = Path.mkdir + redirected = False + + def classify(path: Path) -> bool: + return (redirected and path == goal_root) or original_redirect(path) + + def inject_after_backup_dir( + self: Path, mode: int = 0o777, parents: bool = False, exist_ok: bool = False, + ) -> None: + nonlocal redirected + original_mkdir(self, mode=mode, parents=parents, exist_ok=exist_ok) + if self == backup: + redirected = True + + monkeypatch.setattr(migration, "_is_redirected_path", classify) + monkeypatch.setattr(Path, "mkdir", inject_after_backup_dir) + with pytest.raises(ValueError, match="legacy Goal source.*symlink or junction"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert redirected + assert source.exists() and not target.exists() + + +@pytest.mark.parametrize("redirected_route", ["source_parent", "target_parent"]) +def test_runtime_root_rejects_redirected_ancestors_before_preview( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, redirected_route: str, +) -> None: + from loopx import local_state_migration as migration + + source, default_target, _projects = _fixture(tmp_path, projects=1) + target = default_target if redirected_route == "source_parent" else tmp_path / "target-home" / ".loopx" + redirected = source.parent if redirected_route == "source_parent" else target.parent + original = migration._is_redirected_path + monkeypatch.setattr( + migration, "_is_redirected_path", + lambda path: path == redirected or original(path), + ) + + with pytest.raises(ValueError, match="symlink or junction"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + backup_dir=tmp_path / "safe-backup", + ) + assert source.exists() and not target.exists() + + +def test_runtime_digest_rejects_redirected_descendant( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, _projects = _fixture(tmp_path, projects=1) + redirected = source / "goals" / "goal-0" / "runs" + original = migration._is_redirected_path + monkeypatch.setattr( + migration, "_is_redirected_path", + lambda path: path == redirected or original(path), + ) + + with pytest.raises(ValueError, match="migration source contains a symlink or junction"): + migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert source.exists() and not target.exists() + + +@pytest.mark.parametrize("redirected_route", ["legacy_goal_parent", "backup_snapshot"]) +def test_rollback_preview_rejects_redirected_routes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, redirected_route: str, +) -> None: + from loopx import local_state_migration as migration + + source, target, projects = _fixture(tmp_path, projects=1) + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + receipt = migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + backup = Path(receipt["backup_dir"]) + redirected = ( + projects[0] / ".codex" / "goals" + if redirected_route == "legacy_goal_parent" else backup / "snapshot" + ) + original = migration._is_redirected_path + monkeypatch.setattr( + migration, "_is_redirected_path", + lambda path: path == redirected or original(path), + ) + + with pytest.raises(ValueError, match="symlink or junction"): + migration.rollback_local_state_migration(backup / RECEIPT_NAME) + assert target.exists() and not source.exists() + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows junction regression") +def test_windows_junction_goal_source_never_reads_or_moves_outside_project( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, projects = _fixture(tmp_path, projects=1) + goal_root = projects[0] / ".codex" / "goals" + outside = tmp_path / "outside" + outside.mkdir() + external_root = outside / "goals" + goal_root.rename(external_root) + external_state = external_root / "goal-0" / "ACTIVE_GOAL_STATE.md" + original_bytes = external_state.read_bytes() + original_digest = migration._digest + original_copy = migration._copy + + def reject_external_read(path: Path) -> str: + resolved = path.resolve() + if resolved == external_root or external_root in resolved.parents: + raise AssertionError("migration read external Goal source bytes") + return original_digest(path) + + def reject_external_copy(original: Path, copied: Path) -> None: + resolved = original.resolve() + if resolved == external_root or external_root in resolved.parents: + raise AssertionError("migration copied external Goal source bytes") + original_copy(original, copied) + + monkeypatch.setattr(migration, "_digest", reject_external_read) + monkeypatch.setattr(migration, "_copy", reject_external_copy) + + def make_junction() -> None: + subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(goal_root), str(external_root)], + check=True, capture_output=True, text=True, + ) + assert goal_root.lstat().st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT + + make_junction() + try: + with pytest.raises(ValueError, match="legacy Goal source.*symlink or junction"): + migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert source.exists() and not target.exists() + assert external_state.read_bytes() == original_bytes + finally: + goal_root.rmdir() + + external_root.rename(goal_root) + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + goal_root.rename(external_root) + make_junction() + try: + with pytest.raises(ValueError, match="legacy Goal source.*symlink or junction"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert source.exists() and not target.exists() + assert external_state.read_bytes() == original_bytes + finally: + goal_root.rmdir() + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows junction regression") +def test_windows_junction_rollback_destination_never_writes_outside_project( + tmp_path: Path, +) -> None: + source, target, projects = _fixture(tmp_path, projects=1) + preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) + receipt = migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + legacy_parent = projects[0] / ".codex" / "goals" + legacy_parent.rmdir() + outside = tmp_path / "outside" + outside.mkdir() + subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(legacy_parent), str(outside)], + check=True, capture_output=True, text=True, + ) + assert legacy_parent.lstat().st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT + try: + with pytest.raises(ValueError, match="migration rollback destination.*symlink or junction"): + rollback_local_state_migration(Path(receipt["backup_dir"]) / RECEIPT_NAME) + assert target.exists() and not source.exists() + assert list(outside.iterdir()) == [] + finally: + legacy_parent.rmdir() + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows junction regression") +def test_windows_junction_goal_destination_never_writes_outside_project( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, projects = _fixture(tmp_path, projects=1) + goal_parent = projects[0] / ".loopx" / "goals" + outside = tmp_path / "outside" + outside.mkdir() + + def make_junction() -> None: + subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(goal_parent), str(outside)], + check=True, capture_output=True, text=True, + ) + assert goal_parent.lstat().st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT + + make_junction() + try: + with pytest.raises(ValueError, match="symlink or junction"): + migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert source.exists() and not target.exists() + assert list(outside.iterdir()) == [] + finally: + goal_parent.rmdir() + + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + make_junction() + try: + with pytest.raises(ValueError, match="symlink or junction"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert source.exists() and not target.exists() + assert list(outside.iterdir()) == [] + finally: + goal_parent.rmdir() + + original_copy = migration._copy + injected = False + + def inject_after_backup(original: Path, copied: Path) -> None: + nonlocal injected + original_copy(original, copied) + if not injected: + make_junction() + injected = True + + monkeypatch.setattr(migration, "_copy", inject_after_backup) + try: + with pytest.raises(RuntimeError, match="original routes were restored"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert source.exists() and not target.exists() + assert list(outside.iterdir()) == [] + finally: + if goal_parent.exists(): + goal_parent.rmdir() + + +@pytest.mark.parametrize("explicit_backup", [False, True]) +def test_symlinked_backup_parent_is_rejected_before_preview_or_copy( + tmp_path: Path, explicit_backup: bool, +) -> None: + source, target, _projects = _fixture(tmp_path, projects=1) + outside = tmp_path / "outside" + outside.mkdir() + backup_parent = ( + tmp_path / "explicit-backups" + if explicit_backup else source.parent / "loopx-local-state-backups" + ) + backup_parent.symlink_to(outside, target_is_directory=True) + backup_dir = backup_parent / "receipt" if explicit_backup else None + + with pytest.raises(ValueError, match="backup.*symlink"): + migrate_local_state( + source_runtime_root=source, + target_runtime_root=target, + backup_dir=backup_dir, + ) + assert list(outside.iterdir()) == [] + assert source.exists() and not target.exists() + + +def test_backup_parent_redirected_after_plan_cannot_write_outside( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, _projects = _fixture(tmp_path, projects=1) + outside = tmp_path / "outside" + outside.mkdir() + preview = migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + ) + original_plan = migration.plan_local_state_migration + + def plan_then_redirect(**kwargs: object) -> dict[str, object]: + plan = original_plan(**kwargs) + (source.parent / "loopx-local-state-backups").symlink_to( + outside, target_is_directory=True, + ) + return plan + + monkeypatch.setattr(migration, "plan_local_state_migration", plan_then_redirect) + with pytest.raises(ValueError, match="backup.*symlink"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert list(outside.iterdir()) == [] + assert source.exists() and not target.exists() + + +def test_backup_snapshot_parent_changed_before_copy_cannot_write_outside( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, _projects = _fixture(tmp_path, projects=1) + outside = tmp_path / "outside" + outside.mkdir() + preview = migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + ) + backup = Path(preview["backup_dir"]) + original_copy = migration._copy + calls = 0 + + def redirect_before_second_copy(original: Path, copied: Path) -> None: + nonlocal calls + calls += 1 + if calls == 2: + snapshot = backup / "snapshot" + snapshot.rename(backup / "snapshot-before-link") + snapshot.symlink_to(outside, target_is_directory=True) + original_copy(original, copied) + + monkeypatch.setattr(migration, "_copy", redirect_before_second_copy) + with pytest.raises(ValueError, match="backup.*symlink"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert list(outside.iterdir()) == [] + assert source.exists() and not target.exists() + + +def test_explicit_real_backup_path_supports_execute_and_rollback(tmp_path: Path) -> None: + source, target, _projects = _fixture(tmp_path, projects=1) + backup = tmp_path / "private-backups" / "receipt" + preview = migrate_local_state( + source_runtime_root=source, target_runtime_root=target, backup_dir=backup, + ) + receipt = migrate_local_state( + source_runtime_root=source, target_runtime_root=target, backup_dir=backup, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert receipt["backup_dir"] == str(backup) + assert (backup / RECEIPT_NAME).is_file() + assert target.exists() and not source.exists() + rollback_local_state_migration(backup / RECEIPT_NAME, execute=True) + assert source.exists() and not target.exists() + + +def test_custom_state_keeps_its_declared_file_through_migration_and_rollback( + tmp_path: Path, +) -> None: + source, target, project, custom = _custom_state_fixture(tmp_path) + local_registry = project / ".loopx" / "registry.json" + before = local_registry.read_bytes() + preview = migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert preview["goal_directory_count"] == 0 + receipt = migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + migrated = load_project_registry(local_registry) + assert migrated["common_runtime_root"] == str(target) + assert migrated["goals"][0]["state_file"] == "custom/STATE.md" + assert custom.read_text(encoding="utf-8") == "custom state\n" + rollback_local_state_migration(Path(receipt["backup_dir"]) / RECEIPT_NAME, execute=True) + assert local_registry.read_bytes() == before + assert custom.read_text(encoding="utf-8") == "custom state\n" + assert source.exists() and not target.exists() + + +def test_custom_state_cannot_bypass_project_registry_ancestor_fence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, project, _custom = _custom_state_fixture(tmp_path) + local_registry = project / ".loopx" / "registry.json" + + outside = tmp_path / "outside" + outside.mkdir() + outside_registry = outside / "registry.json" + outside_registry.write_bytes(local_registry.read_bytes()) + original_outside = outside_registry.read_bytes() + project_loopx = project / ".loopx" + parked = project / ".loopx-original" + + project_loopx.rename(parked) + project_loopx.symlink_to(outside, target_is_directory=True) + with pytest.raises(ValueError, match="project registry.*symlink"): + migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert outside_registry.read_bytes() == original_outside + assert list(outside.iterdir()) == [outside_registry] + assert source.exists() and not target.exists() + + project_loopx.unlink() + parked.rename(project_loopx) + preview = migration.migrate_local_state(source_runtime_root=source, target_runtime_root=target) + assert preview["goal_directory_count"] == 0 + original_plan = migration.plan_local_state_migration + + def redirect_after_plan(**kwargs: object) -> dict[str, object]: + plan = original_plan(**kwargs) + project_loopx.rename(parked) + project_loopx.symlink_to(outside, target_is_directory=True) + return plan + + monkeypatch.setattr(migration, "plan_local_state_migration", redirect_after_plan) + with pytest.raises(ValueError, match="project registry.*symlink"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert outside_registry.read_bytes() == original_outside + assert list(outside.iterdir()) == [outside_registry] + assert source.exists() and not target.exists() + + +def test_project_registry_redirect_after_backup_cannot_write_outside( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import local_state_migration as migration + + source, target, project, _custom = _custom_state_fixture(tmp_path) + local_registry = project / ".loopx" / "registry.json" + outside = tmp_path / "outside" + outside.mkdir() + outside_registry = outside / "registry.json" + outside_registry.write_bytes(local_registry.read_bytes()) + original_outside = outside_registry.read_bytes() + preview = migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + ) + original_copy = migration._copy + + def redirect_after_registry_copy(original: Path, copied: Path) -> None: + original_copy(original, copied) + if original == local_registry: + (project / ".loopx").rename(project / ".loopx-original") + (project / ".loopx").symlink_to(outside, target_is_directory=True) + + monkeypatch.setattr(migration, "_copy", redirect_after_registry_copy) + with pytest.raises(RuntimeError, match="project registry.*symlink"): + migration.migrate_local_state( + source_runtime_root=source, target_runtime_root=target, + expected_plan_id=preview["plan_id"], execute=True, + ) + assert outside_registry.read_bytes() == original_outside + assert list(outside.iterdir()) == [outside_registry] + assert source.exists() and not target.exists() + + +def test_cli_preview_execute_and_rollback_readback(tmp_path: Path) -> None: + source, target, projects = _fixture(tmp_path, projects=1) + + def invoke(*args: str) -> dict[str, object]: + result = subprocess.run( + [sys.executable, "-m", "loopx.cli", "--format", "json", "migrate-local-state", *args], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + return json.loads(result.stdout) + + paths_args = ("--source-runtime-root", str(source), "--target-runtime-root", str(target)) + preview = invoke(*paths_args) + assert preview["dry_run"] is True + receipt = invoke(*paths_args, "--execute", "--expected-plan-id", str(preview["plan_id"])) + assert receipt["status"] == "migrated" + assert _read(projects[0] / ".loopx" / "registry.json")["common_runtime_root"] == str(target) + receipt_path = str(Path(str(receipt["backup_dir"])) / RECEIPT_NAME) + assert invoke("--rollback-receipt", receipt_path)["status"] == "rollback_ready" + assert invoke("--rollback-receipt", receipt_path, "--execute")["status"] == "rolled_back" + assert source.exists() and not target.exists() diff --git a/tests/test_manager_ssh_evidence.py b/tests/test_manager_ssh_evidence.py index 10b2b1b805..46d81db5e1 100644 --- a/tests/test_manager_ssh_evidence.py +++ b/tests/test_manager_ssh_evidence.py @@ -82,7 +82,7 @@ def test_remote_source_is_discovered_and_read_without_local_host_metadata(remote argv, opts = calls[0] assert ( argv[-2] == "research-host" - and '"$HOME/.codex/loopx/registry.global.json"' in argv[-1] + and "--registry" not in argv[-1] ) assert "--manager-view todos" in argv[-1] and "--goal-id remote-goal" in argv[-1] assert "BatchMode=yes" in argv and opts["timeout"] == 45 @@ -234,7 +234,7 @@ def test_ssh_wire_arguments_execute_real_remote_cli_projection(remote, tmp_path) def execute_cli(argv, **kwargs): arguments = shlex.split(argv[-1].split("--format json ", 1)[1]) - return subprocess.run( + completed = subprocess.run( [ sys.executable, "-m", @@ -249,12 +249,14 @@ def execute_cli(argv, **kwargs): ], **kwargs, ) + assert completed.returncode == 0, completed.stderr + return completed tool.remote_runner = execute_cli result = tool.read( TOOL_NAME, {"view": "portfolio", "source_id": "ssh:research-host", "limit": 3} ) - assert result["ok"] + assert result["ok"], result assert result["rows"][0]["goal_id"] == "remote-goal" assert result["rows"][0]["source_host"] == "research-host" assert result["rows"][0]["quality"] != "verified" diff --git a/tests/test_ssh_tunnel_source.py b/tests/test_ssh_tunnel_source.py index f317a668ae..95c22fb3cf 100644 --- a/tests/test_ssh_tunnel_source.py +++ b/tests/test_ssh_tunnel_source.py @@ -179,7 +179,7 @@ def test_apply_ssh_goal_lifecycle_uses_remote_typed_contract_without_local_fallb assert argv[:4] == ["ssh", "-o", "ConnectTimeout=5", "ark-devbox"] assert "goal-lifecycle" in argv[4] assert "--actor-kind owner" in argv[4] - assert '"$HOME/.codex/loopx/registry.global.json"' in argv[4] + assert "--registry" not in argv[4] assert result == { "ok": True, "schema_version": "loopx_remote_goal_lifecycle_v1", diff --git a/tests/test_turn_default_host_binding.py b/tests/test_turn_default_host_binding.py index 5541e93691..1dad0086e5 100644 --- a/tests/test_turn_default_host_binding.py +++ b/tests/test_turn_default_host_binding.py @@ -22,7 +22,7 @@ @pytest.fixture(autouse=True) def isolated_machine_credential_store(tmp_path, monkeypatch): """CLI default tests must not read the developer machine's provider store.""" - monkeypatch.setattr(operator_provider, "DEFAULT_RUNTIME_ROOT", tmp_path / "machine") + monkeypatch.setattr(operator_provider, "select_default_runtime_root", lambda: tmp_path / "machine") def test_managed_credential_selects_the_managed_default_host(): diff --git a/tests/test_turn_machine_credential.py b/tests/test_turn_machine_credential.py index 36722d9f55..21b585b546 100644 --- a/tests/test_turn_machine_credential.py +++ b/tests/test_turn_machine_credential.py @@ -9,6 +9,7 @@ from loopx.cli import main from loopx.control_plane import operator_provider as provider +from loopx import paths from loopx.control_plane.collaboration.delegation_context import project_delegation_context from loopx.control_plane.turn_driver import host_binding from loopx.dsh_goal_mode import turn_host_adapter @@ -22,7 +23,8 @@ @pytest.fixture def machine(tmp_path, monkeypatch): root = tmp_path / "machine-owner" - monkeypatch.setattr(provider, "DEFAULT_RUNTIME_ROOT", root) + monkeypatch.setattr(paths, "DEFAULT_RUNTIME_ROOT", root) + monkeypatch.setattr(paths, "LEGACY_RUNTIME_ROOT", tmp_path / "absent-legacy-runtime") for name in ("DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL", "LOOPX_TURN_HOST"): monkeypatch.delenv(name, raising=False) # SDK availability is independent of credential ownership; no model is called. diff --git a/tests/test_windows_install.py b/tests/test_windows_install.py index 774c3abd50..a666d892c0 100644 --- a/tests/test_windows_install.py +++ b/tests/test_windows_install.py @@ -244,10 +244,11 @@ def test_windows_installer_promotes_release_and_runs_doctor(tmp_path: Path) -> N env=launch_env, ) assert todo.returncode == 0, todo.stderr - state_file = project / ".codex" / "goals" / "windows-probe" / "ACTIVE_GOAL_STATE.md" + state_file = project / ".loopx" / "goals" / "windows-probe" / "ACTIVE_GOAL_STATE.md" assert "[P0] Validate Windows PowerShell state writeback" in state_file.read_text( encoding="utf-8" ) + assert not (project / ".codex" / "goals" / "windows-probe").exists() quota = _run_loopx( pwsh=pwsh,