From 37a3bc69fc1ff09e6fc372320bafd29d4d867e2f Mon Sep 17 00:00:00 2001 From: Michael Beutler Date: Mon, 5 Oct 2026 10:26:31 -0400 Subject: [PATCH 1/6] feat: report managed component updates and clean agent threads --- MANIFEST.sha256 | 11 +- README.md | 20 ++ .../references/coordination.md | 9 +- ...cpk-061-component-updates-agent-cleanup.md | 148 ++++++++++ docs/roadmap.md | 1 + kit.py | 95 +++++++ tests/test_kit.py | 256 ++++++++++++++++++ 7 files changed, 534 insertions(+), 6 deletions(-) create mode 100644 docs/plans/cpk-061-component-updates-agent-cleanup.md diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index ea813e3..edf1ff0 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -23,7 +23,7 @@ d60f352d0db1404c70afb4bb8b2ca3fd1c610572aa40720e8a0b7baa7885418c ./.gitattribut 6d7a25701ba2532525fe556f1a55529ab09af6675522f749afb22609f7f8183e ./CODEX-INSTALL-PROMPT.md 6612129aca01053f15098204fb4bbaafdf85a667956202530bdcbc339e58e462 ./LICENSE e4912f4f8d644555dcd57d5e336a249cf8a9d7f6222b78096d6b403f45a8a215 ./PLAYGROUND.md -964bddcb73a1cd9492eaf0340987a5f33aa57cad53f4ca62e4c73e984f82fa3f ./README.md +70489ebf898ac49654b09d398e4c1c528ed6cadb7b23b5f49c5fba7859324398 ./README.md 979191eeef7975567b4c44c9fb6dc38bf78e431eda582ff55d4f37eb2f69a441 ./THIRD_PARTY.md 59b191889c8b7d098ddaf6b029f6f12b3ce6590893e1823f4448ad3b90c03fb4 ./assets/AGENTS.block.md b9f9eb58967f54b19fb3a58b1db32e4c25299ae10c06e2e45a6611c250801a71 ./assets/hooks/session_start.py @@ -38,7 +38,7 @@ a564a3b00a86f43d38318b76168593bc965b2f1decf67a328d499a054aa35f36 ./assets/skill baa988b652f856bce1a4e183bf327879a021896eb65617b2fd4903e34a461339 ./assets/skills/defect-diagnostic/agents/openai.yaml f98f3661b7b4cfbe6a1b061e075898ae21b65cbb6765abec76b332b03cab2f9b ./assets/skills/defect-diagnostic/references/diagnostic-summary.md 20fb4b743b3ed14fa58ae1710bf5c621eb1f0b20dad552212221594368fa2d4c ./assets/skills/delivery-lifecycle/SKILL.md -9320e46907d34dd905159c75abba4b37bd7ce990f30a023d6b80c4715d6a0196 ./assets/skills/delivery-lifecycle/references/coordination.md +a149cc13be4525d74bab07edca41b3d84770c9aa924c3ae037a5c808c248dfb4 ./assets/skills/delivery-lifecycle/references/coordination.md b24f6d4c5d6ba9d3a4ab4215eac302764e8dcb0fe2c6dc76789b3ffe77a8e21f ./assets/skills/delivery-lifecycle/references/decision-handoffs.md d9d882f331eaed467f783b899073d992b9f9872927f76ce8c7a2069295a8a080 ./assets/skills/delivery-lifecycle/references/delivery-lifecycle.md 2536a374b63658e5e0bbca17d1a9ed25a41b61eeb539702cc8ee7c6f41bf1a31 ./assets/skills/delivery-lifecycle/references/git-isolation.md @@ -125,12 +125,13 @@ a5d8013da3d22afba28c1259e29e3255d1d19969d0934b2f59069f0de80ea31b ./docs/plans/c 5f2abf3bc85fbfc580d05139165ff95f29a3a85246bab2724c5f949eb93f26a5 ./docs/plans/cpk-058-plan-history-compaction.md 43141b61bf800dc9956603b6e88cdd0c3a5564340d3f4aee3fb6b0682dceee00 ./docs/plans/cpk-059-public-pr-release.md 8cea5185f3a1181dc9a1419f3f980712b2b9e35936ec2a8011629d67cd0d041f ./docs/plans/cpk-060-windows-codex-resolution.md -23106fd9f30090fc85692c14612b95f69be5b8d2757137a3df19ed6093d2af0b ./docs/roadmap.md +ccc9e20f5cd288592bde3dc81fd49820e9f7d62ef2274a99af8ac9781d5d3f5c ./docs/plans/cpk-061-component-updates-agent-cleanup.md +fe6379868ba2232d54e1bba3fcab4dd25e3594aa2d262c39524953f767b0c109 ./docs/roadmap.md 6f8f14ca5fd8ad935eee66256b3f7c49d5c40957eedff938ba21365e30869719 ./doctor.ps1 ab56a88d9188979f06ea142d1f58ea0d6ad9088e83a965bd324209d83da513ad ./doctor.sh 8087bdb3ee654c0baac278a8e3bf85e33e314f8acfd486e7ebf675126a697058 ./install.ps1 3466d2e34145cfdaa83d793960794b2aefc96f3d9c48d22fd2af930ec819e9c4 ./install.sh -7c4238f798f567de9b7de28268b5fd344a861ce8fff1d16da7f2e2966908f8f5 ./kit.py +5469fe020aad5b51e376c4c15ef44f214b29f859529bb797aa4f23748b9726de ./kit.py 331f17ca1486b5779d9b57a3a73834328eb3089ce612e636e6a8e5085899d61e ./run-tests.ps1 e6df9f1da02280a66161583529b06a34f0103041e9587afad0fac6074eecc15b ./run-tests.sh 85a2ba72e2b0cd1ab6c25a51f39059502415c3ac62a6bffa613d7e31b6422cd4 ./setup-repo.ps1 @@ -141,7 +142,7 @@ e1e231aac51822f7e1bb3aa61aba138cf8c5df9cefd3079b0c1b5a98eea03bd9 ./tests/fixtur a269775204ae7cad2457d00f5565c90da2df41a9719479f3489ec01afdd2519c ./tests/fixtures/cpk056-agent-cases.json e32941eb74be1f2cdcb6c82cf71e113b841651bf0d01786f364176de2da1b9c5 ./tests/fixtures/cpk056-agent-grading.json 289367b7090a0d71936b41f08c4a354bc2758013dceb709819de36a1b73d9aa6 ./tests/live_plan_history.py -9b6d3048643461026467186632f3de49fbc533aaa8a91de46b699c67cfe7bad3 ./tests/test_kit.py +a1f37a4b26f13c014da797245895547a2dc64e7e534834507eb0ecad88a4d466 ./tests/test_kit.py 1979109d44a6b49c01747216c99dc6598e5cc725b986df4eea60215e4598f4d8 ./tests/test_ponytail.py d16f7eadc8c668e82a1d0d2554ed04def15cb0a4b2ce13ac1ce1339bc0e62161 ./uninstall.ps1 f12cff94ef7f9b0ca489cbe43e1de737011476966e04c1578f4977177b0da217 ./uninstall.sh diff --git a/README.md b/README.md index c1e4434..8121759 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,26 @@ The installer writes a managed block in `~/.codex/AGENTS.md`. It renders the abs The installer also writes `$CODEX_HOME/PLANS.md` and managed Codex hooks. Session Start announces the AGENTS router. +## Check managed component updates + +On macOS or Linux, run this read-only command from the source checkout: + +```bash +python3 kit.py check-updates +``` + +On Windows, run: + +```powershell +python kit.py check-updates +``` + +The command reads authoritative upstream sources for all managed runtime tools and bundled skills. It excludes optional tools and does not change installations, plugins, pins, or configuration. + +The report compares toolkit version and revision pins with upstream identities. Ponytail has no toolkit pin, so the report identifies its upstream release and Codex-owned lifecycle. + +A complete report returns status 0, including when upstream differs. A request error returns status 2 and prints no partial report. + ## Global and repository instructions The installer manages `~/.codex/AGENTS.md` and `$CODEX_HOME/PLANS.md`. These files apply to every repository unless a nearer repository file overrides them. diff --git a/assets/skills/delivery-lifecycle/references/coordination.md b/assets/skills/delivery-lifecycle/references/coordination.md index fe5b82a..a98f666 100644 --- a/assets/skills/delivery-lifecycle/references/coordination.md +++ b/assets/skills/delivery-lifecycle/references/coordination.md @@ -2,6 +2,9 @@ + + + @@ -17,7 +20,11 @@ The coordinator can run at most one write-capable implementation subagent at a t While an implementation subagent writes, the coordinator performs read-only work. -The coordinator closes each subagent after it reports `complete` or `blocked`. +A stopped turn does not finish an assignment when a required follow-up remains. Keep the same agent available until the complete assignment reports `complete` or `blocked`. + +Capture each terminal delegated result. Then call the exposed native `close_agent` operation for that exact agent. Close finished agent threads before spending another agent slot. Close only threads whose results you already captured. + +Archive only an explicitly targeted finished agent subtree. Closure releases open-agent capacity. Archival cleans persisted thread history and does not prove that closure occurred. If the platform exposes native thread archival, specify the finished thread identifier after closure. Do not use a default target that can select the coordinator. Archiving a parent can also archive its descendants, so wait until every descendant has finished and owes no required follow-up. Do not delete session files or edit Codex state directly. Elapsed time alone cannot authorize a duplicate or restart of an active delegated task. Keep and poll the same task until it completes, reports blocked, or the platform reports that it stopped without coordinator action. diff --git a/docs/plans/cpk-061-component-updates-agent-cleanup.md b/docs/plans/cpk-061-component-updates-agent-cleanup.md new file mode 100644 index 0000000..cefbfa3 --- /dev/null +++ b/docs/plans/cpk-061-component-updates-agent-cleanup.md @@ -0,0 +1,148 @@ +# Report managed component updates and clean finished agent threads + +This ExecPlan is a living specification until review closure. Maintain it in accordance with [`.agent/PLANS.md`](../../.agent/PLANS.md). + +## Purpose / Big Picture + +Users can run `python3 kit.py check-updates` to compare each managed component with its authoritative upstream source. The command reports availability and does not update files, installations, plugins, or configuration. + +Codex coordinators also receive precise cleanup instructions. They preserve required follow-ups, capture each terminal delegated result, close that exact agent thread, and archive only finished history subtrees. + +## Decision Log + +- Decision: Add an explicit `check-updates` command instead of a startup, install, or Doctor check. + Rationale: Network availability must not change deterministic setup and health operations. +- Decision: Treat `upstream.lock.json` sections `runtime_tools` and `skills` as the complete managed inventory. + Rationale: The same lock owns toolkit pins and bundled skill revisions. Its `optional_tools` section is explicitly outside the installed kit. +- Decision: Compare authoritative identities without version ordering. + Rationale: Equality can show that upstream differs. It cannot prove compatibility, safety, or that a release is chronologically newer. +- Decision: Use PyPI project JSON for RepoWise, GitHub latest full releases for uv and Ponytail, and GitHub default-branch heads for bundled skills. + Rationale: These endpoints match each component's pinned identity type. +- Decision: Buffer every result before output and stop after the first request error. + Rationale: A failed scan must not print a partial report that looks complete. The command does not retry. +- Decision: Native agent closure releases an open-thread slot. Native archival is a separate history operation. + Rationale: Archival is not proof that Codex released agent capacity. +- Decision: No Plan History record applies to CPK-061. + Rationale: Existing records describe earlier tasks. No prior Plan Mode specification defines this outcome. +- Decision: Use branch `cpk-061-component-updates-agent-cleanup` from `main` commit `00f3af7e9e91fdb7ddd31c529863c616522cdd82` in the primary checkout. + Rationale: This task has one writable implementation stream. + +## Context and Orientation + +`kit.py` is the Python command-line entry point. `build_parser` defines commands, `main` dispatches them, and `load_lock` reads `upstream.lock.json`. + +`upstream.lock.json` contains three managed runtime tools. RepoWise and uv have version pins. Ponytail is a Codex-owned plugin without a toolkit pin. The lock also contains two bundled skills with exact commit pins. + +`assets/skills/delivery-lifecycle/references/coordination.md` is the only live owner of coordinator and subagent lifecycle rules. Do not copy these rules into another live instruction file. + +`tests/test_kit.py` contains CLI and rule-owner integration tests. `README.md` owns the public command and component lifecycle documentation. `MANIFEST.sha256` records tracked package content. + +## Product Boundary + +The accepted user request, `upstream.lock.json`, and the canonical coordination reference define this task. Apply [Scope boundaries](../../assets/skills/design-preflight/references/scope-boundaries.md). + +The managed lock inventory, the `kit.py` CLI, canonical coordination instructions, Design Preflight follow-up rules, and README compose the result. Existing install, Doctor, plugin lifecycle, native thread lifecycle, and Codex thread-limit behavior remain opaque. Optional tools, automatic updates, persistence, retries, and thread-limit changes remain deferred. + +No inherited specification conflict exists. Do not alter component pins or installation behavior. Do not add background work, caches, schedules, dependencies, provider abstractions, version-order logic, or direct Codex state-file operations. + +## Supported Operating Model + +The supported path is an explicit online query against the five current managed components. A complete query returns status 0. The first network or response-shape error uses the existing `ERROR: ...` path and returns status 2. + +The command reports toolkit pins and upstream identities. It does not inspect installed versions. For Ponytail, it reports the latest upstream release and Codex ownership without a current or update-needed claim. + +A delegated assignment is terminal only when no required follow-up remains. After the coordinator captures that result, it closes the exact agent through the exposed native close operation before it spends another slot. If native archival is exposed, the coordinator explicitly targets a finished subtree with no active or required descendant. + +## Boundary Inventory + +| Boundary | Condition and contrast | Required oracle | Entry point | Scenarios | +| --- | --- | --- | --- | --- | +| B1 | Process all managed runtime tools and skills. Do not stop early, merge equal values, or include optional tools. | Five named results appear exactly once. No optional request or output occurs. | `kit.main(["check-updates"])` | S1 | +| B2 | The command performs reads and GET requests only. It does not mutate toolkit or Codex state. | Source, installation, plugin, and configuration state remain unchanged. | `kit.main(["check-updates"])` | S1, S5 | +| B3 | Compare version pins with releases and commit pins with repository heads. Do not fabricate a Ponytail baseline. | Each line uses the correct identity and same/different wording. Ponytail has Codex-owned wording only. | `kit.main(["check-updates"])` | S2, S3, S4 | +| B4 | A complete report returns 0. The first failed request returns 2 without retry, later requests, or partial success output. | Status, output, error count, and request sequence match the selected path. | `build_parser`, `main` | S1, S5, S6 | +| B5 | Capture a terminal assignment result, then close its exact agent. Archival is separate from capacity release. | The canonical rule states the actor, order, exact target, capacity purpose, and archive distinction. | Canonical coordination rule | S7 | +| B6 | Cleanup targets only finished work and preserves active or required descendants. | The rule requires an explicit archive target and forbids coordinator defaults and direct session deletion. | Canonical coordination rule | S8 | +| B7 | A finished turn is not a finished assignment when a required follow-up remains. | The same Design Preflight challenger survives both phases and becomes eligible only after Phase 2. | Coordination and Design Preflight rules | S9 | + +## Scenario Proof + +Apply [Scenario discrimination](../../assets/skills/design-preflight/references/scenario-discrimination.md), [Owner composition](../../assets/skills/design-preflight/references/owner-composition.md), and [Full-set results](../../assets/skills/design-preflight/references/full-set-results.md). + +| Scenario | Discriminator and contrast | Production path | Required oracle | Runnable check | +| --- | --- | --- | --- | --- | +| S1 | Use equal early identities and a different final skill revision. This exposes early stop and value deduplication. | `main` to parser, lock selection, provider parsing, comparison, and output | Exactly five managed results, no optional request, status 0, unchanged files | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_reports_complete_managed_inventory` | +| S2 | Vary RepoWise and uv between equal and different versions. Prefix uv tags with `v`. | `main` through real version comparison | Correct toolkit-pin and upstream-release labels, with no installed-version claim | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_compares_version_pins` | +| S3 | Vary each skill head between its exact pin and a different full SHA. | `main` through default-branch head requests and SHA comparison | Correct full revisions and same/different identity | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_compares_skill_revisions` | +| S4 | Give unpinned Ponytail the same release shape used by uv. | `main` through unpinned rendering | Upstream release and Codex-owned wording, with no installed/current/update-needed claim | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_reports_codex_owned_ponytail` | +| S5 | Change one later request from success to error after earlier requests complete. | `main` through the external seam and existing error owner | One error, status 2, no retry or later request, no stdout report, unchanged files | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_stops_once_on_request_error` | +| S6 | Select top-level help or subcommand help instead of command execution. | Parser help path | The read-only command is discoverable and makes no provider request | `python3 -m unittest discover -s tests -p test_kit.py -k test_update_check_is_explicit` | +| S7 | Compare final assignment cleanup with an idle but still-open thread or archive-only cleanup. | Canonical coordination instructions | Capture occurs before exact native closure and before another spawn. Archival is separate. | `python3 -m unittest discover -s tests -p test_kit.py -k test_coordination_cleans_terminal_agent_threads` | +| S8 | Keep a finished parent fixed and vary its descendant between active and terminal. | Canonical coordination instructions | Explicit archive targeting waits for active or required descendants and never targets the coordinator by default | `python3 -m unittest discover -s tests -p test_kit.py -k test_coordination_archive_targets_preserve_active_work` | +| S9 | Compare a Phase 1 return with completion of the full two-phase assignment. | Design Preflight through canonical cleanup gate | The same challenger remains available for Phase 2 and is cleaned only after the final result | `python3 -m unittest discover -s tests -p test_kit.py -k test_coordination_cleanup_preserves_required_followups` | + +## Plan of Work + +### CPK-061-A: Report managed component updates + +Add the minimum standard-library implementation to `kit.py`. Reuse `load_lock`. Add one JSON GET helper, fixed provider URL selection for the current inventory, buffered result formatting, parser registration, and main dispatch. + +Add the S1 through S6 tests to `tests/test_kit.py`. Replace only `urllib.request.urlopen` at the external boundary. Keep lock selection, comparison, formatting, parser dispatch, and error handling real. + +Document the command in `README.md`. This subtask changes `kit.py`, `tests/test_kit.py`, `README.md`, the ExecPlan, roadmap, and manifest. Its checkpoint review boundary is the complete update-reporting path. No separate checkpoint commit is required before the cumulative task review. + +### CPK-061-B: Clean finished delegated threads + +Edit only the canonical coordination owner for live instructions. Define assignment completion, result capture, exact native closure, optional explicit archival, descendant protection, and the ban on direct session-file deletion. + +Add the S7 through S9 deterministic owner tests to `tests/test_kit.py`. Keep the Design Preflight same-challenger text unchanged. This subtask changes the canonical coordination reference, tests, the ExecPlan, roadmap, and manifest. Its checkpoint review boundary is the instruction contract and its rule-owner checks. No separate checkpoint commit is required before the cumulative task review. + +## Concrete Steps + +Work from `/home/mbeutler/Projects/codex-practical-kit` on branch `cpk-061-component-updates-agent-cleanup`. + +Use `apply_patch` for tracked edits. After implementation, run each Scenario Proof command. Then run: + + python3 -m unittest discover -s tests -p 'test_*.py' + python3 kit.py check-updates --help + python3 kit.py --help + +Regenerate `MANIFEST.sha256` with the repository's existing manifest procedure. Record command results in ignored `.agent/test-results/CPK-061.md`. + +## Validation and Acceptance + +The focused tests must prove both sides of each listed contrast. The complete suite must pass. Help output must describe `check-updates` as read-only and must not access the network. + +A live `python3 kit.py check-updates` run can use the current network. Its output must contain RepoWise, uv, Ponytail, NeuroArxiv, and Simple English. The command can report differing identities without failing. + +Before native correctness review, a fresh read-only trace reviewer must close executable boundaries B1 through B4. Deterministic source checks and native review cover instruction boundaries B5 through B7. After a clean final review, move CPK-061 from Active to Completed and apply review closure. + +## Idempotence and Recovery + +The update command is repeatable because it writes no state. A failed request stops the command. Run it again only after the external problem is corrected. + +All source edits are ordinary Git changes. Preserve unrelated untracked `.mcp.json`, `.repowise/`, and `.vscode/` content. + +## Artifacts and Notes + +The preflight found no contract gap. RepoWise indexed base commit `00f3af7e9e91` with `index_behind: false` before implementation. + +## Interfaces and Dependencies + +Use Python's existing `urllib.request` and `json` modules. Add no dependency. + +`check_updates() -> None` owns the full scan and successful report. A small JSON GET helper returns a decoded JSON value and raises `KitError` for transport, HTTP, JSON, or required-field errors. `main` returns 0 after `check_updates` completes and keeps the existing status-2 exception path. + +The public interface is: + + python3 kit.py check-updates + +The output uses one concise line per managed component. Each line names the toolkit baseline or Codex-owned state, the upstream identity, and whether comparable identities are the same or different. + +## Outcomes & Retrospective + +Users can compare all managed component pins with upstream identities through one read-only command. Coordinators now preserve required follow-ups and separate capacity-releasing closure from targeted history archival. + +The fixed provider paths kept the update report small and explicit. Native Codex lifecycle operations remained in the coordination owner instead of toolkit runtime code. + +Plan note: CPK-061 combines two user-requested outcomes under their existing owners. The runtime path remains read-only, and native Codex lifecycle behavior remains outside toolkit code. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1432a78..25fa595 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,6 +33,7 @@ No residual risk is accepted. ## Completed +- ✅ **CPK-061 — Report managed component updates and clean up finished agent threads.** Add a read-only update check for RepoWise and every other managed component. Teach coordinators to preserve required follow-ups, close terminal delegated threads, and archive finished history with native tools. [ExecPlan](plans/cpk-061-component-updates-agent-cleanup.md) - ✅ **CPK-060 — Resolve the Codex command on Windows.** Use the Windows npm command shim for native plugin installation and Doctor checks. Publish version `0.24.1`. [ExecPlan](plans/cpk-060-windows-codex-resolution.md) - ✅ **CPK-059 — Publish through protected pull requests.** Add readiness-triggered CI, make the repository public, protect main, and release reusable Plan History as version `0.24.0`. [ExecPlan](plans/cpk-059-public-pr-release.md) - ✅ **CPK-058 — Compact Plan History and resume interrupted reads.** Reuse verified summaries without changing original records, and preserve reading progress across normal context compaction. [ExecPlan](plans/cpk-058-plan-history-compaction.md) diff --git a/kit.py b/kit.py index 928f59d..09f9344 100755 --- a/kit.py +++ b/kit.py @@ -230,6 +230,89 @@ def load_lock() -> dict[str, Any]: return value +def fetch_json(url: str) -> Any: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json, application/json", + "User-Agent": f"{KIT_ID}/{KIT_VERSION}", + }, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise KitError(f"Could not read component update data from {url}: {exc}") from exc + + +def github_api_url(repository: str, suffix: str) -> str: + match = re.fullmatch(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?", repository) + if not match: + raise KitError(f"Unsupported repository URL: {repository}") + return f"https://api.github.com/repos/{match.group(1)}/{match.group(2)}/{suffix}" + + +def required_string(value: Any, field: str, url: str) -> str: + if not isinstance(value, str) or not value: + raise KitError(f"Component update source did not return {field}: {url}") + return value + + +def check_updates() -> None: + lock = load_lock() + lines: list[str] = [] + + for name, component in lock["runtime_tools"].items(): + repository = component["repository"] + if name == "repowise": + url = "https://pypi.org/pypi/repowise/json" + data = fetch_json(url) + info = data.get("info") if isinstance(data, dict) else None + upstream = required_string( + info.get("version") if isinstance(info, dict) else None, + "info.version", + url, + ) + pinned = component["version"] + state = "same identity" if upstream == pinned else "upstream differs" + lines.append(f"{name}: toolkit pin {pinned}; upstream release {upstream}; {state}") + elif name in {"uv", "ponytail"}: + url = github_api_url(repository, "releases/latest") + data = fetch_json(url) + upstream = required_string( + data.get("tag_name") if isinstance(data, dict) else None, + "tag_name", + url, + ) + if name == "ponytail": + lines.append( + f"{name}: Codex-owned plugin; upstream release {upstream}; use the Codex plugin manager" + ) + else: + pinned = component["version"] + comparable = upstream[1:] if upstream.startswith("v") else upstream + state = "same identity" if comparable == pinned else "upstream differs" + lines.append(f"{name}: toolkit pin {pinned}; upstream release {upstream}; {state}") + else: + raise KitError(f"No update source is defined for managed runtime tool: {name}") + + for name, component in lock["skills"].items(): + url = github_api_url(component["repository"], "commits?per_page=1") + data = fetch_json(url) + upstream = required_string( + data[0].get("sha") if isinstance(data, list) and data and isinstance(data[0], dict) else None, + "default-branch commit SHA", + url, + ) + pinned = component["commit"] + state = "same identity" if upstream == pinned else "upstream differs" + lines.append(f"{name}: toolkit revision {pinned}; upstream revision {upstream}; {state}") + + print("Managed component updates:") + for line in lines: + print(f"- {line}") + + def raw_github_url(repository: str, commit: str, source_path: str) -> str: match = re.fullmatch(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?", repository.rstrip("/")) if not match: @@ -1211,6 +1294,15 @@ def build_parser() -> argparse.ArgumentParser: update = sub.add_parser("repowise-update", help="Refresh the pinned RepoWise index.") update.add_argument("repo", nargs="?", default=".") + sub.add_parser( + "check-updates", + help="Report managed component update availability.", + description=( + "Read upstream sources and report managed component updates. " + "This read-only command changes no files or installations." + ), + ) + diag = sub.add_parser("doctor", help="Check the core kit and repository integration.") add_path_options(diag) diag.add_argument("--repo", help="Also check RepoWise in this repository.") @@ -1238,6 +1330,9 @@ def main(argv: list[str] | None = None) -> int: update_repowise(args, paths) print("RepoWise index updated.") return 0 + if args.command == "check-updates": + check_updates() + return 0 if args.command == "install": install_core(args, paths) diff --git a/tests/test_kit.py b/tests/test_kit.py index 0e07f0e..5aa26de 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -1,4 +1,6 @@ +import contextlib import hashlib +import io import json import os import re @@ -2100,6 +2102,198 @@ def run(command, **kwargs): self.assertEqual(mcp_kwargs["env"]["REPOWISE_SKIP_EDITOR_SETUP"], "1") +class ComponentUpdateTests(unittest.TestCase): + class Response: + def __init__(self, value): + self.data = json.dumps(value).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return self.data + + def responses(self): + lock = kit.load_lock() + runtime = lock["runtime_tools"] + skills = lock["skills"] + return { + kit.github_api_url(runtime["ponytail"]["repository"], "releases/latest"): { + "tag_name": "v4.12.0" + }, + "https://pypi.org/pypi/repowise/json": { + "info": {"version": runtime["repowise"]["version"]} + }, + kit.github_api_url(runtime["uv"]["repository"], "releases/latest"): { + "tag_name": f"v{runtime['uv']['version']}" + }, + **{ + kit.github_api_url(component["repository"], "commits?per_page=1"): [ + {"sha": component["commit"]} + ] + for component in skills.values() + }, + } + + def run_check(self, responses): + calls = [] + + def urlopen(request, timeout): + self.assertEqual(timeout, 60) + calls.append(request.full_url) + value = responses[request.full_url] + if isinstance(value, BaseException): + raise value + return self.Response(value) + + stdout = io.StringIO() + stderr = io.StringIO() + with mock.patch.object(kit.urllib.request, "urlopen", side_effect=urlopen), contextlib.redirect_stdout( + stdout + ), contextlib.redirect_stderr(stderr): + status = kit.main(["check-updates"]) + return status, stdout.getvalue(), stderr.getvalue(), calls + + def test_check_updates_reports_complete_managed_inventory(self): + lock_before = (ROOT / "upstream.lock.json").read_bytes() + responses = self.responses() + final_url = kit.github_api_url( + kit.load_lock()["skills"]["simple-english"]["repository"], + "commits?per_page=1", + ) + responses[final_url] = [{"sha": "f" * 40}] + + with mock.patch.object(kit, "write_file") as write_file, mock.patch.object( + kit, "run" + ) as run, mock.patch.object(kit, "codex_plugin") as codex_plugin: + status, stdout, stderr, calls = self.run_check(responses) + + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertEqual(len(calls), 5) + self.assertEqual(set(calls), set(responses)) + for name in (*kit.load_lock()["runtime_tools"], *kit.load_lock()["skills"]): + self.assertEqual(stdout.count(f"- {name}:"), 1) + for name in kit.load_lock()["optional_tools"]: + self.assertNotIn(name, stdout) + self.assertFalse(any(name in url for url in calls)) + self.assertIn("simple-english:", stdout) + self.assertIn("upstream differs", stdout) + self.assertEqual((ROOT / "upstream.lock.json").read_bytes(), lock_before) + write_file.assert_not_called() + run.assert_not_called() + codex_plugin.assert_not_called() + + def test_check_updates_compares_version_pins(self): + lock = kit.load_lock() + repowise_url = "https://pypi.org/pypi/repowise/json" + uv_url = kit.github_api_url(lock["runtime_tools"]["uv"]["repository"], "releases/latest") + cases = ( + (repowise_url, {"info": {"version": lock["runtime_tools"]["repowise"]["version"]}}, "same identity"), + (repowise_url, {"info": {"version": "99.0.0"}}, "upstream differs"), + (uv_url, {"tag_name": f"v{lock['runtime_tools']['uv']['version']}"}, "same identity"), + (uv_url, {"tag_name": "v99.0.0"}, "upstream differs"), + ) + for url, value, expected in cases: + with self.subTest(url=url, value=value): + responses = self.responses() + responses[url] = value + status, stdout, stderr, _calls = self.run_check(responses) + name = "repowise" if url == repowise_url else "uv" + line = next(line for line in stdout.splitlines() if line.startswith(f"- {name}:")) + pinned = lock["runtime_tools"][name]["version"] + upstream = value["info"]["version"] if name == "repowise" else value["tag_name"] + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertEqual( + line, + f"- {name}: toolkit pin {pinned}; upstream release {upstream}; {expected}", + ) + + def test_check_updates_compares_skill_revisions(self): + lock = kit.load_lock() + for name, component in lock["skills"].items(): + url = kit.github_api_url(component["repository"], "commits?per_page=1") + for upstream, expected in ((component["commit"], "same identity"), ("f" * 40, "upstream differs")): + with self.subTest(name=name, upstream=upstream): + responses = self.responses() + responses[url] = [{"sha": upstream}] + status, stdout, stderr, calls = self.run_check(responses) + line = next(line for line in stdout.splitlines() if line.startswith(f"- {name}:")) + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertIn(url, calls) + self.assertIn(f"toolkit revision {component['commit']}", line) + self.assertIn(f"upstream revision {upstream}", line) + self.assertIn(expected, line) + + def test_check_updates_reports_codex_owned_ponytail(self): + status, stdout, stderr, _calls = self.run_check(self.responses()) + line = next(line for line in stdout.splitlines() if line.startswith("- ponytail:")) + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertEqual( + line, + "- ponytail: Codex-owned plugin; upstream release v4.12.0; " + "use the Codex plugin manager", + ) + + def test_check_updates_stops_once_on_request_error(self): + lock_before = (ROOT / "upstream.lock.json").read_bytes() + uv_url = kit.github_api_url( + kit.load_lock()["runtime_tools"]["uv"]["repository"], "releases/latest" + ) + for failure, detail in ( + (kit.urllib.error.URLError("offline"), "offline"), + ({}, "tag_name"), + ): + with self.subTest(detail=detail): + responses = self.responses() + responses[uv_url] = failure + with mock.patch.object(kit, "write_file") as write_file, mock.patch.object( + kit, "run" + ) as run, mock.patch.object(kit, "codex_plugin") as codex_plugin: + status, stdout, stderr, calls = self.run_check(responses) + + self.assertEqual(status, 2) + self.assertEqual(stdout, "") + self.assertEqual(stderr.count("ERROR:"), 1) + self.assertIn(detail, stderr) + self.assertEqual(calls[-1], uv_url) + self.assertEqual(len(calls), 3) + write_file.assert_not_called() + run.assert_not_called() + codex_plugin.assert_not_called() + self.assertEqual((ROOT / "upstream.lock.json").read_bytes(), lock_before) + + def test_update_check_is_explicit(self): + urlopen = mock.Mock() + top_level_help = "" + command_help = "" + for argv in (["--help"], ["check-updates", "--help"]): + with self.subTest(argv=argv), mock.patch.object( + kit.urllib.request, "urlopen", urlopen + ), contextlib.redirect_stdout(io.StringIO()) as stdout, self.assertRaises( + SystemExit + ) as raised: + kit.main(argv) + self.assertEqual(raised.exception.code, 0) + self.assertIn("check-updates", stdout.getvalue()) + if argv == ["--help"]: + top_level_help = stdout.getvalue() + else: + command_help = stdout.getvalue() + self.assertIn("managed component update availability", top_level_help) + self.assertIn("managed component", top_level_help) + normalized_command_help = " ".join(command_help.split()) + self.assertIn("read-only command", normalized_command_help) + self.assertIn("changes no files or installations", normalized_command_help) + urlopen.assert_not_called() + + class IntegrationTests(unittest.TestCase): def test_setup_skips_catch_up_before_first_commit(self): with tempfile.TemporaryDirectory() as temp: @@ -2915,6 +3109,68 @@ def test_coordination_limits_writers_but_not_read_only_agents(self): self.assertIn("cpk-rule-route-only: coordination", preflight) self.assertIn("delivery-lifecycle/references/coordination.md", preflight) + def test_coordination_cleans_terminal_agent_threads(self): + coordination = ( + ROOT + / "assets" + / "skills" + / "delivery-lifecycle" + / "references" + / "coordination.md" + ).read_text() + + capture = coordination.index("Capture each terminal delegated result.") + close = coordination.index("`close_agent`") + next_slot = coordination.index("Close finished agent threads before spending another agent slot.", close) + archive = coordination.index("Archive only an explicitly targeted finished agent subtree.", next_slot) + self.assertLess(capture, close) + self.assertLess(close, next_slot) + self.assertLess(next_slot, archive) + self.assertIn("for that exact agent", coordination) + self.assertIn("Closure releases open-agent capacity", coordination) + self.assertIn("does not prove that closure occurred", coordination) + self.assertIn("Do not delete session files or edit Codex state directly", coordination) + + def test_coordination_archive_targets_preserve_active_work(self): + coordination = ( + ROOT + / "assets" + / "skills" + / "delivery-lifecycle" + / "references" + / "coordination.md" + ).read_text() + + self.assertIn("specify the finished thread identifier", coordination) + self.assertIn("Do not use a default target that can select the coordinator", coordination) + self.assertIn("Archiving a parent can also archive its descendants", coordination) + self.assertIn("wait until every descendant has finished", coordination) + self.assertIn("owes no required follow-up", coordination) + self.assertIn( + "Do not interrupt or replace a healthy active delegated task because it is slow", + coordination, + ) + + def test_coordination_cleanup_preserves_required_followups(self): + coordination = ( + ROOT + / "assets" + / "skills" + / "delivery-lifecycle" + / "references" + / "coordination.md" + ).read_text() + preflight = ( + ROOT / "assets" / "skills" / "design-preflight" / "SKILL.md" + ).read_text() + + self.assertIn("A stopped turn does not finish an assignment", coordination) + self.assertIn("when a required follow-up remains", coordination) + self.assertIn("Keep the same agent available", coordination) + self.assertIn("complete assignment reports `complete` or `blocked`", coordination) + self.assertIn("Keep the same challenger identity for both phases", preflight) + self.assertIn("give the same challenger the coordinator's complete card", preflight) + def test_default_mode_preflight_and_repowise_continue_through_corrections(self): root = ROOT / "assets" / "skills" delivery = ( From 62c549376f2f53a48484ddba49bad607feb426cf Mon Sep 17 00:00:00 2001 From: Michael Beutler Date: Mon, 5 Oct 2026 14:20:43 -0400 Subject: [PATCH 2/6] feat: apply managed updates and add website-only 12UI --- CODEX-INSTALL-PROMPT.md | 2 +- MANIFEST.sha256 | 27 +- README.md | 48 +- THIRD_PARTY.md | 8 + apply-updates.ps1 | 3 + apply-updates.sh | 4 + assets/AGENTS.block.md | 2 + check-updates.ps1 | 3 + check-updates.sh | 4 + docs/REPOWISE.md | 4 + ...cpk-062-managed-update-application-12ui.md | 196 +++ docs/roadmap.md | 1 + kit.py | 386 +++-- tests/test_kit.py | 79 +- tests/test_ponytail.py | 82 +- tests/test_updates.py | 1268 +++++++++++++++++ tests/update_subprocess_fixture.py | 206 +++ upstream.lock.json | 15 +- 18 files changed, 2187 insertions(+), 151 deletions(-) create mode 100644 apply-updates.ps1 create mode 100755 apply-updates.sh create mode 100644 check-updates.ps1 create mode 100755 check-updates.sh create mode 100644 docs/plans/cpk-062-managed-update-application-12ui.md create mode 100644 tests/test_updates.py create mode 100644 tests/update_subprocess_fixture.py diff --git a/CODEX-INSTALL-PROMPT.md b/CODEX-INSTALL-PROMPT.md index ff665b0..acbe2ce 100644 --- a/CODEX-INSTALL-PROMPT.md +++ b/CODEX-INSTALL-PROMPT.md @@ -22,7 +22,7 @@ Review the changes in `~/.codex/AGENTS.md`, `~/.codex/PLANS.md`, and `~/.codex/c Confirm that these skills are installed: `delivery-lifecycle`, `neuroarxiv`, `plan-history`, `publication`, `repository-knowledge`, `roadmap-maintainer`, and `toolkit-maintainer`. Confirm that `codex-practical-kit-rules` and `task-brief` are absent. Confirm that the RepoWise MCP table has `default_tools_approval_mode = "approve"`, `required = true`, and `startup_timeout_sec = 1800`. -Verify that Node.js and native Codex plugin commands work before changing an existing installation. Install the official `ponytail@ponytail` plugin only when absent. Preserve its existing version, settings, and enabled or disabled state. Use the selected Codex home for native commands. Reject a conflicting marketplace source or unowned standalone Ponytail skill. Remove the old copied skill only when the toolkit manifest owns it and native installation succeeds. Confirm that the plugin provides all six Ponytail skills and its lifecycle hooks. Do not change Ponytail modes. Explain the Codex `@ponytail` skill controls and link to README's complete-removal steps. +Verify that Node.js and native Codex plugin commands work before you change an existing installation. Install `ponytail@ponytail` and `12ui-design@12ui-plugin` only when absent. Preserve each existing plugin version, configuration, and enabled state. Use the selected Codex home for native commands. Reject a conflicting marketplace source or unowned standalone Ponytail skill. Remove the old copied skill only when the toolkit manifest owns it and native installation succeeds. Make sure that Ponytail provides all six skills and its lifecycle hooks. Do not change Ponytail modes. Use 12UI only for website projects. Explain the Codex `@ponytail` skill controls and link to the complete-removal steps in README. Do not trust toolkit or Ponytail hooks on my behalf. Tell me to open `/hooks` in a new Codex session. If version 0.2.0 was active, do not resume that old session. That session can retain the stale continuation that version 0.2.1 corrected. diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index edf1ff0..f9ed6d2 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -20,12 +20,14 @@ a4d0bc13747cf250f25fb599b37f90cf519c95a29382f4da544afe9ff8095372 ./.agent/plan- d60f352d0db1404c70afb4bb8b2ca3fd1c610572aa40720e8a0b7baa7885418c ./.gitattributes 13d683d7a117f01846724725f15a844768d16f52d226ec6d3f76143b1fc3f6f8 ./.github/workflows/ci.yml 15cefba6945d22a52b5b8ef6dd656fffcb698627b9fb23dacc5c8ec27ad1680d ./.gitignore -6d7a25701ba2532525fe556f1a55529ab09af6675522f749afb22609f7f8183e ./CODEX-INSTALL-PROMPT.md +b463073a4cc7942388b920d0d23749685dad47ab7fe8d894e61e5c626f8f5f94 ./CODEX-INSTALL-PROMPT.md 6612129aca01053f15098204fb4bbaafdf85a667956202530bdcbc339e58e462 ./LICENSE e4912f4f8d644555dcd57d5e336a249cf8a9d7f6222b78096d6b403f45a8a215 ./PLAYGROUND.md -70489ebf898ac49654b09d398e4c1c528ed6cadb7b23b5f49c5fba7859324398 ./README.md -979191eeef7975567b4c44c9fb6dc38bf78e431eda582ff55d4f37eb2f69a441 ./THIRD_PARTY.md -59b191889c8b7d098ddaf6b029f6f12b3ce6590893e1823f4448ad3b90c03fb4 ./assets/AGENTS.block.md +b1cdff1ca02a427364817a87ce75580340b440316227ef11542f2356b129bc6f ./README.md +3e5b0840dd197d740c5f7345d86a8658d9e45aeecd16d5785fc7b307e9ba59b3 ./THIRD_PARTY.md +3d652285f38f181fa273e1fc993b08dafa2fc3113c2109ccbdabf1a0c7fec573 ./apply-updates.ps1 +74d504bff9dc0b10f116f6b83a452a1c757ca7ad7c65797c7439e928bfbbe45e ./apply-updates.sh +d0ab843abb452ecba5386cbc56659aac83400cd4c5fb086db501e232f2e4d0a9 ./assets/AGENTS.block.md b9f9eb58967f54b19fb3a58b1db32e4c25299ae10c06e2e45a6611c250801a71 ./assets/hooks/session_start.py 3027ffa3ef382dcd0c96f6280ba861bb4a9410011e1176d71d7aa7911805821f ./assets/runtime/repowise_bootstrap.py a564a3b00a86f43d38318b76168593bc965b2f1decf67a328d499a054aa35f36 ./assets/skills/adversarial-review/SKILL.md @@ -63,11 +65,13 @@ b87078d33fa47da8a2f60b1b5155671f593b7dcf198e50e307c788bd42bc154c ./assets/skill c5465d35f51779e1b75da0d96bdb75e792fbe0a2691685f4c5e54140f1ed7388 ./assets/skills/roadmap-maintainer/SKILL.md a8a3764af5e50240c687019e5bf56d927728a0e802ab4ce2168d3816e2b69cbd ./assets/skills/roadmap-maintainer/assets/roadmap-template.md afe980a9557961e9fa13d65fa066c62d50b87523234a4f88886ae494a1e302be ./assets/skills/toolkit-maintainer/SKILL.md +4bd91aa5c60f699f19c0e5e049b3fcf01a419baaf161202bb1bf67620240be14 ./check-updates.ps1 +b97c626ce8e220598d12f7bd0cce5099d331aae71a45f692a9eb57d669d0c038 ./check-updates.sh 913770b6e8fa270a4f8927ce9840c877366eb6a06a76a4fac47b3c773b9bcf4c ./docs/HOTFIX-0.2.1.md 78823831a732ebf870c45b9acc92abddfdddbdbb47db6db81a38ada0497de00f ./docs/OPERATING-MANUAL.md 97dd789229fa55905bd20290c2767ef7bd274cc81965a6f4eb1136dda9ed0adb ./docs/OPTIONAL-REVIEW-TOOLS.md 9bee78762d4d1f446cfb816878e36671cdd823ab62521d32833cc915a572796e ./docs/REMOVE-CBM.md -208aeeb48807fd9a4d44ea7a5960e88f4a41d77ba2ac6351ad410b80f319233a ./docs/REPOWISE.md +62dba022bbc34c4239deb7749e58052a05c7d71919e78db459bc31db0c56014b ./docs/REPOWISE.md e27b3d72ff47ebf7230a96b3a5f2d6ea3f410e22ef382f8e7d9cd27c7bb59892 ./docs/REVIEW.md e86355a4eae8ec5cd42e7674461ebaf1acc4f14f20fa9600da2bb5248efb8e43 ./docs/SPEC-KIT.md f7298a2735489830b11a83c5a852984916147e8c3408214cc722d58a4f7a139f ./docs/WHY-THIS-SHAPE.md @@ -126,12 +130,13 @@ a5d8013da3d22afba28c1259e29e3255d1d19969d0934b2f59069f0de80ea31b ./docs/plans/c 43141b61bf800dc9956603b6e88cdd0c3a5564340d3f4aee3fb6b0682dceee00 ./docs/plans/cpk-059-public-pr-release.md 8cea5185f3a1181dc9a1419f3f980712b2b9e35936ec2a8011629d67cd0d041f ./docs/plans/cpk-060-windows-codex-resolution.md ccc9e20f5cd288592bde3dc81fd49820e9f7d62ef2274a99af8ac9781d5d3f5c ./docs/plans/cpk-061-component-updates-agent-cleanup.md -fe6379868ba2232d54e1bba3fcab4dd25e3594aa2d262c39524953f767b0c109 ./docs/roadmap.md +487d05e84cc1c1849f7dff4fc08eac2adad08af9dab8091b60bac48c8ad085c4 ./docs/plans/cpk-062-managed-update-application-12ui.md +3f747ebc949ed432154864b2dd8c1da1e423fc5aec2ffa3e3490a2d5b7353a3e ./docs/roadmap.md 6f8f14ca5fd8ad935eee66256b3f7c49d5c40957eedff938ba21365e30869719 ./doctor.ps1 ab56a88d9188979f06ea142d1f58ea0d6ad9088e83a965bd324209d83da513ad ./doctor.sh 8087bdb3ee654c0baac278a8e3bf85e33e314f8acfd486e7ebf675126a697058 ./install.ps1 3466d2e34145cfdaa83d793960794b2aefc96f3d9c48d22fd2af930ec819e9c4 ./install.sh -5469fe020aad5b51e376c4c15ef44f214b29f859529bb797aa4f23748b9726de ./kit.py +301d556ad0dbbde620b77fab53081821b70f67beb98cfa99608144eb4271538f ./kit.py 331f17ca1486b5779d9b57a3a73834328eb3089ce612e636e6a8e5085899d61e ./run-tests.ps1 e6df9f1da02280a66161583529b06a34f0103041e9587afad0fac6074eecc15b ./run-tests.sh 85a2ba72e2b0cd1ab6c25a51f39059502415c3ac62a6bffa613d7e31b6422cd4 ./setup-repo.ps1 @@ -142,8 +147,10 @@ e1e231aac51822f7e1bb3aa61aba138cf8c5df9cefd3079b0c1b5a98eea03bd9 ./tests/fixtur a269775204ae7cad2457d00f5565c90da2df41a9719479f3489ec01afdd2519c ./tests/fixtures/cpk056-agent-cases.json e32941eb74be1f2cdcb6c82cf71e113b841651bf0d01786f364176de2da1b9c5 ./tests/fixtures/cpk056-agent-grading.json 289367b7090a0d71936b41f08c4a354bc2758013dceb709819de36a1b73d9aa6 ./tests/live_plan_history.py -a1f37a4b26f13c014da797245895547a2dc64e7e534834507eb0ecad88a4d466 ./tests/test_kit.py -1979109d44a6b49c01747216c99dc6598e5cc725b986df4eea60215e4598f4d8 ./tests/test_ponytail.py +cdfd409f6ba98e599ec9cd642c8c9c58e42fe747ec2723baf0db73e45d91ebd0 ./tests/test_kit.py +0be6f880195f412efa9c9b7cf8d03c5514254a9b1d31fa1e4e1b1d838ec7a760 ./tests/test_ponytail.py +6d70829e4edaa08bbfa507a3444189278edaa850e5f8c4671553fa27d403d838 ./tests/test_updates.py +54673ace20ec6333c73ead405c82d52560848bc8b9aa3a0102fc39935cc3ab87 ./tests/update_subprocess_fixture.py d16f7eadc8c668e82a1d0d2554ed04def15cb0a4b2ce13ac1ce1339bc0e62161 ./uninstall.ps1 f12cff94ef7f9b0ca489cbe43e1de737011476966e04c1578f4977177b0da217 ./uninstall.sh -e96e2cd99b37377ca84d420d142d097981f303f5006d930880352ad9404d3b13 ./upstream.lock.json +16c555f5b2ac5588132173fff8125d17278a4b290479d54cc1d7b80df57246b7 ./upstream.lock.json diff --git a/README.md b/README.md index 8121759..bc0dd26 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The managed `AGENTS.md` is a small skill router. Skill descriptions keep unrelat ## Included components -The kit installs the official Ponytail plugin and these task skills: +The kit installs the official Ponytail and 12UI plugins. It also installs these task skills: - `simple-english` - `neuroarxiv` @@ -44,32 +44,50 @@ pwsh -File .\install.ps1 pwsh -File .\doctor.ps1 ``` -The installer copies managed skills to `~/.agents/skills/`. Do not edit installed copies. Codex manages Ponytail separately as a native plugin. +The installer copies managed skills to `~/.agents/skills/`. Do not edit installed copies. Codex manages Ponytail and 12UI as native plugins. The installer writes a managed block in `~/.codex/AGENTS.md`. It renders the absolute PLANS path and names installed skills. The installer also writes `$CODEX_HOME/PLANS.md` and managed Codex hooks. Session Start announces the AGENTS router. -## Check managed component updates +## Check and apply managed component updates On macOS or Linux, run this read-only command from the source checkout: ```bash -python3 kit.py check-updates +./check-updates.sh ``` On Windows, run: ```powershell -python kit.py check-updates +pwsh -File .\check-updates.ps1 ``` The command reads authoritative upstream sources for all managed runtime tools and bundled skills. It excludes optional tools and does not change installations, plugins, pins, or configuration. -The report compares toolkit version and revision pins with upstream identities. Ponytail has no toolkit pin, so the report identifies its upstream release and Codex-owned lifecycle. +The report compares toolkit pins with upstream identities. Native plugins have no toolkit pin. The report identifies their upstream release and Codex-owned lifecycle. A complete report returns status 0, including when upstream differs. A request error returns status 2 and prints no partial report. +To apply all managed updates on macOS or Linux, run: + +```bash +./apply-updates.sh +``` + +On Windows, run: + +```powershell +pwsh -File .\apply-updates.ps1 +``` + +The apply command updates `upstream.lock.json` and the selected toolkit installation. It also refreshes each installed managed plugin by its marketplace name. + +The command refuses an untracked source lock or an independent source edit. A prior successful apply remains valid when its source lock matches the selected installation. The command validates upstream data before mutation. It writes the source lock only after installation succeeds. + +If an operation fails, the command stops and returns status 2. Correct the reported cause, then run the command again. + ## Global and repository instructions The installer manages `~/.codex/AGENTS.md` and `$CODEX_HOME/PLANS.md`. These files apply to every repository unless a nearer repository file overrides them. @@ -96,7 +114,7 @@ For large histories, agents create reusable reading summaries under `.agent/plan An unrecorded destination skill is a conflict. The installer stops before it changes that directory. -Open a new Codex session after installation. Use `/hooks` to review and trust the toolkit and Ponytail commands. +Open a new Codex session after installation. Use `/hooks` to review and trust the toolkit and plugin commands. ## Ponytail @@ -121,12 +139,20 @@ Bare `@ponytail` reports the mode. `@ponytail default off` changes the default f The upstream Codex instructions use `@` commands; they do not promise a `/ponytail` slash-menu entry. See the [official Ponytail commands](https://github.com/DietrichGebert/ponytail#commands). -Use Codex's plugin manager for updates, enablement, and removal. Toolkit reinstall does not update or re-enable Ponytail. +Use Codex's plugin manager for enablement and removal. The apply command refreshes Ponytail but does not re-enable it. Migration removes an old standalone Ponytail skill only when the toolkit manifest owns its destination and native installation succeeds. An unowned skill or conflicting marketplace source stops installation. Resolve that ownership conflict before retrying. `--codex-home` selects the Codex home for installation and Doctor. Otherwise, the toolkit uses `CODEX_HOME` or `~/.codex`. Use the same home for native plugin commands. +## 12UI + +The installer uses the official `just-every/12ui-plugin` marketplace. It installs `12ui-design@12ui-plugin` when the plugin is absent. + +The toolkit uses 12UI only for website projects. It does not use 12UI for native applications or other user-interface work. + +The toolkit does not copy or edit the upstream skill. The apply command refreshes its marketplace and preserves its enabled or disabled state. + ## Rule owners An applicable skill selects only the rules needed for its task: @@ -177,7 +203,7 @@ See [RepoWise notes](docs/REPOWISE.md) for setup, refresh, opt-out, and trust-bo ## Uninstall -Uninstall removes toolkit-managed global integrations and copied skills. `--purge` also removes the toolkit data directory. Both retain native Ponytail resources, settings, uv, RepoWise, and Plan history. +Uninstall removes toolkit-managed global integrations and copied skills. `--purge` also removes the toolkit data directory. Both retain native plugin resources, uv, RepoWise, and Plan history. For complete removal, follow the ordered steps below. Keep the source checkout until the removal commands finish. @@ -250,6 +276,8 @@ Set `CODEX_HOME` to the selected directory with `export CODEX_HOME="$cpk_codex_h ```text codex plugin remove ponytail@ponytail codex plugin marketplace remove ponytail +codex plugin remove 12ui-design@12ui-plugin +codex plugin marketplace remove 12ui-plugin ``` Keep the marketplace if you still use another plugin from it. Normal toolkit uninstall never runs these commands. @@ -298,7 +326,7 @@ On macOS or Linux, run: ```bash ./run-tests.sh -sh -n doctor.sh install.sh run-tests.sh setup-repo.sh uninstall.sh +sh -n apply-updates.sh check-updates.sh doctor.sh install.sh run-tests.sh setup-repo.sh uninstall.sh git diff --check sha256sum --check MANIFEST.sha256 ``` diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 1e32301..5e205b3 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -10,6 +10,14 @@ - Version policy: install when absent; preserve existing versions and settings; Codex owns updates and removal - License: MIT +### 12UI + +- Source: https://github.com/just-every/12ui-plugin +- Installation: official native Codex plugin `12ui-design@12ui-plugin` from the `just-every/12ui-plugin` marketplace +- Use: website projects only +- Version policy: install when absent; refresh through the named marketplace; preserve enabled state and user data +- License: MIT + ### SimpleEnglish - Source: https://github.com/AminBlg/SimpleEnglish diff --git a/apply-updates.ps1 b/apply-updates.ps1 new file mode 100644 index 0000000..aedcbb6 --- /dev/null +++ b/apply-updates.ps1 @@ -0,0 +1,3 @@ +#Requires -Version 7.0 +& python (Join-Path $PSScriptRoot "kit.py") apply-updates @args +exit $LASTEXITCODE diff --git a/apply-updates.sh b/apply-updates.sh new file mode 100755 index 0000000..4f21044 --- /dev/null +++ b/apply-updates.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$HERE/kit.py" apply-updates "$@" diff --git a/assets/AGENTS.block.md b/assets/AGENTS.block.md index 368c73d..4b2e2c6 100644 --- a/assets/AGENTS.block.md +++ b/assets/AGENTS.block.md @@ -1,5 +1,6 @@ + ## Codex Practical Kit @@ -19,3 +20,4 @@ Use only the skills that match the current task. Follow the focused references s - Severe defect analysis: `defect-diagnostic`. - Publication or version selection: `publication`. - Toolkit rule changes: `toolkit-maintainer`. +- Use 12ui-design only for website projects. Do not use it for native apps or other UI work. diff --git a/check-updates.ps1 b/check-updates.ps1 new file mode 100644 index 0000000..f4b6df7 --- /dev/null +++ b/check-updates.ps1 @@ -0,0 +1,3 @@ +#Requires -Version 7.0 +& python (Join-Path $PSScriptRoot "kit.py") check-updates @args +exit $LASTEXITCODE diff --git a/check-updates.sh b/check-updates.sh new file mode 100755 index 0000000..3210048 --- /dev/null +++ b/check-updates.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$HERE/kit.py" check-updates "$@" diff --git a/docs/REPOWISE.md b/docs/REPOWISE.md index 61ae53d..79059be 100644 --- a/docs/REPOWISE.md +++ b/docs/REPOWISE.md @@ -54,6 +54,10 @@ RepoWise is a large external tool and uses AGPL-3.0. The kit does not copy it, m Core installation reuses `uv` when present. If it is absent, the kit installs pinned uv 0.12.4 from a checksum-verified upstream script. Windows uses the official PowerShell installer. macOS and Linux use the official shell installer. The kit installs RepoWise 0.45.0 as a persistent uv tool. A later kit install upgrades only the RepoWise executable recorded at the kit-owned user-bin path. It does not replace an unrecorded command. +Doctor uses the runtime paths in the selected installation manifest before it searches `PATH`. Doctor reports the selected uv version. Ordinary installation can reuse an existing uv. Update application installs and records the lock-selected uv. + +Update application reuses an external RepoWise executable when its version matches the selected lock. It force-installs only a mismatched kit-owned executable. + On Windows, reinstall also repairs a failed `repowise.exe` when the previous kit manifest records the fixed user-bin path. The installer adds a user-level RepoWise MCP server. If Codex starts in a completely empty folder, the server command initializes Git first. It does not initialize Git in a non-empty folder. diff --git a/docs/plans/cpk-062-managed-update-application-12ui.md b/docs/plans/cpk-062-managed-update-application-12ui.md new file mode 100644 index 0000000..43e7847 --- /dev/null +++ b/docs/plans/cpk-062-managed-update-application-12ui.md @@ -0,0 +1,196 @@ +# Apply managed updates and add website-only 12UI + +This ExecPlan is a living specification until review closure. Maintain it in accordance with `.agent/PLANS.md`. + +## Purpose / Big Picture + +The toolkit can report upstream releases, but it cannot act on that report. After this change, a user can run a small Bash or PowerShell command to check updates or explicitly apply every managed update. Application updates the source lock and the selected installed toolkit, so the next check, install, and Doctor result agree. + +The toolkit also installs the official 12UI Codex plugin. The global toolkit router permits that skill only for website projects. The toolkit does not copy or modify the upstream 12UI skill. + +## Surprises & Discoveries + +- Observation: Codex CLI 0.160.0 has no general `plugin update` command. A named Git marketplace is refreshed with `codex plugin marketplace upgrade NAME`, which refreshes installed plugin caches and preserves enabled state. + Evidence: the CLI help and the native plugin implementation expose the named marketplace upgrade operation. +- Observation: a current source pin does not prove that the selected installation is current. + Evidence: an updated checkout can coexist with skills installed from an older checkout. +- Observation: the documented 12UI install route is an official Git marketplace, not a copied skill directory. + Evidence: `just-every/12ui-plugin` documents `marketplace add just-every/12ui-plugin` and `plugin add 12ui-design@12ui-plugin`. +- Observation: Codex lists the 12UI package as local because the marketplace repository is also the package root. + Evidence: the installed record keeps the official Git marketplace identity and reports a local package source. + +## Decision Log + +- Decision: preserve `check-updates` as a read-only operation and add a separate `apply-updates` operation. + Rationale: checking must remain safe and explicit, while applying is an intentional mutation. +- Decision: add `check-updates.sh`, `check-updates.ps1`, `apply-updates.sh`, and `apply-updates.ps1` as thin sibling-script launchers. + Rationale: the shell files only locate `kit.py`, select one fixed command, forward arguments, and return its status. +- Decision: use `upstream.lock.json` as the single source of runtime versions, installer identities, skill revisions, native plugin sources, and package files. + Rationale: duplicated editable constants can make checking, installation, and Doctor disagree. +- Decision: resolve each selected skill commit through its complete, non-truncated Git tree and install every blob under its declared package root plus its declared repository license. + Rationale: a short static file list can silently omit added, changed, or removed package files. +- Decision: apply an exact RepoWise version through the existing managed `uv tool install` owner. Download and verify both official uv installer bodies, then execute only the current platform body. + Rationale: each installed runtime must match the selected lock identity and the platform paths remain distinct. +- Decision: manage Ponytail and 12UI through exact native Git marketplace selectors. Install an absent managed plugin and refresh an installed plugin with `marketplace upgrade`. + Rationale: this is the upstream-native lifecycle and preserves user enablement and plugin data. +- Decision: validate the 12UI local package through its official Git marketplace record. + Rationale: Codex resolves that local package from the verified marketplace snapshot. +- Decision: put the website-only 12UI restriction in the global routing owner, `assets/AGENTS.block.md`. + Rationale: the router owns activation scope. The upstream plugin continues to own its workflow. +- Decision: validate all candidates before the first mutation. Stop on the first operation failure without retry or a false completion message. + Rationale: this minimizes partial work while preserving the repository's accepted non-transactional model. +- Decision: evaluate checkout identity and tracked target modifications relative to `kit.py`'s `ROOT`, not the caller's current directory. + Rationale: an absolute launcher path must work from another directory. +- Decision: write the source lock only after the selected installation succeeds. + Rationale: a failed installation remains retryable, and the installed lock identifies a prior successful source write. +- Decision: no Plan History record applies to this task. The branch `cpk-062-managed-update-application-12ui` is based on reviewed CPK-061 commit `37a3bc69fc1ff09e6fc372320bafd29d4d867e2f`. + Rationale: the user continued from that local reviewed result and did not request publication. + +## Outcomes & Retrospective + +Users can now check or apply all managed updates through short Bash or PowerShell commands. Apply validates complete candidates, updates the source lock, and reconciles the selected installation even when the source identities already match. + +The toolkit installs or refreshes official Ponytail and 12UI plugins through their exact native marketplaces. The installed router limits 12UI to website projects. The update model remains explicit and non-transactional: it stops after the first error and can be run again after the user corrects the cause. + +The main design lesson is that source identity and installed state are separate facts. End-to-end checks must prove both through the real command and launcher paths. + +## Context and Orientation + +`kit.py` is the Python command owner. `check_updates` reads `upstream.lock.json`, queries official provider endpoints, and reports comparable identities. `install_core` stages locked skills, installs owned files, configures native plugins, writes the installed manifest, and installs global instructions. `doctor` checks the selected installation. + +`upstream.lock.json` is the authoritative managed inventory. `runtime_tools` contains RepoWise, uv, and native plugins. `skills` contains copied third-party skill packages. `optional_tools` is informative and is outside this task. + +`assets/AGENTS.block.md` is the live global router installed into the user's selected Codex home. `MANIFEST.sha256` lists distribution files. `tests/test_kit.py`, `tests/test_ponytail.py`, and the new focused update tests exercise installation and update behavior. + +## Product Boundary + +Follow [Scope boundaries](). The authoritative request is to apply reported managed updates, provide simpler Bash and PowerShell actions, add 12UI, and use 12UI only for website projects. + +The toolkit owns managed inventory selection, candidate validation, source-lock writes, installed toolkit files and records, exact native marketplace selection, global router text, launchers, Doctor checks, and terminal status. Official HTTP providers, installer internals, Codex plugin storage, and the internal Ponytail and 12UI workflows are opaque external owners. + +Normal use is one personal user and one writer, online official provider reads, a normal Git checkout, clean tracked update targets, and a selected installation whose manifest owns its targets. A managed plugin can be absent or installed from its official source. + +The hard ceiling excludes optional tools, background or scheduled updates, version-order policy, general package management, new dependencies, retry, rollback, recovery, permission or interruption models, arbitrary repository instruction overrides, plugin storage parsing, modified upstream skills, and claims about future model behavior. + +## Scenario Proof + +The accepted behavioral boundaries are: + +- B1: Check and apply cover each managed component exactly once, including 12UI. They exclude optional tools and unrelated plugins. +- B2: Check remains explicit and read-only. It reports comparable identities honestly and does not invent a baseline for native plugins. +- B3: Apply changes the selected managed installation. Runtime versions, complete skill files, verification identities, records, and affected Doctor results must agree. +- B4: Plugin refresh targets only the exact managed marketplaces in the selected Codex home. It preserves enablement, settings, unrelated marketplaces, and the default home. +- B5: Toolkit instructions permit 12UI only for website design. Installation and refresh must not add a contradictory broader toolkit instruction. +- B6: Ordinary installation adds an absent official 12UI plugin. It reuses an existing official disabled plugin without re-enabling it. +- B7: Each launcher resolves its sibling `kit.py`, preserves arguments and destinations, and returns the Python command status from another working directory. +- B8: The first operation error stops the action after one attempt. The command returns a failure status and prints no partial check report or false apply success. + +Follow [Scenario discrimination](), [Owner composition]( str: return value -def check_updates() -> None: +def release_identity(repository: str) -> str: + url = github_api_url(repository, "releases/latest") + data = fetch_json(url) + return required_string( + data.get("tag_name") if isinstance(data, dict) else None, + "tag_name", + url, + ) + + +def managed_candidates(*, include_payloads: bool = False) -> tuple[dict[str, Any], list[str], dict[str, bytes]]: lock = load_lock() + candidate = copy.deepcopy(lock) lines: list[str] = [] + payloads: dict[str, bytes] = {} for name, component in lock["runtime_tools"].items(): repository = component["repository"] @@ -276,23 +283,30 @@ def check_updates() -> None: pinned = component["version"] state = "same identity" if upstream == pinned else "upstream differs" lines.append(f"{name}: toolkit pin {pinned}; upstream release {upstream}; {state}") - elif name in {"uv", "ponytail"}: - url = github_api_url(repository, "releases/latest") - data = fetch_json(url) - upstream = required_string( - data.get("tag_name") if isinstance(data, dict) else None, - "tag_name", - url, - ) - if name == "ponytail": + candidate["runtime_tools"][name]["version"] = upstream + elif name == "uv": + upstream = release_identity(repository) + comparable = upstream[1:] if upstream.startswith("v") else upstream + pinned = component["version"] + state = "same identity" if comparable == pinned else "upstream differs" + lines.append(f"{name}: toolkit pin {pinned}; upstream release {upstream}; {state}") + candidate["runtime_tools"][name]["version"] = comparable + if include_payloads: + installers = {} + for platform, suffix in (("posix", "install.sh"), ("windows", "install.ps1")): + url = f"https://astral.sh/uv/{comparable}/{suffix}" + data = download_url(url) + payloads[f"uv:{platform}"] = data + installers[platform] = {"url": url, "sha256": hashlib.sha256(data).hexdigest()} + candidate["runtime_tools"][name]["installers"] = installers + elif component.get("plugin_id"): + upstream = release_identity(repository) + if name in {"ponytail", "12ui"}: lines.append( f"{name}: Codex-owned plugin; upstream release {upstream}; use the Codex plugin manager" ) else: - pinned = component["version"] - comparable = upstream[1:] if upstream.startswith("v") else upstream - state = "same identity" if comparable == pinned else "upstream differs" - lines.append(f"{name}: toolkit pin {pinned}; upstream release {upstream}; {state}") + raise KitError(f"No update source is defined for managed native plugin: {name}") else: raise KitError(f"No update source is defined for managed runtime tool: {name}") @@ -307,7 +321,43 @@ def check_updates() -> None: pinned = component["commit"] state = "same identity" if upstream == pinned else "upstream differs" lines.append(f"{name}: toolkit revision {pinned}; upstream revision {upstream}; {state}") + candidate["skills"][name]["commit"] = upstream + if include_payloads: + tree_url = github_api_url(component["repository"], f"git/trees/{upstream}?recursive=1") + tree_data = fetch_json(tree_url) + tree = tree_data.get("tree") if isinstance(tree_data, dict) else None + if not isinstance(tree_data, dict) or tree_data.get("truncated") is not False or not isinstance(tree, list): + raise KitError(f"Component update source did not return a complete Git tree: {tree_url}") + root = component["root"].rstrip("/") + "/" + selected: dict[str, str] = {} + for item in tree: + if not isinstance(item, dict) or item.get("type") != "blob": + continue + path = item.get("path") + blob = item.get("sha") + if isinstance(path, str) and isinstance(blob, str) and path.startswith(root): + selected[path] = blob + license_path = component["license_path"] + for item in tree: + if isinstance(item, dict) and item.get("type") == "blob" and item.get("path") == license_path: + selected[license_path] = required_string(item.get("sha"), "license Git blob", tree_url) + break + if license_path not in selected or not any(path.startswith(root) for path in selected): + raise KitError(f"Selected skill package or license is missing from {tree_url}") + files = [] + for path, blob in sorted(selected.items()): + destination = f"{name}/LICENSE" if path == license_path else f"{name}/{path.removeprefix(root)}" + data = download_file(raw_github_url(component["repository"], upstream, path), blob) + payloads[f"skill:{destination}"] = data + files.append({"path": path, "destination": destination, "git_blob_sha1": blob}) + candidate["skills"][name]["files"] = files + + candidate["generated_on"] = time.strftime("%Y-%m-%d") + return candidate, lines, payloads + +def check_updates() -> None: + _, lines, _ = managed_candidates() print("Managed component updates:") for line in lines: print(f"- {line}") @@ -322,21 +372,25 @@ def raw_github_url(repository: str, commit: str, source_path: str) -> str: def download_file(url: str, expected_blob: str) -> bytes: - request = urllib.request.Request(url, headers={"User-Agent": f"{KIT_ID}/{KIT_VERSION}"}) - try: - with urllib.request.urlopen(request, timeout=60) as response: - data = response.read() - except (urllib.error.URLError, TimeoutError) as exc: - raise KitError(f"Could not download pinned upstream file:\n{url}\n{exc}") from exc + data = download_url(url) actual = git_blob_sha1(data) if actual != expected_blob: raise KitError( - "Downloaded upstream file did not match the pinned Git blob. " + "Downloaded upstream file did not match the selected Git blob. " f"Expected {expected_blob}, got {actual}: {url}" ) return data +def download_url(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": f"{KIT_ID}/{KIT_VERSION}"}) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + except (urllib.error.URLError, TimeoutError) as exc: + raise KitError(f"Could not download selected upstream file:\n{url}\n{exc}") from exc + + def download_sha256(url: str, expected_sha256: str) -> bytes: request = urllib.request.Request(url, headers={"User-Agent": f"{KIT_ID}/{KIT_VERSION}"}) try: @@ -353,15 +407,23 @@ def download_sha256(url: str, expected_sha256: str) -> bytes: return data -def stage_upstream_skills(destination: Path) -> dict[str, dict[str, Any]]: - lock = load_lock() +def stage_upstream_skills( + destination: Path, + lock: dict[str, Any] | None = None, + payloads: dict[str, bytes] | None = None, +) -> dict[str, dict[str, Any]]: + lock = lock or load_lock() records: dict[str, dict[str, Any]] = {} for skill_name, skill in lock["skills"].items(): repo = skill["repository"] commit = skill["commit"] skill_root = destination / skill_name for item in skill["files"]: - data = download_file(raw_github_url(repo, commit, item["path"]), item["git_blob_sha1"]) + data = ( + payloads[f"skill:{item['destination']}"] + if payloads is not None + else download_file(raw_github_url(repo, commit, item["path"]), item["git_blob_sha1"]) + ) target = destination / item["destination"] target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) @@ -373,6 +435,51 @@ def stage_upstream_skills(destination: Path) -> dict[str, dict[str, Any]]: return records +def require_clean_update_source(paths: InstallPaths) -> None: + root = resolve_git_root(ROOT) + if root != ROOT.resolve(): + raise KitError(f"Update source is not the toolkit checkout: {ROOT}") + tracked = run( + ["git", "ls-files", "--error-unmatch", "upstream.lock.json"], + cwd=ROOT, + check=False, + timeout=20, + ) + changed = run( + ["git", "diff", "--quiet", "HEAD", "--", "upstream.lock.json"], + cwd=ROOT, + check=False, + timeout=20, + ) + installed_lock = paths.install_root / "upstream.lock.json" + previous_apply = ( + changed.returncode == 1 + and installed_lock.is_file() + and installed_lock.read_bytes() == (ROOT / "upstream.lock.json").read_bytes() + ) + if tracked.returncode != 0 or (changed.returncode != 0 and not previous_apply): + raise KitError("Refusing to overwrite a modified or untracked upstream.lock.json.") + + +def apply_updates(args: argparse.Namespace, paths: InstallPaths) -> None: + candidate, _, payloads = managed_candidates(include_payloads=True) + plugin_states = { + "ponytail": ponytail_status(paths), + "12ui": twelve_ui_status(paths), + } + require_clean_update_source(paths) + install_core( + args, + paths, + prepared_lock=candidate, + prepared_payloads=payloads, + refresh_plugins=True, + force_runtime=True, + plugin_states=plugin_states, + ) + write_file(ROOT / "upstream.lock.json", json.dumps(candidate, indent=2, sort_keys=True) + "\n") + + # ---------- skills ---------- @@ -594,25 +701,31 @@ def codex_plugin(paths: InstallPaths, *args: str) -> dict[str, Any]: return data -def official_ponytail_source(source: str) -> bool: +def official_plugin_source(source: str, repository: str) -> bool: + expected = repository.rstrip("/").removesuffix(".git").lower() + short = expected.removeprefix("https://github.com/") return source.rstrip("/").removesuffix(".git").lower() in { - "dietrichgebert/ponytail", - "https://github.com/dietrichgebert/ponytail", - "git@github.com:dietrichgebert/ponytail", - "ssh://git@github.com/dietrichgebert/ponytail", + short, + expected, + f"git@github.com:{short}", + f"ssh://git@github.com/{short}", } -def ponytail_status(paths: InstallPaths) -> tuple[bool, dict[str, Any] | None]: +def native_plugin_status(paths: InstallPaths, name: str) -> tuple[bool, dict[str, Any] | None]: + component = load_lock()["runtime_tools"][name] + marketplace_name = component["marketplace_name"] + repository = component["repository"] + plugin_id = component["plugin_id"] try: run(["node", "--version"], timeout=20) except KitError as exc: - raise KitError(f"Ponytail requires Node.js on PATH. {exc}") from exc + raise KitError(f"{name} requires Node.js on PATH. {exc}") from exc try: marketplaces = codex_plugin(paths, "marketplace", "list").get("marketplaces") - installed = codex_plugin(paths, "list", "--marketplace", "ponytail").get("installed") + installed = codex_plugin(paths, "list", "--marketplace", marketplace_name).get("installed") except KitError as exc: - raise KitError(f"Ponytail requires working native Codex plugin commands. {exc}") from exc + raise KitError(f"{name} requires working native Codex plugin commands. {exc}") from exc if not isinstance(marketplaces, list) or not isinstance(installed, list): raise KitError("Codex plugin listings are missing marketplace or installation records.") marketplace_found = False @@ -620,24 +733,50 @@ def ponytail_status(paths: InstallPaths) -> tuple[bool, dict[str, Any] | None]: for item in marketplaces + installed: if not isinstance(item, dict): raise KitError("Codex plugin listing contains an invalid record.") - if item.get("name") != "ponytail": + if item.get("name") not in {marketplace_name, name, plugin_id.split("@", 1)[0]}: continue source = item.get("marketplaceSource", {}) - if not isinstance(source, dict) or source.get("sourceType") != "git" or not official_ponytail_source(str(source.get("source", ""))): - raise KitError("Ponytail marketplace conflicts with the official DietrichGebert/ponytail source.") + if not isinstance(source, dict) or source.get("sourceType") != "git" or not official_plugin_source(str(source.get("source", "")), repository): + raise KitError(f"{name} marketplace conflicts with the official {repository} source.") if item in marketplaces: marketplace_found = True - elif item.get("pluginId") == "ponytail@ponytail" and item.get("installed") is True: + elif item.get("pluginId") == plugin_id and item.get("installed") is True: package = item.get("source", {}) - if not isinstance(package, dict) or package.get("source") != "git" or not official_ponytail_source(str(package.get("url", ""))): - raise KitError("Installed Ponytail plugin conflicts with the official DietrichGebert/ponytail source.") + package_ok = isinstance(package, dict) and ( + package.get("source") == "local" + if name == "12ui" + else package.get("source") == "git" + and official_plugin_source(str(package.get("url", "")), repository) + ) + if not package_ok: + raise KitError(f"Installed {name} plugin conflicts with the official {repository} source.") plugin = item return marketplace_found, plugin -def install_core(args: argparse.Namespace, paths: InstallPaths) -> None: +def ponytail_status(paths: InstallPaths) -> tuple[bool, dict[str, Any] | None]: + return native_plugin_status(paths, "ponytail") + + +def twelve_ui_status(paths: InstallPaths) -> tuple[bool, dict[str, Any] | None]: + return native_plugin_status(paths, "12ui") + + +def install_core( + args: argparse.Namespace, + paths: InstallPaths, + *, + prepared_lock: dict[str, Any] | None = None, + prepared_payloads: dict[str, bytes] | None = None, + refresh_plugins: bool = False, + force_runtime: bool = False, + plugin_states: dict[str, tuple[bool, dict[str, Any] | None]] | None = None, +) -> None: paths.codex_home.mkdir(parents=True, exist_ok=True) - marketplace_found, ponytail = ponytail_status(paths) + plugin_states = plugin_states or { + "ponytail": ponytail_status(paths), + "12ui": twelve_ui_status(paths), + } paths.install_root.parent.mkdir(parents=True, exist_ok=True) paths.install_root.mkdir(parents=True, exist_ok=True) previous_manifest = json_load(manifest_path(paths), {}) @@ -669,7 +808,11 @@ def install_core(args: argparse.Namespace, paths: InstallPaths) -> None: try: skill_stage = staging / "skills" skill_stage.mkdir(parents=True) - upstream_records = stage_upstream_skills(skill_stage) + upstream_records = ( + stage_upstream_skills(skill_stage, prepared_lock, prepared_payloads) + if prepared_lock is not None or prepared_payloads is not None + else stage_upstream_skills(skill_stage) + ) copy_custom_skills(skill_stage) conflicts = [ paths.skills_home / name @@ -684,18 +827,36 @@ def install_core(args: argparse.Namespace, paths: InstallPaths) -> None: if plan.exists() and not plan_owned: raise KitError(f"This plans file already exists and is not owned by this kit: {plan}") validate_global_repowise_config(paths) - if ponytail is None: - if not marketplace_found: - codex_plugin(paths, "marketplace", "add", "DietrichGebert/ponytail") - codex_plugin(paths, "add", "ponytail@ponytail") - uv, repowise = ensure_repowise_runtime(paths) + lock = prepared_lock or load_lock() + for name, (marketplace_found, plugin) in plugin_states.items(): + component = lock["runtime_tools"][name] + if plugin is None: + if not marketplace_found: + codex_plugin(paths, "marketplace", "add", component["marketplace"]) + elif refresh_plugins: + codex_plugin(paths, "marketplace", "upgrade", component["marketplace_name"]) + codex_plugin(paths, "add", component["plugin_id"]) + elif refresh_plugins: + codex_plugin(paths, "marketplace", "upgrade", component["marketplace_name"]) + uv, repowise = ensure_repowise_runtime( + paths, + force=force_runtime, + uv_installer=(prepared_payloads or {}).get(f"uv:{'windows' if is_windows() else 'posix'}"), + repowise_version=lock["runtime_tools"]["repowise"]["version"], + ) install_runtime(paths) hook_root = paths.install_root / "hooks" remove_path(hook_root) hook_root.mkdir() shutil.copy2(ROOT / "assets" / "hooks" / "session_start.py", hook_root) - shutil.copy2(ROOT / "upstream.lock.json", paths.install_root / "upstream.lock.json") + if prepared_lock is None: + shutil.copy2(ROOT / "upstream.lock.json", paths.install_root / "upstream.lock.json") + else: + write_file( + paths.install_root / "upstream.lock.json", + json.dumps(prepared_lock, indent=2, sort_keys=True) + "\n", + ) shutil.copy2(ROOT / "LICENSE", paths.install_root / "LICENSE") records = install_skills(paths, skill_stage, owned_names) @@ -788,25 +949,26 @@ def runtime_executable(name: str) -> str: def find_runtime_command(paths: InstallPaths, name: str) -> str | None: executable = runtime_executable(name) + manifest = json_load(manifest_path(paths), {}) + recorded = manifest.get(name) if isinstance(manifest, dict) else None + if isinstance(recorded, str) and Path(recorded).is_file(): + return recorded found = shutil.which(executable) if found: return found candidate = user_bin(paths) / executable - if candidate.is_file(): - return str(candidate) - manifest = json_load(manifest_path(paths), {}) - recorded = manifest.get(name) if isinstance(manifest, dict) else None - return recorded if isinstance(recorded, str) and Path(recorded).is_file() else None + return str(candidate) if candidate.is_file() else None -def ensure_uv(paths: InstallPaths) -> str: +def ensure_uv(paths: InstallPaths, *, force: bool = False, installer_bytes: bytes | None = None) -> str: + component = load_lock()["runtime_tools"]["uv"] + version = component["version"] uv = find_runtime_command(paths, "uv") - if uv: + if uv and not force: return uv if is_windows(): - url = UV_WINDOWS_INSTALLER_URL - checksum = UV_WINDOWS_INSTALLER_SHA256 + installer_record = component["installers"]["windows"] installer = [ "pwsh", "-NoProfile", @@ -817,10 +979,13 @@ def ensure_uv(paths: InstallPaths) -> str: "& ([scriptblock]::Create([Console]::In.ReadToEnd()))", ] else: - url = UV_INSTALLER_URL - checksum = UV_INSTALLER_SHA256 + installer_record = component["installers"]["posix"] installer = ["sh"] - script = download_sha256(url, checksum).decode("utf-8") + script = ( + installer_bytes + if installer_bytes is not None + else download_sha256(installer_record["url"], installer_record["sha256"]) + ).decode("utf-8") destination = user_bin(paths) env = os.environ.copy() env["HOME"] = str(paths.home) @@ -829,13 +994,20 @@ def ensure_uv(paths: InstallPaths) -> str: run(installer, env=env, input_text=script, timeout=300) uv = destination / runtime_executable("uv") if not uv.is_file(): - raise KitError(f"uv {UV_VERSION} installation did not create {uv}") + raise KitError(f"uv {version} installation did not create {uv}") return str(uv) -def ensure_repowise(paths: InstallPaths, uv: str) -> str: +def ensure_repowise( + paths: InstallPaths, + uv: str, + *, + force_install: bool = False, + version_expected: str | None = None, +) -> str: + version_expected = version_expected or load_lock()["runtime_tools"]["repowise"]["version"] repowise = find_runtime_command(paths, "repowise") - force = False + force = force_install if repowise: try: result = run([repowise, "--version"], check=False, timeout=20) @@ -843,7 +1015,7 @@ def ensure_repowise(paths: InstallPaths, uv: str) -> str: result = None detail = ((result.stdout or result.stderr).strip() if result else "") version = detail.splitlines()[0] if detail else None - if result and result.returncode == 0 and version and REPOWISE_VERSION in version: + if result and result.returncode == 0 and version and version_expected in version: return repowise manifest = json_load(manifest_path(paths), {}) recorded = manifest.get("repowise") if isinstance(manifest, dict) else None @@ -857,7 +1029,7 @@ def ensure_repowise(paths: InstallPaths, uv: str) -> str: if not force: raise KitError( f"Found a different RepoWise command at {repowise}: {version or 'unknown version'}. " - f"Install RepoWise {REPOWISE_VERSION} or remove that command before retrying." + f"Install RepoWise {version_expected} or remove that command before retrying." ) destination = user_bin(paths) @@ -868,18 +1040,29 @@ def ensure_repowise(paths: InstallPaths, uv: str) -> str: command = [uv, "tool", "install"] if force: command.append("--force") - command.append(f"repowise=={REPOWISE_VERSION}") + command.append(f"repowise=={version_expected}") run(command, env=env, timeout=1800) run([uv, "tool", "update-shell"], env=env, timeout=60) repowise = destination / runtime_executable("repowise") if not repowise.is_file(): - raise KitError(f"RepoWise {REPOWISE_VERSION} installation did not create {repowise}") + raise KitError(f"RepoWise {version_expected} installation did not create {repowise}") return str(repowise) -def ensure_repowise_runtime(paths: InstallPaths) -> tuple[str, str]: - uv = ensure_uv(paths) - return uv, ensure_repowise(paths, uv) +def ensure_repowise_runtime( + paths: InstallPaths, + *, + force: bool = False, + uv_installer: bytes | None = None, + repowise_version: str | None = None, +) -> tuple[str, str]: + uv = ensure_uv(paths, force=force, installer_bytes=uv_installer) + return uv, ensure_repowise( + paths, + uv, + force_install=force, + version_expected=repowise_version, + ) def repowise_init_args(prose: bool) -> list[str]: @@ -1137,19 +1320,26 @@ def check(condition: bool, label: str, detail: str = "") -> bool: def doctor(args: argparse.Namespace, paths: InstallPaths) -> int: ok = True - try: - _, ponytail = ponytail_status(paths) - detail = ( - f"{ponytail.get('version', 'unknown version')} ({'enabled' if ponytail.get('enabled') else 'disabled by user'})" - if ponytail else "not installed; run the toolkit installer" - ) - ok &= check(ponytail is not None, "Ponytail native plugin and Node.js", detail) - except KitError as exc: - ok &= check(False, "Ponytail native plugin and Node.js", str(exc)) + for label, status in (("Ponytail", ponytail_status), ("12UI", twelve_ui_status)): + try: + _, plugin = status(paths) + detail = ( + f"{plugin.get('version', 'unknown version')} ({'enabled' if plugin.get('enabled') else 'disabled by user'})" + if plugin else "not installed; run the toolkit installer" + ) + ok &= check(plugin is not None, f"{label} native plugin and Node.js", detail) + except KitError as exc: + ok &= check(False, f"{label} native plugin and Node.js", str(exc)) manifest = json_load(manifest_path(paths), {}) ok &= check(isinstance(manifest, dict) and bool(manifest), "core install manifest", str(manifest_path(paths))) installed_version = manifest.get("kit_version") if isinstance(manifest, dict) else None ok &= check(installed_version == KIT_VERSION, "kit version", str(installed_version or "unknown")) + installed_lock = paths.install_root / "upstream.lock.json" + ok &= check( + read_text(installed_lock) == read_text(ROOT / "upstream.lock.json"), + "managed component lock", + str(installed_lock), + ) for name in ALL_SKILLS: dest = paths.skills_home / name ok &= check(dest.exists(), f"skill {name}", str(dest)) @@ -1200,13 +1390,17 @@ def doctor(args: argparse.Namespace, paths: InstallPaths) -> int: ) uv = find_runtime_command(paths, "uv") ok &= check(bool(uv), "uv", uv or "not found") + if uv: + uv_version = command_version([uv, "--version"]) + ok &= check(bool(uv_version), "uv version", uv_version or "unavailable") repowise = find_runtime_command(paths, "repowise") ok &= check(bool(repowise), "RepoWise command", repowise or "not found") if repowise: + repowise_version = load_lock()["runtime_tools"]["repowise"]["version"] version = command_version([repowise, "--version"]) ok &= check( - bool(version and REPOWISE_VERSION in version), - f"RepoWise {REPOWISE_VERSION}", + bool(version and repowise_version in version), + f"RepoWise {repowise_version}", version or "unavailable", ) @@ -1303,6 +1497,16 @@ def build_parser() -> argparse.ArgumentParser: ), ) + apply = sub.add_parser( + "apply-updates", + help="Apply every managed component update.", + description=( + "Read and validate every managed upstream candidate, update the source lock, " + "and reconcile the selected toolkit installation." + ), + ) + add_path_options(apply) + diag = sub.add_parser("doctor", help="Check the core kit and repository integration.") add_path_options(diag) diag.add_argument("--repo", help="Also check RepoWise in this repository.") @@ -1333,6 +1537,10 @@ def main(argv: list[str] | None = None) -> int: if args.command == "check-updates": check_updates() return 0 + if args.command == "apply-updates": + apply_updates(args, paths) + print("Managed component updates applied.") + return 0 if args.command == "install": install_core(args, paths) @@ -1341,8 +1549,8 @@ def main(argv: list[str] | None = None) -> int: print(f"Global instructions: {paths.codex_home / 'AGENTS.md'}") print(f"Global ExecPlan rules: {plans_path(paths)}") print(f"Hooks: {paths.codex_home / 'config.toml'}") - print("Ponytail: native Codex plugin; existing versions and settings preserved.") - print("Open a new Codex session and use `/hooks` to review and trust toolkit and Ponytail hooks.") + print("Ponytail and 12UI: native Codex plugins; existing versions and settings preserved.") + print("Open a new Codex session and use `/hooks` to review and trust toolkit and plugin hooks.") if args.repo: repo_args = argparse.Namespace(repo=args.repo, prose=args.repowise_prose) setup_repo(repo_args, paths) diff --git a/tests/test_kit.py b/tests/test_kit.py index 5aa26de..a7d3d3b 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -66,10 +66,13 @@ def setUp(self): self.runtime.start() self.ponytail = mock.patch.object(kit, "ponytail_status", return_value=(True, {"installed": True})) self.ponytail.start() + self.twelve_ui = mock.patch.object(kit, "twelve_ui_status", return_value=(True, {"installed": True})) + self.twelve_ui.start() def tearDown(self): self.runtime.stop() self.ponytail.stop() + self.twelve_ui.stop() def test_run_decodes_utf8_subprocess_output(self): result = kit.run( @@ -1379,14 +1382,23 @@ def test_windows_runtime_discovery_uses_executable_suffix(self): self.assertEqual(kit.find_runtime_command(paths, "uv"), str(command)) which.assert_called_once_with("uv.exe") + def test_runtime_discovery_prefers_selected_manifest_command(self): + with tempfile.TemporaryDirectory() as temp: + paths = self.paths(Path(temp)) + selected = Path(temp) / "selected-uv" + selected.write_text("uv") + kit.write_file(kit.manifest_path(paths), json.dumps({"uv": str(selected)})) + with mock.patch.object(kit.shutil, "which", return_value="/external/uv"): + self.assertEqual(kit.find_runtime_command(paths, "uv"), str(selected)) + def test_runtime_pins_match_lock_file(self): runtime = json.loads((ROOT / "upstream.lock.json").read_text())["runtime_tools"] - self.assertEqual(runtime["uv"]["version"], kit.UV_VERSION) - self.assertEqual(runtime["uv"]["installers"]["posix"]["url"], kit.UV_INSTALLER_URL) - self.assertEqual(runtime["uv"]["installers"]["posix"]["sha256"], kit.UV_INSTALLER_SHA256) - self.assertEqual(runtime["uv"]["installers"]["windows"]["url"], kit.UV_WINDOWS_INSTALLER_URL) - self.assertEqual(runtime["uv"]["installers"]["windows"]["sha256"], kit.UV_WINDOWS_INSTALLER_SHA256) - self.assertEqual(runtime["repowise"]["version"], kit.REPOWISE_VERSION) + version = runtime["uv"]["version"] + self.assertEqual(runtime["uv"]["installers"]["posix"]["url"], f"https://astral.sh/uv/{version}/install.sh") + self.assertEqual(runtime["uv"]["installers"]["windows"]["url"], f"https://astral.sh/uv/{version}/install.ps1") + self.assertRegex(runtime["uv"]["installers"]["posix"]["sha256"], r"^[0-9a-f]{64}$") + self.assertRegex(runtime["uv"]["installers"]["windows"]["sha256"], r"^[0-9a-f]{64}$") + self.assertRegex(runtime["repowise"]["version"], r"^\d+\.\d+\.\d+$") def test_missing_uv_runs_verified_installer_once(self): with tempfile.TemporaryDirectory() as temp: @@ -1407,7 +1419,8 @@ def fake_run(command, **kwargs): ) as download, mock.patch.object(kit, "run", side_effect=fake_run) as run: uv = kit.ensure_uv(paths) self.assertEqual(uv, str(paths.home / ".local" / "bin" / "uv")) - download.assert_called_once_with(kit.UV_INSTALLER_URL, kit.UV_INSTALLER_SHA256) + installer = kit.load_lock()["runtime_tools"]["uv"]["installers"]["posix"] + download.assert_called_once_with(installer["url"], installer["sha256"]) run.assert_called_once() def test_missing_windows_uv_runs_verified_powershell_installer_once(self): @@ -1440,9 +1453,8 @@ def fake_run(command, **kwargs): ) as download, mock.patch.object(kit, "run", side_effect=fake_run) as run: uv = kit.ensure_uv(paths) self.assertEqual(uv, str(paths.home / ".local" / "bin" / "uv.exe")) - download.assert_called_once_with( - kit.UV_WINDOWS_INSTALLER_URL, kit.UV_WINDOWS_INSTALLER_SHA256 - ) + installer = kit.load_lock()["runtime_tools"]["uv"]["installers"]["windows"] + download.assert_called_once_with(installer["url"], installer["sha256"]) run.assert_called_once() @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is not installed") @@ -1570,6 +1582,8 @@ def fake_run(command, **_kwargs): kit, "stage_upstream_skills", side_effect=fake_stage ), mock.patch.object(kit, "run", side_effect=fake_run), mock.patch.object( kit, "ponytail_status", return_value=(True, {"installed": True}) + ), mock.patch.object( + kit, "twelve_ui_status", return_value=(True, {"installed": True}) ): kit.install_core(Namespace(), paths) @@ -2121,6 +2135,9 @@ def responses(self): runtime = lock["runtime_tools"] skills = lock["skills"] return { + kit.github_api_url(runtime["12ui"]["repository"], "releases/latest"): { + "tag_name": "design-v0.2.107" + }, kit.github_api_url(runtime["ponytail"]["repository"], "releases/latest"): { "tag_name": "v4.12.0" }, @@ -2173,11 +2190,34 @@ def test_check_updates_reports_complete_managed_inventory(self): self.assertEqual(status, 0) self.assertEqual(stderr, "") - self.assertEqual(len(calls), 5) + self.assertEqual(len(calls), len(responses)) self.assertEqual(set(calls), set(responses)) - for name in (*kit.load_lock()["runtime_tools"], *kit.load_lock()["skills"]): + lock = kit.load_lock() + expected_lines = [ + "Managed component updates:", + "- 12ui: Codex-owned plugin; upstream release design-v0.2.107; use the Codex plugin manager", + "- ponytail: Codex-owned plugin; upstream release v4.12.0; use the Codex plugin manager", + ( + f"- repowise: toolkit pin {lock['runtime_tools']['repowise']['version']}; " + f"upstream release {lock['runtime_tools']['repowise']['version']}; same identity" + ), + ( + f"- uv: toolkit pin {lock['runtime_tools']['uv']['version']}; " + f"upstream release v{lock['runtime_tools']['uv']['version']}; same identity" + ), + ( + f"- neuroarxiv: toolkit revision {lock['skills']['neuroarxiv']['commit']}; " + f"upstream revision {lock['skills']['neuroarxiv']['commit']}; same identity" + ), + ( + f"- simple-english: toolkit revision {lock['skills']['simple-english']['commit']}; " + f"upstream revision {'f' * 40}; upstream differs" + ), + ] + self.assertEqual(stdout, "\n".join(expected_lines) + "\n") + for name in (*lock["runtime_tools"], *lock["skills"]): self.assertEqual(stdout.count(f"- {name}:"), 1) - for name in kit.load_lock()["optional_tools"]: + for name in lock["optional_tools"]: self.assertNotIn(name, stdout) self.assertFalse(any(name in url for url in calls)) self.assertIn("simple-english:", stdout) @@ -2243,6 +2283,9 @@ def test_check_updates_reports_codex_owned_ponytail(self): def test_check_updates_stops_once_on_request_error(self): lock_before = (ROOT / "upstream.lock.json").read_bytes() + success, complete, error, _ = self.run_check(self.responses()) + self.assertEqual((success, error), (0, "")) + self.assertTrue(complete.startswith("Managed component updates:\n")) uv_url = kit.github_api_url( kit.load_lock()["runtime_tools"]["uv"]["repository"], "releases/latest" ) @@ -2263,7 +2306,8 @@ def test_check_updates_stops_once_on_request_error(self): self.assertEqual(stderr.count("ERROR:"), 1) self.assertIn(detail, stderr) self.assertEqual(calls[-1], uv_url) - self.assertEqual(len(calls), 3) + self.assertEqual(calls.count(uv_url), 1) + self.assertNotIn("Managed component updates:", stderr) write_file.assert_not_called() run.assert_not_called() codex_plugin.assert_not_called() @@ -2670,6 +2714,11 @@ def test_execplan_is_the_only_durable_task_model(self): self.assertNotIn("Task Brief", preflight) self.assertNotIn("task-brief", kit.ALL_SKILLS) + def test_12ui_router_is_website_only(self): + rules = (ROOT / "assets" / "AGENTS.block.md").read_text() + self.assertEqual(rules.count("Use 12ui-design only for website projects."), 2) + self.assertIn("Do not use it for native apps or other UI work.", rules) + def test_rule_owners_are_unique(self): live = [ ROOT / ".agent" / "PLANS.md", @@ -3799,7 +3848,7 @@ def test_windows_launchers_and_runtime_are_complete_distribution_artifacts(self) shell = {path.stem for path in shell_paths} powershell = {path.stem for path in powershell_paths} self.assertEqual( - shell, {"install", "uninstall", "setup-repo", "doctor", "run-tests"} + shell, {"install", "uninstall", "setup-repo", "doctor", "run-tests", "check-updates", "apply-updates"} ) self.assertEqual(powershell, shell) self.assertTrue(all(path.parent == Path(".") for path in shell_paths | powershell_paths)) diff --git a/tests/test_ponytail.py b/tests/test_ponytail.py index 109a6cd..7882482 100644 --- a/tests/test_ponytail.py +++ b/tests/test_ponytail.py @@ -47,25 +47,40 @@ def state(self, home=None): def write_state(self, state, home=None): kit.write_file(self.state_path(home), json.dumps(state, sort_keys=True)) - def seed_plugin(self, *, enabled=True, source=None, home=None): - source = source or self.SOURCE + def seed_plugin(self, *, enabled=True, source=None, home=None, name="ponytail"): + source = source or (self.SOURCE if name == "ponytail" else "https://github.com/just-every/12ui-plugin.git") + marketplace = "ponytail" if name == "ponytail" else "12ui-plugin" + plugin_name = "ponytail" if name == "ponytail" else "12ui-design" + plugin_id = f"{plugin_name}@{marketplace}" marketplace_source = {"sourceType": "git", "source": source} + package_source = ( + {"source": "git", "url": source, "ref": "user-ref"} + if name == "ponytail" + else {"source": "local", "path": "."} + ) plugin = { - "name": "ponytail", "pluginId": "ponytail@ponytail", "installed": True, + "name": plugin_name, "pluginId": plugin_id, "installed": True, "version": "user-version", "enabled": enabled, - "source": {"source": "git", "url": source, "ref": "user-ref"}, + "source": package_source, "marketplaceSource": marketplace_source, } - self.write_state({"marketplaces": [{"name": "ponytail", "marketplaceSource": marketplace_source}], "installed": [plugin]}, home) + state = self.state(home) + state["marketplaces"] = [item for item in state["marketplaces"] if item.get("name") != marketplace] + state["installed"] = [item for item in state["installed"] if item.get("pluginId") != plugin_id] + state["marketplaces"].append({"name": marketplace, "marketplaceSource": marketplace_source}) + state["installed"].append(plugin) + self.write_state(state, home) home = home or self.paths.codex_home config = home / "config.toml" - text = re.sub(r'(?m)^\[plugins\."ponytail@ponytail"\]\nenabled = (?:true|false)\n', '', kit.read_text(config)) - kit.write_file(config, text + '\n[plugins."ponytail@ponytail"]\nenabled = ' + str(enabled).lower() + '\n') - kit.write_file(home / "plugins/data/ponytail-ponytail/.ponytail-active", "ultra") - kit.write_file(self.paths.home / ".config/ponytail/config.json", '{"defaultMode":"off"}') + pattern = rf'(?m)^\[plugins\."{re.escape(plugin_id)}"\]\nenabled = (?:true|false)\n' + text = re.sub(pattern, '', kit.read_text(config)) + kit.write_file(config, text + f'\n[plugins."{plugin_id}"]\nenabled = ' + str(enabled).lower() + '\n') + if name == "ponytail": + kit.write_file(home / "plugins/data/ponytail-ponytail/.ponytail-active", "ultra") + kit.write_file(self.paths.home / ".config/ponytail/config.json", '{"defaultMode":"off"}') def native_run(self, command, **kwargs): - if command[0] not in ("node", "codex", "fake-repowise"): + if command[0] not in ("node", "codex", "fake-repowise", "fake-uv"): return self.real_run(command, **kwargs) env = kwargs.get("env") or os.environ home = Path(env["CODEX_HOME"]) @@ -75,7 +90,11 @@ def native_run(self, command, **kwargs): raise FileNotFoundError("node") return subprocess.CompletedProcess(command, 0, "v22.0.0\n", "") if command[0] == "fake-repowise": - return subprocess.CompletedProcess(command, 0, "RepoWise " + kit.REPOWISE_VERSION, "") + version = kit.load_lock()["runtime_tools"]["repowise"]["version"] + return subprocess.CompletedProcess(command, 0, "RepoWise " + version, "") + if command[0] == "fake-uv": + version = kit.load_lock()["runtime_tools"]["uv"]["version"] + return subprocess.CompletedProcess(command, 0, "uv " + version, "") if command[1] != "plugin": return subprocess.CompletedProcess(command, 0, "codex-cli test" if command[1] == "--version" else "Logged in", "") if self.missing == "plugins": @@ -86,22 +105,33 @@ def native_run(self, command, **kwargs): state = self.state(home) if args == ["marketplace", "list", "--json"]: output = {"marketplaces": state["marketplaces"]} - elif args == ["list", "--marketplace", "ponytail", "--json"]: - output = {"installed": state["installed"], "available": []} - elif args == ["marketplace", "add", "DietrichGebert/ponytail", "--json"]: - state["marketplaces"].append({"name": "ponytail", "marketplaceSource": {"sourceType": "git", "source": self.SOURCE}}) + elif len(args) == 4 and args[:2] == ["list", "--marketplace"] and args[3] == "--json": + marketplace = args[2] + output = {"installed": [item for item in state["installed"] if item["pluginId"].endswith("@" + marketplace)], "available": []} + elif args[:2] == ["marketplace", "add"] and args[-1] == "--json": + source = args[2] + marketplace = "ponytail" if "ponytail" in source.lower() else "12ui-plugin" + url = self.SOURCE if marketplace == "ponytail" else "https://github.com/just-every/12ui-plugin.git" + state["marketplaces"].append({"name": marketplace, "marketplaceSource": {"sourceType": "git", "source": url}}) self.write_state(state, home) output = {"added": True} - elif args == ["add", "ponytail@ponytail", "--json"]: + elif args[:1] == ["add"] and args[-1] == "--json": if self.missing == "install": return subprocess.CompletedProcess(command, 1, "", "native plugin installation failed") - self.seed_plugin(home=home) + plugin_id = args[1] + name = "ponytail" if plugin_id == "ponytail@ponytail" else "12ui" + self.seed_plugin(home=home, name=name) installed = self.state(home) - installed["marketplaces"] = state["marketplaces"] + existing = {item["name"]: item for item in installed["marketplaces"]} + existing.update({item["name"]: item for item in state["marketplaces"]}) + installed["marketplaces"] = list(existing.values()) self.write_state(installed, home) - for skill in self.SKILLS: - kit.write_file(home / "plugins/cache/ponytail/skills" / skill / "SKILL.md", "native package fixture") - kit.write_file(home / "plugins/cache/ponytail/hooks.json", json.dumps({"hooks": {name: [] for name in ("SessionStart", "UserPromptSubmit", "SubagentStart")}})) + if name == "ponytail": + for skill in self.SKILLS: + kit.write_file(home / "plugins/cache/ponytail/skills" / skill / "SKILL.md", "native package fixture") + kit.write_file(home / "plugins/cache/ponytail/hooks.json", json.dumps({"hooks": {event: [] for event in ("SessionStart", "UserPromptSubmit", "SubagentStart")}})) + else: + kit.write_file(home / "plugins/cache/12ui-plugin/skills/12ui-design/SKILL.md", "12UI fixture") output = {"installed": True} else: self.fail(f"Unexpected native mutation or command: {command}") @@ -133,8 +163,8 @@ def test_fresh_install_registers_complete_plugin(self): self.assertFalse(self.paths.codex_home.exists()) result, output = self.cli("install") self.assertEqual(result, 0, output) - self.assertIn("`/hooks` to review and trust toolkit and Ponytail hooks", output) - self.assertEqual(len(self.mutations()), 2) + self.assertIn("`/hooks` to review and trust toolkit and plugin hooks", output) + self.assertEqual(len(self.mutations()), 4) package = self.paths.codex_home / "plugins/cache/ponytail" self.assertEqual(sorted(p.parent.name for p in package.glob("skills/*/SKILL.md")), sorted(self.SKILLS)) self.assertEqual(set(json.loads((package / "hooks.json").read_text())["hooks"]), {"SessionStart", "UserPromptSubmit", "SubagentStart"}) @@ -177,7 +207,7 @@ def test_legacy_skill_migration_requires_recorded_ownership(self): def test_existing_official_plugin_is_reused(self): kit.write_file(self.paths.codex_home / "config.toml", '# unrelated user configuration\n') self.assertEqual(self.cli("install")[0], 0) - self.assertEqual(len(self.mutations()), 2) + self.assertEqual(len(self.mutations()), 4) self.calls.clear() for enabled in (True, False): with self.subTest(enabled=enabled): @@ -290,7 +320,9 @@ def test_foreign_marketplace_is_preserved(self): self.write_state(state) self.assertEqual(self.cli("install")[0], 0) self.assertFalse((self.paths.skills_home / "ponytail").exists()) - self.assertEqual(self.state()["marketplaces"], state["marketplaces"]) + installed_marketplaces = self.state()["marketplaces"] + self.assertEqual(installed_marketplaces[: len(state["marketplaces"])], state["marketplaces"]) + self.assertEqual(installed_marketplaces[-1]["name"], "12ui-plugin") if __name__ == "__main__": diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..8711999 --- /dev/null +++ b/tests/test_updates.py @@ -0,0 +1,1268 @@ +"""Focused checks for explicit managed-update application.""" + +import contextlib +import copy +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import urllib.parse +from unittest import mock + +import kit + + +ROOT = Path(__file__).resolve().parents[1] + + +class ApplyUpdateTests(unittest.TestCase): + def paths(self, root: Path) -> kit.InstallPaths: + return kit.InstallPaths(root / "home", root / "codex", root / "skills", root / "kit") + + def runtime_lock(self, *, repowise="9.9.9", uv="8.8.8"): + lock = copy.deepcopy(kit.load_lock()) + lock["runtime_tools"]["repowise"]["version"] = repowise + lock["runtime_tools"]["uv"]["version"] = uv + lock["runtime_tools"]["uv"]["installers"] = { + "posix": {"url": f"https://astral.sh/uv/{uv}/install.sh", "sha256": "a" * 64}, + "windows": {"url": f"https://astral.sh/uv/{uv}/install.ps1", "sha256": "b" * 64}, + } + return lock + + def test_apply_repowise_selected_version(self): + with tempfile.TemporaryDirectory() as temp: + paths = self.paths(Path(temp)) + uv = paths.home / "bin/uv" + repowise = paths.home / ".local/bin/repowise" + uv.parent.mkdir(parents=True) + uv.write_text("uv") + repowise.parent.mkdir(parents=True) + repowise.write_text("old") + kit.write_file( + kit.manifest_path(paths), + json.dumps({"repowise": str(repowise)}), + ) + calls = [] + + def run(command, **_kwargs): + calls.append(command) + if command == [str(repowise), "--version"]: + return subprocess.CompletedProcess(command, 0, "RepoWise 1.0.0\n", "") + if command[:3] == [str(uv), "tool", "install"]: + repowise.write_text("new") + return subprocess.CompletedProcess(command, 0, "", "") + + with mock.patch.object(kit, "load_lock", return_value=self.runtime_lock()), mock.patch.object( + kit, "find_runtime_command", return_value=str(repowise) + ), mock.patch.object(kit, "run", side_effect=run): + result = kit.ensure_repowise(paths, str(uv), force_install=True) + + self.assertEqual(result, str(repowise)) + self.assertIn([str(uv), "tool", "install", "--force", "repowise==9.9.9"], calls) + + def test_apply_reuses_current_external_repowise(self): + with tempfile.TemporaryDirectory() as temp: + paths = self.paths(Path(temp)) + repowise = Path(temp) / "external/repowise" + repowise.parent.mkdir() + repowise.write_text("repowise") + calls = [] + + def run(command, **_kwargs): + calls.append(command) + return subprocess.CompletedProcess(command, 0, "RepoWise 9.9.9\n", "") + + with mock.patch.object(kit, "load_lock", return_value=self.runtime_lock()), mock.patch.object( + kit, "find_runtime_command", return_value=str(repowise) + ), mock.patch.object(kit, "run", side_effect=run): + result = kit.ensure_repowise(paths, "uv", force_install=True) + + self.assertEqual(result, str(repowise)) + self.assertEqual(calls, [[str(repowise), "--version"]]) + + def _assert_uv_platform(self, windows: bool): + with tempfile.TemporaryDirectory() as temp: + paths = self.paths(Path(temp)) + body = b"windows installer" if windows else b"posix installer" + calls = [] + + def run(command, **kwargs): + calls.append((command, kwargs["input_text"])) + target = Path(kwargs["env"]["UV_INSTALL_DIR"]) + target.mkdir(parents=True) + (target / ("uv.exe" if windows else "uv")).write_text("uv") + return subprocess.CompletedProcess(command, 0, "", "") + + with mock.patch.object(kit, "load_lock", return_value=self.runtime_lock()), mock.patch.object( + kit, "is_windows", return_value=windows + ), mock.patch.object(kit, "find_runtime_command", return_value="old-uv"), mock.patch.object( + kit, "run", side_effect=run + ): + result = kit.ensure_uv(paths, force=True, installer_bytes=body) + + self.assertEqual(Path(result).name, "uv.exe" if windows else "uv") + self.assertEqual(calls[0][1], body.decode()) + self.assertEqual(calls[0][0][0], "pwsh" if windows else "sh") + + def test_apply_uv_posix(self): + self._assert_uv_platform(False) + + def test_apply_uv_windows(self): + self._assert_uv_platform(True) + + def test_apply_complete_skill_revision(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + paths = self.paths(root) + destination = paths.skills_home / "demo" + kit.write_file(destination / "obsolete.md", "old") + lock = self.runtime_lock() + lock["skills"] = { + "demo": { + "repository": "https://github.com/example/demo", + "commit": "1" * 40, + "license": "MIT", + "root": "skills/demo", + "license_path": "LICENSE", + "files": [ + {"path": "skills/demo/SKILL.md", "destination": "demo/SKILL.md", "git_blob_sha1": "0" * 40}, + {"path": "skills/demo/obsolete.md", "destination": "demo/obsolete.md", "git_blob_sha1": "9" * 40}, + ], + } + } + commit_url = kit.github_api_url("https://github.com/example/demo", "commits?per_page=1") + tree_url = kit.github_api_url("https://github.com/example/demo", f"git/trees/{'2' * 40}?recursive=1") + + def fetch(url): + if url.endswith("/repowise/json"): + return {"info": {"version": "9.9.9"}} + if url == commit_url: + return [{"sha": "2" * 40}] + if url == tree_url: + return { + "truncated": False, + "tree": [ + {"type": "blob", "path": "skills/demo/SKILL.md", "sha": "a" * 40}, + {"type": "blob", "path": "skills/demo/new.md", "sha": "b" * 40}, + {"type": "blob", "path": "LICENSE", "sha": "c" * 40}, + {"type": "blob", "path": "unrelated.txt", "sha": "d" * 40}, + ], + } + self.fail(url) + + with mock.patch.object(kit, "load_lock", return_value=lock), mock.patch.object( + kit, "release_identity", side_effect=lambda repository: "v8.8.8" if repository.endswith("/uv") else "release" + ), mock.patch.object(kit, "fetch_json", side_effect=fetch), mock.patch.object( + kit, "download_url", return_value=b"installer" + ), mock.patch.object( + kit, "download_file", side_effect=lambda url, _blob: url.rsplit("/", 1)[-1].encode() + ): + candidate, _lines, payloads = kit.managed_candidates(include_payloads=True) + + self.assertEqual( + [item["destination"] for item in candidate["skills"]["demo"]["files"]], + ["demo/LICENSE", "demo/SKILL.md", "demo/new.md"], + ) + self.assertNotIn("skill:demo/obsolete.md", payloads) + stage_lock = { + "skills": { + "demo": { + "repository": "https://github.com/example/demo", + "commit": "2" * 40, + "license": "MIT", + "files": [ + {"path": "skills/demo/SKILL.md", "destination": "demo/SKILL.md", "git_blob_sha1": "1" * 40}, + {"path": "skills/demo/new.md", "destination": "demo/new.md", "git_blob_sha1": "2" * 40}, + ], + } + } + } + payloads = {"skill:demo/SKILL.md": b"new skill", "skill:demo/new.md": b"new file"} + stage = root / "stage" + stage.mkdir() + with mock.patch.object(kit, "ALL_SKILLS", ("demo",)): + records = kit.stage_upstream_skills(stage, stage_lock, payloads) + installed = kit.install_skills(paths, stage, {"demo"}) + + self.assertEqual(records["demo"]["commit"], "2" * 40) + self.assertEqual(installed, [{"name": "demo"}]) + self.assertEqual( + {path.relative_to(destination).as_posix() for path in destination.rglob("*") if path.is_file()}, + {"SKILL.md", "new.md"}, + ) + self.assertFalse((destination / "obsolete.md").exists()) + + def _run_install_plugins(self, states, *, refresh): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + paths = self.paths(Path(temp.name)) + calls = [] + + def stage(destination): + for name in kit.UPSTREAM_SKILLS: + kit.write_file(destination / name / "SKILL.md", name) + return {} + + with mock.patch.object(kit, "stage_upstream_skills", side_effect=stage), mock.patch.object( + kit, "ensure_repowise_runtime", return_value=("uv", "repowise") + ), mock.patch.object(kit, "codex_plugin", side_effect=lambda _paths, *args: calls.append(args) or {}): + kit.install_core( + mock.Mock(), + paths, + refresh_plugins=refresh, + plugin_states=states, + ) + return paths, calls + + def test_apply_native_plugins_preserves_user_state(self): + disabled = {"installed": True, "enabled": False, "user": "keep"} + states = {"ponytail": (True, {"installed": True, "enabled": True}), "12ui": (True, disabled)} + paths, calls = self._run_install_plugins(states, refresh=True) + self.assertEqual( + calls, + [("marketplace", "upgrade", "ponytail"), ("marketplace", "upgrade", "12ui-plugin")], + ) + self.assertFalse(disabled["enabled"]) + self.assertEqual(disabled["user"], "keep") + self.assertTrue((paths.codex_home / "AGENTS.md").is_file()) + + def test_install_12ui_absent_or_existing(self): + absent = {"ponytail": (True, {"installed": True}), "12ui": (False, None)} + _paths, calls = self._run_install_plugins(absent, refresh=False) + self.assertEqual( + calls, + [("marketplace", "add", "just-every/12ui-plugin"), ("add", "12ui-design@12ui-plugin")], + ) + existing = {"ponytail": (True, {"installed": True}), "12ui": (True, {"installed": True, "enabled": False})} + _paths, calls = self._run_install_plugins(existing, refresh=False) + self.assertEqual(calls, []) + existing_marketplace = {"ponytail": (True, {"installed": True}), "12ui": (True, None)} + _paths, calls = self._run_install_plugins(existing_marketplace, refresh=True) + self.assertEqual( + calls, + [ + ("marketplace", "upgrade", "ponytail"), + ("marketplace", "upgrade", "12ui-plugin"), + ("add", "12ui-design@12ui-plugin"), + ], + ) + + def test_12ui_policy_is_website_only(self): + text = (ROOT / "assets/AGENTS.block.md").read_text() + self.assertEqual(text.count("cpk-rule-guard: Use 12ui-design only for website projects."), 1) + self.assertIn("Use 12ui-design only for website projects", text) + self.assertIn("Do not use it for native apps or other UI work", text) + self.assertNotIn("all non-trivial UI", text.lower()) + + def test_update_set_cardinality_and_late_record(self): + lock = kit.load_lock() + responses = { + "repowise": lock["runtime_tools"]["repowise"]["version"], + "uv": "v" + lock["runtime_tools"]["uv"]["version"], + "ponytail": "v4.12.0", + "12ui": "design-v0.2.107", + } + + def release(repository): + for name in ("12ui", "ponytail", "uv"): + if repository == lock["runtime_tools"][name]["repository"]: + return responses[name] + self.fail(repository) + + def fetch(url): + if url.endswith("/repowise/json"): + return {"info": {"version": responses["repowise"]}} + for component in lock["skills"].values(): + if url == kit.github_api_url(component["repository"], "commits?per_page=1"): + return [{"sha": component["commit"]}] + self.fail(url) + + with mock.patch.object(kit, "release_identity", side_effect=release), mock.patch.object( + kit, "fetch_json", side_effect=fetch + ): + _candidate, lines, _payloads = kit.managed_candidates() + self.assertEqual(len(lines), len(lock["runtime_tools"]) + len(lock["skills"])) + self.assertTrue(all("same identity" in line or "Codex-owned plugin" in line for line in lines)) + responses["repowise"] = "99.0.0" + with mock.patch.object(kit, "release_identity", side_effect=release), mock.patch.object( + kit, "fetch_json", side_effect=fetch + ): + _candidate, changed, _payloads = kit.managed_candidates() + self.assertEqual(sum("upstream differs" in line for line in changed), 1) + + def test_update_actions_stop_once_on_error(self): + with mock.patch.object(kit, "managed_candidates", side_effect=kit.KitError("offline")), mock.patch.object( + kit, "write_file" + ) as write, mock.patch.object(kit, "install_core") as install: + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + status = kit.main(["apply-updates"]) + self.assertEqual(status, 2) + self.assertEqual(stderr.getvalue().count("ERROR:"), 1) + write.assert_not_called() + install.assert_not_called() + + def test_update_source_safety_uses_kit_root(self): + with tempfile.TemporaryDirectory(prefix="toolkit source ") as source_temp, tempfile.TemporaryDirectory( + prefix="caller repo " + ) as caller_temp: + source = Path(source_temp) + caller = Path(caller_temp) + (source / "upstream.lock.json").write_text("{}\n") + for root in (source, caller): + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(source), "add", "upstream.lock.json"], check=True) + subprocess.run(["git", "-C", str(source), "commit", "-qm", "base"], check=True) + previous = Path.cwd() + paths = self.paths(source) + try: + os.chdir(caller) + with mock.patch.object(kit, "ROOT", source): + kit.require_clean_update_source(paths) + (source / "upstream.lock.json").write_text('{"changed": true}\n') + with self.assertRaisesRegex(kit.KitError, "modified or untracked"): + kit.require_clean_update_source(paths) + kit.write_file( + paths.install_root / "upstream.lock.json", + (source / "upstream.lock.json").read_bytes(), + ) + kit.require_clean_update_source(paths) + finally: + os.chdir(previous) + + def test_apply_reconciles_stale_installed_skill(self): + self.test_apply_complete_skill_revision() + + @unittest.skipIf(os.name == "nt", "POSIX launchers require a POSIX host") + def test_bash_update_launchers_complete_path(self): + with tempfile.TemporaryDirectory(prefix="update launchers ") as temp: + for script in ("check-updates.sh", "apply-updates.sh"): + success = subprocess.run([str(ROOT / script), "--help"], cwd=temp, text=True, capture_output=True) + failure = subprocess.run([str(ROOT / script), "--not-an-option"], cwd=temp, text=True, capture_output=True) + self.assertEqual(success.returncode, 0, success.stderr) + self.assertEqual(failure.returncode, 2) + + @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is not installed") + def test_powershell_update_launchers_complete_path(self): + with tempfile.TemporaryDirectory(prefix="update launchers ") as temp: + shim = Path(temp) / "python" + shim.symlink_to(sys.executable) + env = {**os.environ, "PATH": temp + os.pathsep + os.environ["PATH"]} + for script in ("check-updates.ps1", "apply-updates.ps1"): + success = subprocess.run(["pwsh", "-NoProfile", "-File", str(ROOT / script), "--help"], cwd=temp, env=env, text=True, capture_output=True) + failure = subprocess.run(["pwsh", "-NoProfile", "-File", str(ROOT / script), "--not-an-option"], cwd=temp, env=env, text=True, capture_output=True) + self.assertEqual(success.returncode, 0, success.stderr) + self.assertEqual(failure.returncode, 2) + + +class ComposedUpdateTests(unittest.TestCase): + """Exercise the real update entry point with only external systems faked.""" + + class Response: + def __init__(self, value): + self.body = value if isinstance(value, bytes) else json.dumps(value).encode() + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return self.body + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="composed updates ") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.source = self.root / "toolkit source" + shutil.copytree( + ROOT, + self.source, + ignore=shutil.ignore_patterns(".git", ".repowise", ".mcp.json", ".vscode", "__pycache__"), + ) + subprocess.run(["git", "init", "-q", str(self.source)], check=True) + subprocess.run(["git", "-C", str(self.source), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(self.source), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(self.source), "add", "."], check=True) + subprocess.run(["git", "-C", str(self.source), "commit", "-qm", "candidate"], check=True) + self.paths = kit.InstallPaths( + self.root / "home", + self.root / "selected codex", + self.root / "installed skills", + self.root / "installed kit", + ) + self.default_home = self.root / "default codex" + kit.write_file(self.default_home / "sentinel", "unchanged") + self.default_before = (self.default_home / "sentinel").read_bytes() + self.process_calls = [] + self.http_calls = [] + self.plugin_state = { + "marketplaces": [ + {"name": "unrelated", "root": "keep"}, + { + "name": "ponytail", + "marketplaceSource": { + "sourceType": "git", + "source": "https://github.com/DietrichGebert/ponytail.git", + }, + }, + ], + "installed": [self.plugin("ponytail", enabled=False, user="keep")], + } + self.fail_upgrade = None + self.skill_payloads = { + "neuroarxiv": { + "skills/neuroarxiv/SKILL.md": b"# neuroarxiv selected\n", + "skills/neuroarxiv/references/paper.md": b"selected paper\n", + "LICENSE": b"MIT neuroarxiv\n", + }, + "simple-english": { + "skills/simple-english/SKILL.md": b"# simple English selected\n", + "skills/simple-english/references/new.md": b"selected reference\n", + "LICENSE": b"MIT simple English\n", + }, + } + self.skill_commits = {"neuroarxiv": "1" * 40, "simple-english": "2" * 40} + self.real_subprocess_run = subprocess.run + git = shutil.which("git") + self.assertIsNotNone(git) + self.patches = [ + mock.patch.object(kit, "ROOT", self.source), + mock.patch.object(kit.urllib.request, "urlopen", side_effect=self.urlopen), + mock.patch.dict( + os.environ, + {"CODEX_HOME": str(self.default_home), "PATH": str(Path(git).parent)}, + ), + ] + for patch in self.patches: + patch.start() + self.addCleanup(patch.stop) + + @staticmethod + def plugin(name, *, enabled, user=None): + marketplace = "ponytail" if name == "ponytail" else "12ui-plugin" + plugin_name = "ponytail" if name == "ponytail" else "12ui-design" + repository = ( + "https://github.com/DietrichGebert/ponytail.git" + if name == "ponytail" + else "https://github.com/just-every/12ui-plugin.git" + ) + package_source = ( + {"source": "git", "url": repository} + if name == "ponytail" + else {"source": "local", "path": "."} + ) + record = { + "name": plugin_name, + "pluginId": f"{plugin_name}@{marketplace}", + "installed": True, + "version": "old", + "enabled": enabled, + "source": package_source, + "marketplaceSource": {"sourceType": "git", "source": repository}, + } + if user is not None: + record["user"] = user + return record + + def urlopen(self, request, timeout=60): + self.assertEqual(timeout, 60) + url = request.full_url + self.http_calls.append(url) + if url == "https://pypi.org/pypi/repowise/json": + return self.Response({"info": {"version": "9.9.9"}}) + if url.endswith("/repos/astral-sh/uv/releases/latest"): + return self.Response({"tag_name": "v8.8.8"}) + if url.endswith("/repos/DietrichGebert/ponytail/releases/latest"): + return self.Response({"tag_name": "v4.12.0"}) + if url.endswith("/repos/just-every/12ui-plugin/releases/latest"): + return self.Response({"tag_name": "design-v0.2.107"}) + if url.startswith("https://astral.sh/uv/"): + return self.Response(b"fixture installer\n") + for name, repository in ( + ("neuroarxiv", "UditAkhourii/neuroarxiv"), + ("simple-english", "AminBlg/SimpleEnglish"), + ): + if f"/repos/{repository}/commits?per_page=1" in url: + return self.Response([{"sha": self.skill_commits[name]}]) + if f"/repos/{repository}/git/trees/" in url: + tree = [ + {"type": "blob", "path": path, "sha": kit.git_blob_sha1(data)} + for path, data in self.skill_payloads[name].items() + ] + return self.Response({"truncated": False, "tree": tree}) + raw_prefix = f"https://raw.githubusercontent.com/{repository}/" + if url.startswith(raw_prefix): + # Remove the owner, repository, and commit path segments. + path = urllib.parse.urlsplit(url).path.lstrip("/").split("/", 3)[3] + return self.Response(self.skill_payloads[name][path]) + self.fail(f"Unexpected HTTP request: {url}") + + def fake_run(self, command, **kwargs): + command = [str(item) for item in command] + self.process_calls.append((command, (kwargs.get("env") or {}).get("CODEX_HOME"))) + executable = Path(command[0]).name + if executable == "git": + return self.real_subprocess_run(command, **kwargs) + if executable == "node": + return subprocess.CompletedProcess(command, 0, "v22.0.0\n", "") + if executable in {"sh", "pwsh"} and kwargs.get("input") is not None: + target = Path(kwargs["env"]["UV_INSTALL_DIR"]) + target.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if executable == "pwsh" else "" + (target / f"uv{suffix}").write_text("fixture") + return subprocess.CompletedProcess(command, 0, "", "") + if executable in {"uv", "uv.exe"}: + if command[1:] == ["--version"]: + return subprocess.CompletedProcess(command, 0, "uv 8.8.8\n", "") + if command[1:3] == ["tool", "install"]: + target = Path(kwargs["env"]["UV_TOOL_BIN_DIR"]) + target.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if executable.endswith(".exe") else "" + (target / f"repowise{suffix}").write_text("fixture") + return subprocess.CompletedProcess(command, 0, "", "") + if executable in {"repowise", "repowise.exe"}: + return subprocess.CompletedProcess(command, 0, "RepoWise 9.9.9\n", "") + if executable != "codex": + self.fail(f"Unexpected process: {command}") + if command[1:] == ["--version"]: + return subprocess.CompletedProcess(command, 0, "codex-cli fixture\n", "") + if command[1:] == ["login", "status"]: + return subprocess.CompletedProcess(command, 0, "Logged in\n", "") + args = command[2:-1] + if args == ["marketplace", "list"]: + output = {"marketplaces": self.plugin_state["marketplaces"]} + elif args[:2] == ["list", "--marketplace"]: + marketplace = args[2] + output = { + "installed": [ + item for item in self.plugin_state["installed"] + if item["pluginId"].endswith("@" + marketplace) + ] + } + elif args[:2] == ["marketplace", "add"]: + source = args[2] + name = "ponytail" if "ponytail" in source.lower() else "12ui-plugin" + repository = ( + "https://github.com/DietrichGebert/ponytail.git" + if name == "ponytail" else "https://github.com/just-every/12ui-plugin.git" + ) + self.plugin_state["marketplaces"].append( + {"name": name, "marketplaceSource": {"sourceType": "git", "source": repository}} + ) + output = {"added": True} + elif args[:1] == ["add"]: + name = "ponytail" if args[1].startswith("ponytail@") else "12ui" + self.plugin_state["installed"].append(self.plugin(name, enabled=True)) + output = {"installed": True} + elif args[:2] == ["marketplace", "upgrade"]: + marketplace = args[2] + if self.fail_upgrade == marketplace: + return subprocess.CompletedProcess(command, 1, "", "fixture upgrade failed") + for item in self.plugin_state["installed"]: + if item["pluginId"].endswith("@" + marketplace): + item["version"] = "refreshed" + cache = self.paths.codex_home / "plugins" / "cache" / marketplace / "revision" + kit.write_file(cache, "refreshed") + output = {"upgraded": marketplace} + else: + self.fail(f"Unexpected Codex command: {command}") + return subprocess.CompletedProcess(command, 0, json.dumps(output), "") + + def cli(self, command): + args = [ + command, + "--home", str(self.paths.home), + "--codex-home", str(self.paths.codex_home), + "--skills-home", str(self.paths.skills_home), + "--install-root", str(self.paths.install_root), + ] + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch.object(subprocess, "run", side_effect=self.fake_run), contextlib.redirect_stdout( + stdout + ), contextlib.redirect_stderr(stderr): + status = kit.main(args) + return status, stdout.getvalue(), stderr.getvalue() + + def commit_source_lock(self): + subprocess.run(["git", "-C", str(self.source), "add", "upstream.lock.json"], check=True) + changed = subprocess.run( + ["git", "-C", str(self.source), "diff", "--cached", "--quiet"] + ).returncode + if changed: + subprocess.run(["git", "-C", str(self.source), "commit", "-qm", "selected lock"], check=True) + + def test_apply_main_reconciles_complete_installation_and_plugins(self): + first_call = len(self.process_calls) + status, stdout, stderr = self.cli("apply-updates") + self.assertEqual((status, stderr), (0, "")) + self.assertIn("Managed component updates applied.", stdout) + selected = json.loads((self.source / "upstream.lock.json").read_text()) + self.assertEqual(selected["runtime_tools"]["repowise"]["version"], "9.9.9") + self.assertEqual(selected["runtime_tools"]["uv"]["version"], "8.8.8") + self.assertEqual(set(selected["runtime_tools"]), {"12ui", "ponytail", "repowise", "uv"}) + self.assertEqual(set(selected["skills"]), set(self.skill_payloads)) + self.assertFalse( + any(name in url for name in selected["optional_tools"] for url in self.http_calls) + ) + self.assertEqual(self.http_calls.count("https://pypi.org/pypi/repowise/json"), 1) + for name in ("12ui", "ponytail", "uv"): + self.assertEqual( + self.http_calls.count( + kit.github_api_url(selected["runtime_tools"][name]["repository"], "releases/latest") + ), + 1, + ) + for name, component in selected["skills"].items(): + self.assertEqual( + self.http_calls.count( + kit.github_api_url(component["repository"], "commits?per_page=1") + ), + 1, + name, + ) + for name, files in self.skill_payloads.items(): + installed = { + path.relative_to(self.paths.skills_home / name).as_posix(): path.read_bytes() + for path in (self.paths.skills_home / name).rglob("*") if path.is_file() + } + expected = { + ("LICENSE" if path == "LICENSE" else path.removeprefix(f"skills/{name}/")): data + for path, data in files.items() + } + self.assertEqual(installed, expected) + manifest = json.loads(kit.manifest_path(self.paths).read_text()) + self.assertEqual( + {name: item["commit"] for name, item in manifest["upstream"].items()}, + self.skill_commits, + ) + installed_12ui = next(item for item in self.plugin_state["installed"] if item["name"] == "12ui-design") + self.assertTrue(installed_12ui["enabled"]) + ponytail = next(item for item in self.plugin_state["installed"] if item["name"] == "ponytail") + self.assertFalse(ponytail["enabled"]) + self.assertEqual(ponytail["user"], "keep") + router = (self.paths.codex_home / "AGENTS.md").read_text() + self.assertIn("Use 12ui-design only for website projects", router) + self.assertNotIn("all non-trivial UI", router.lower()) + self.assertEqual((self.default_home / "sentinel").read_bytes(), self.default_before) + self.assertEqual(self.cli("doctor")[0], 0) + first_processes = [call[0] for call in self.process_calls[first_call:]] + self.assertEqual( + [ + call[-1] + for call in first_processes + if Path(call[0]).name.startswith("uv") and call[1:3] == ["tool", "install"] + ], + ["repowise==9.9.9"], + ) + self.assertEqual( + [call[4] for call in first_processes if call[1:4] == ["plugin", "marketplace", "upgrade"]], + ["ponytail"], + ) + self.assertEqual( + sum(call[1:3] == ["plugin", "add"] and call[3] == "12ui-design@12ui-plugin" for call in first_processes), + 1, + ) + self.assertEqual( + sum(call[1:4] == ["plugin", "marketplace", "add"] and call[4] == "just-every/12ui-plugin" for call in first_processes), + 1, + ) + + stale = self.paths.skills_home / "simple-english" + (stale / "SKILL.md").write_text("stale") + (stale / "obsolete.md").write_text("obsolete") + installed_12ui["enabled"] = False + installed_12ui["user"] = "preserve" + status, stdout, stderr = self.cli("apply-updates") + self.assertEqual((status, stderr), (0, "")) + self.assertIn("Managed component updates applied.", stdout) + self.assertEqual((stale / "SKILL.md").read_bytes(), self.skill_payloads["simple-english"]["skills/simple-english/SKILL.md"]) + self.assertFalse((stale / "obsolete.md").exists()) + self.assertFalse(installed_12ui["enabled"]) + self.assertEqual(installed_12ui["user"], "preserve") + router = (self.paths.codex_home / "AGENTS.md").read_text() + self.assertIn("Use 12ui-design only for website projects", router) + self.assertNotIn("all non-trivial UI", router.lower()) + upgrades = [ + call[0][4] for call in self.process_calls + if call[0][1:4] == ["plugin", "marketplace", "upgrade"] + ] + self.assertEqual(upgrades[-2:], ["ponytail", "12ui-plugin"]) + self.assertEqual( + (self.paths.codex_home / "plugins/cache/ponytail/revision").read_text(), + "refreshed", + ) + self.assertEqual( + (self.paths.codex_home / "plugins/cache/12ui-plugin/revision").read_text(), + "refreshed", + ) + self.assertTrue( + all( + home == str(self.paths.codex_home) + for command, home in self.process_calls + if command[:2] == ["codex", "plugin"] + ) + ) + self.assertEqual(self.plugin_state["marketplaces"][0], {"name": "unrelated", "root": "keep"}) + self.assertEqual(self.cli("doctor")[0], 0) + + def test_install_main_fresh_and_preserves_disabled_12ui(self): + candidate, _lines, _payloads = kit.managed_candidates(include_payloads=True) + kit.write_file( + self.source / "upstream.lock.json", + json.dumps(candidate, indent=2, sort_keys=True) + "\n", + ) + self.commit_source_lock() + status, stdout, stderr = self.cli("install") + self.assertEqual((status, stderr), (0, "")) + self.assertIn("Core kit installed.", stdout) + self.assertEqual( + sum(item["name"] == "12ui-plugin" for item in self.plugin_state["marketplaces"]), + 1, + ) + self.assertEqual( + sum(item["name"] == "12ui-design" for item in self.plugin_state["installed"]), + 1, + ) + installed = next(item for item in self.plugin_state["installed"] if item["name"] == "12ui-design") + self.assertTrue(installed["enabled"]) + self.assertIn( + "Use 12ui-design only for website projects", + (self.paths.codex_home / "AGENTS.md").read_text(), + ) + self.assertNotIn( + "all non-trivial UI", + (self.paths.codex_home / "AGENTS.md").read_text().lower(), + ) + self.assertEqual(self.cli("doctor")[0], 0) + + installed["enabled"] = False + installed["user"] = "keep" + before = len(self.process_calls) + self.assertEqual(self.cli("install")[0], 0) + mutations = [ + command for command, _home in self.process_calls[before:] + if command[:2] == ["codex", "plugin"] + and (command[2:4] == ["marketplace", "add"] or command[2] == "add") + ] + self.assertEqual(mutations, []) + self.assertFalse(installed["enabled"]) + self.assertEqual(installed["user"], "keep") + self.assertEqual( + sum(item["name"] == "12ui-design" for item in self.plugin_state["installed"]), + 1, + ) + self.assertNotIn( + "all non-trivial UI", + (self.paths.codex_home / "AGENTS.md").read_text().lower(), + ) + self.assertEqual(self.cli("doctor")[0], 0) + + self.plugin_state["installed"] = [ + item for item in self.plugin_state["installed"] if item["name"] != "12ui-design" + ] + before = len(self.process_calls) + self.assertEqual(self.cli("install")[0], 0) + install_calls = [call[0] for call in self.process_calls[before:]] + self.assertTrue(any(call[1:3] == ["plugin", "add"] and call[3] == "12ui-design@12ui-plugin" for call in install_calls)) + installed_12ui = next(item for item in self.plugin_state["installed"] if item["name"] == "12ui-design") + installed_12ui["enabled"] = False + installed_12ui["user"] = "ordinary-install-preserves" + before = len(self.process_calls) + self.assertEqual(self.cli("install")[0], 0) + self.assertFalse(installed_12ui["enabled"]) + self.assertEqual(installed_12ui["user"], "ordinary-install-preserves") + self.assertFalse( + any( + call[0][1:3] == ["plugin", "add"] and call[0][3] == "12ui-design@12ui-plugin" + for call in self.process_calls[before:] + ) + ) + self.assertEqual(self.cli("doctor")[0], 0) + + def test_apply_main_stops_after_first_operation_error(self): + self.assertEqual(self.cli("apply-updates")[0], 0) + self.fail_upgrade = "12ui-plugin" + before = len(self.process_calls) + status, stdout, stderr = self.cli("apply-updates") + calls = self.process_calls[before:] + self.assertEqual(status, 2) + self.assertEqual(stdout, "") + self.assertEqual(stderr.count("ERROR:"), 1) + self.assertIn("fixture upgrade failed", stderr) + self.assertNotIn("Managed component updates applied.", stderr) + self.assertEqual( + [call[0][4] for call in calls if call[0][1:4] == ["plugin", "marketplace", "upgrade"]], + ["ponytail", "12ui-plugin"], + ) + self.assertEqual( + sum( + call[0][1:5] == ["plugin", "marketplace", "upgrade", "12ui-plugin"] + for call in calls + ), + 1, + ) + self.assertEqual( + calls[-1][0][1:5], + ["plugin", "marketplace", "upgrade", "12ui-plugin"], + ) + self.assertFalse(any(Path(call[0][0]).name.startswith("uv") for call in calls)) + + def test_failed_first_apply_leaves_source_retryable(self): + before = (self.source / "upstream.lock.json").read_bytes() + self.fail_upgrade = "ponytail" + self.assertEqual(self.cli("apply-updates")[0], 2) + self.assertEqual((self.source / "upstream.lock.json").read_bytes(), before) + self.fail_upgrade = None + self.assertEqual(self.cli("apply-updates")[0], 0) + + def test_apply_main_windows_runtime_branch(self): + with mock.patch.object(kit, "is_windows", return_value=True): + status, _stdout, stderr = self.cli("apply-updates") + self.assertEqual((status, stderr), (0, "")) + self.assertTrue((self.paths.home / ".local/bin/uv.exe").is_file()) + self.assertTrue((self.paths.home / ".local/bin/repowise.exe").is_file()) + self.assertTrue(any(Path(call[0][0]).name == "pwsh" for call in self.process_calls)) + manifest = json.loads(kit.manifest_path(self.paths).read_text()) + self.assertEqual(set(manifest["upstream"]), set(self.skill_payloads)) + for name, files in self.skill_payloads.items(): + self.assertEqual( + { + path.relative_to(self.paths.skills_home / name).as_posix() + for path in (self.paths.skills_home / name).rglob("*") if path.is_file() + }, + { + "LICENSE" if path == "LICENSE" else path.removeprefix(f"skills/{name}/") + for path in files + }, + ) + self.assertEqual(self.cli("doctor")[0], 0) + + @unittest.skipIf(os.name == "nt", "combined launcher parity requires a POSIX host") + @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is not installed") + def test_update_launchers_run_complete_actions_and_propagate_failures(self): + fixture = self.root / "launcher fixture" + fixture.mkdir() + state = { + "marketplaces": [ + {"name": "unrelated", "root": "keep"}, + { + "name": "ponytail", + "marketplaceSource": { + "sourceType": "git", + "source": "https://github.com/DietrichGebert/ponytail.git", + }, + } + ], + "installed": [self.plugin("ponytail", enabled=False)], + } + state["installed"][0]["user"] = "keep-ponytail" + (fixture / "plugins.json").write_text(json.dumps(state)) + site = self.root / "fixture site" + site.mkdir() + (site / "sitecustomize.py").write_text( + "from tests import update_subprocess_fixture\n" + ) + binaries = self.root / "fixture bin" + binaries.mkdir() + for name in ("python", "python3"): + (binaries / name).symlink_to(sys.executable) + for name in ("node", "codex"): + path = binaries / name + path.write_text("#!/bin/sh\nexit 99\n") + path.chmod(0o755) + real_path = os.environ["PATH"] + env = { + **os.environ, + "CPK_UPDATE_FIXTURE": str(fixture), + "PYTHONPATH": os.pathsep.join((str(site), str(ROOT))), + "PATH": os.pathsep.join((str(binaries), real_path)), + "CODEX_HOME": str(self.default_home), + } + def options(paths): + return [ + "--home", str(paths.home), + "--codex-home", str(paths.codex_home), + "--skills-home", str(paths.skills_home), + "--install-root", str(paths.install_root), + ] + + def invoke(script, *extra): + command = [str(self.source / script), *extra] + if script.endswith(".ps1"): + command = ["pwsh", "-NoProfile", "-File", *command] + return subprocess.run( + command, + cwd=self.root, + env=env, + text=True, + capture_output=True, + ) + + def calls(): + path = fixture / "calls.jsonl" + return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] + + def clear_calls(): + (fixture / "calls.jsonl").unlink(missing_ok=True) + + def snapshot(paths): + roots = (self.source, paths.home, paths.codex_home, paths.skills_home, paths.install_root, self.default_home) + result = {} + for root in roots: + if not root.exists(): + continue + for path in root.rglob("*"): + if path.is_file() and ".git" not in path.parts: + result[str(path)] = path.read_bytes() + result["plugin-state"] = (fixture / "plugins.json").read_bytes() + return result + + def expected_check_output(): + lock = json.loads((self.source / "upstream.lock.json").read_text()) + lines = ["Managed component updates:"] + lines.extend( + ( + "- 12ui: Codex-owned plugin; upstream release design-v0.2.107; use the Codex plugin manager", + "- ponytail: Codex-owned plugin; upstream release v4.12.0; use the Codex plugin manager", + ( + f"- repowise: toolkit pin {lock['runtime_tools']['repowise']['version']}; " + f"upstream release 9.9.9; " + + ("same identity" if lock["runtime_tools"]["repowise"]["version"] == "9.9.9" else "upstream differs") + ), + ( + f"- uv: toolkit pin {lock['runtime_tools']['uv']['version']}; upstream release v8.8.8; " + + ("same identity" if lock["runtime_tools"]["uv"]["version"] == "8.8.8" else "upstream differs") + ), + ( + f"- neuroarxiv: toolkit revision {lock['skills']['neuroarxiv']['commit']}; " + f"upstream revision {'1' * 40}; " + + ("same identity" if lock["skills"]["neuroarxiv"]["commit"] == "1" * 40 else "upstream differs") + ), + ( + f"- simple-english: toolkit revision {lock['skills']['simple-english']['commit']}; " + f"upstream revision {'2' * 40}; " + + ("same identity" if lock["skills"]["simple-english"]["commit"] == "2" * 40 else "upstream differs") + ), + ) + ) + return "\n".join(lines) + "\n" + + def assert_ready(paths): + lock = json.loads((self.source / "upstream.lock.json").read_text()) + self.assertEqual(lock["runtime_tools"]["repowise"]["version"], "9.9.9") + self.assertEqual(lock["runtime_tools"]["uv"]["version"], "8.8.8") + self.assertEqual(set(lock["runtime_tools"]), {"12ui", "ponytail", "repowise", "uv"}) + self.assertEqual(set(lock["skills"]), {"neuroarxiv", "simple-english"}) + expected_files = { + "neuroarxiv": { + "LICENSE": b"MIT neuroarxiv\n", + "SKILL.md": b"# neuroarxiv selected\n", + }, + "simple-english": { + "LICENSE": b"MIT simple English\n", + "SKILL.md": b"# simple English selected\n", + }, + } + for name, expected in expected_files.items(): + actual = { + path.relative_to(paths.skills_home / name).as_posix(): path.read_bytes() + for path in (paths.skills_home / name).rglob("*") if path.is_file() + } + self.assertEqual(actual, expected) + manifest = json.loads(kit.manifest_path(paths).read_text()) + self.assertEqual( + manifest["upstream"], + { + name: { + "repository": lock["skills"][name]["repository"], + "commit": lock["skills"][name]["commit"], + "license": lock["skills"][name]["license"], + } + for name in expected_files + }, + ) + self.assertEqual( + (paths.install_root / "upstream.lock.json").read_bytes(), + (self.source / "upstream.lock.json").read_bytes(), + ) + router = (paths.codex_home / "AGENTS.md").read_text() + self.assertIn("Use 12ui-design only for website projects", router) + self.assertNotIn("all non-trivial UI", router.lower()) + self.assertTrue((paths.home / ".local/bin/uv").is_file()) + self.assertTrue((paths.home / ".local/bin/repowise").is_file()) + doctor = invoke("doctor.sh", *options(paths)) + self.assertEqual(doctor.returncode, 0, doctor.stdout + doctor.stderr) + self.assertIn("Result: ready", doctor.stdout) + + before = snapshot(self.paths) + clear_calls() + bash_check = invoke("check-updates.sh") + self.assertEqual(bash_check.returncode, 0, bash_check.stderr) + self.assertEqual(bash_check.stdout, expected_check_output()) + self.assertEqual(snapshot(self.paths), before) + + clear_calls() + bash_apply = invoke("apply-updates.sh", *options(self.paths)) + self.assertEqual(bash_apply.returncode, 0, bash_apply.stderr) + self.assertEqual(bash_apply.stdout, "Managed component updates applied.\n") + assert_ready(self.paths) + bash_calls = calls() + bash_urls = [item["command"][1] for item in bash_calls if item["command"][0] == "http"] + lock = json.loads((self.source / "upstream.lock.json").read_text()) + self.assertFalse(any(name in url for name in lock["optional_tools"] for url in bash_urls)) + for name, component in lock["skills"].items(): + commit_url = kit.github_api_url(component["repository"], "commits?per_page=1") + self.assertEqual(bash_urls.count(commit_url), 1, name) + tree_url = kit.github_api_url( + component["repository"], + f"git/trees/{lock['skills'][name]['commit']}?recursive=1", + ) + self.assertEqual(bash_urls.count(tree_url), 1, name) + self.assertEqual(bash_urls.count("https://pypi.org/pypi/repowise/json"), 1) + for name in ("12ui", "ponytail", "uv"): + self.assertEqual( + bash_urls.count( + kit.github_api_url(lock["runtime_tools"][name]["repository"], "releases/latest") + ), + 1, + name, + ) + self.assertEqual(sum(url.startswith("https://astral.sh/uv/8.8.8/") for url in bash_urls), 2) + mutations = [ + item["command"][2:-1] for item in bash_calls + if item["command"][:2] == ["codex", "plugin"] + and item["command"][2] in {"add", "marketplace"} + ] + self.assertEqual( + [item for item in mutations if item[:2] == ["marketplace", "upgrade"]], + [["marketplace", "upgrade", "ponytail"]], + ) + self.assertEqual( + [item for item in mutations if item[:2] == ["marketplace", "add"]], + [["marketplace", "add", "just-every/12ui-plugin"]], + ) + self.assertEqual( + [item for item in mutations if item[:1] == ["add"]], + [["add", "12ui-design@12ui-plugin"]], + ) + self.assertEqual( + sum( + item["command"][1:4] == ["tool", "install", "--force"] + for item in bash_calls if Path(item["command"][0]).name == "uv" + ), + 1, + ) + + main_state = json.loads((fixture / "plugins.json").read_text()) + main_ponytail = next(item for item in main_state["installed"] if item["name"] == "ponytail") + self.assertFalse(main_ponytail["enabled"]) + self.assertEqual(main_ponytail["user"], "keep-ponytail") + self.assertEqual(main_state["marketplaces"][0], {"name": "unrelated", "root": "keep"}) + self.assertEqual( + (self.paths.codex_home / "plugins/cache/ponytail/revision").read_text(), + "refreshed", + ) + self.assertTrue( + all( + item["home"] == str(self.paths.codex_home) + for item in bash_calls if item["command"][:2] == ["codex", "plugin"] + ) + ) + self.assertEqual((self.default_home / "sentinel").read_bytes(), self.default_before) + bash_paths = kit.InstallPaths( + self.root / "bash install home", + self.root / "bash install codex", + self.root / "bash install skills", + self.root / "bash install kit", + ) + (fixture / "plugins.json").write_text(json.dumps({"marketplaces": [], "installed": []})) + clear_calls() + bash_install = invoke("install.sh", *options(bash_paths)) + self.assertEqual(bash_install.returncode, 0, bash_install.stderr) + assert_ready(bash_paths) + state = json.loads((fixture / "plugins.json").read_text()) + installed_12ui = next(item for item in state["installed"] if item["name"] == "12ui-design") + installed_12ui["enabled"] = False + installed_12ui["user"] = "keep-bash" + (fixture / "plugins.json").write_text(json.dumps(state)) + clear_calls() + self.assertEqual(invoke("install.sh", *options(bash_paths)).returncode, 0) + state = json.loads((fixture / "plugins.json").read_text()) + self.assertEqual(sum(item["name"] == "12ui-design" for item in state["installed"]), 1) + self.assertFalse(next(item for item in state["installed"] if item["name"] == "12ui-design")["enabled"]) + self.assertEqual(next(item for item in state["installed"] if item["name"] == "12ui-design")["user"], "keep-bash") + self.assertFalse( + any( + item["command"][2] == "add" or item["command"][2:4] == ["marketplace", "upgrade"] + for item in calls() if item["command"][:2] == ["codex", "plugin"] + ) + ) + self.assertEqual(invoke("doctor.sh", *options(bash_paths)).returncode, 0) + + powershell_paths = kit.InstallPaths( + self.root / "powershell install home", + self.root / "powershell install codex", + self.root / "powershell install skills", + self.root / "powershell install kit", + ) + (fixture / "plugins.json").write_text(json.dumps({"marketplaces": [], "installed": []})) + clear_calls() + powershell_install = invoke("install.ps1", *options(powershell_paths)) + self.assertEqual(powershell_install.returncode, 0, powershell_install.stderr) + assert_ready(powershell_paths) + state = json.loads((fixture / "plugins.json").read_text()) + installed_12ui = next(item for item in state["installed"] if item["name"] == "12ui-design") + installed_12ui["enabled"] = False + installed_12ui["user"] = "keep-powershell" + (fixture / "plugins.json").write_text(json.dumps(state)) + clear_calls() + self.assertEqual(invoke("install.ps1", *options(powershell_paths)).returncode, 0) + state = json.loads((fixture / "plugins.json").read_text()) + self.assertEqual(sum(item["name"] == "12ui-design" for item in state["installed"]), 1) + self.assertFalse(next(item for item in state["installed"] if item["name"] == "12ui-design")["enabled"]) + self.assertEqual(next(item for item in state["installed"] if item["name"] == "12ui-design")["user"], "keep-powershell") + self.assertFalse( + any( + item["command"][2] == "add" or item["command"][2:4] == ["marketplace", "upgrade"] + for item in calls() if item["command"][:2] == ["codex", "plugin"] + ) + ) + self.assertEqual(invoke("doctor.ps1", *options(powershell_paths)).returncode, 0) + + (fixture / "plugins.json").write_text(json.dumps(main_state)) + self.commit_source_lock() + before = snapshot(self.paths) + clear_calls() + powershell_check = invoke("check-updates.ps1") + self.assertEqual(powershell_check.returncode, 0, powershell_check.stderr) + self.assertEqual(powershell_check.stdout, expected_check_output()) + self.assertEqual(snapshot(self.paths), before) + + stale = self.paths.skills_home / "simple-english" + (stale / "SKILL.md").write_text("stale") + (stale / "obsolete.md").write_text("obsolete") + state = json.loads((fixture / "plugins.json").read_text()) + installed_12ui = next(item for item in state["installed"] if item["name"] == "12ui-design") + installed_12ui["enabled"] = False + installed_12ui["user"] = "keep-apply" + (fixture / "plugins.json").write_text(json.dumps(state)) + clear_calls() + powershell_apply = invoke("apply-updates.ps1", *options(self.paths)) + self.assertEqual(powershell_apply.returncode, 0, powershell_apply.stderr) + self.assertEqual(powershell_apply.stdout, "Managed component updates applied.\n") + assert_ready(self.paths) + self.assertEqual((stale / "SKILL.md").read_text(), "# simple English selected\n") + self.assertFalse((stale / "obsolete.md").exists()) + state = json.loads((fixture / "plugins.json").read_text()) + installed_12ui = next(item for item in state["installed"] if item["name"] == "12ui-design") + self.assertFalse(installed_12ui["enabled"]) + self.assertEqual(installed_12ui["user"], "keep-apply") + self.assertEqual( + next(item for item in state["installed"] if item["name"] == "ponytail")["user"], + "keep-ponytail", + ) + self.assertEqual(state["marketplaces"][0], {"name": "unrelated", "root": "keep"}) + power_calls = calls() + power_urls = [ + item["command"][1] for item in power_calls if item["command"][0] == "http" + ] + self.assertEqual(power_urls.count("https://pypi.org/pypi/repowise/json"), 1) + for name in ("12ui", "ponytail", "uv"): + self.assertEqual( + power_urls.count( + kit.github_api_url(lock["runtime_tools"][name]["repository"], "releases/latest") + ), + 1, + name, + ) + for name, component in lock["skills"].items(): + self.assertEqual( + power_urls.count( + kit.github_api_url(component["repository"], "commits?per_page=1") + ), + 1, + name, + ) + self.assertEqual( + power_urls.count( + kit.github_api_url( + component["repository"], + f"git/trees/{component['commit']}?recursive=1", + ) + ), + 1, + name, + ) + self.assertEqual(sum(url.startswith("https://astral.sh/uv/8.8.8/") for url in power_urls), 2) + upgrades = [ + item["command"][4] for item in power_calls + if item["command"][1:4] == ["plugin", "marketplace", "upgrade"] + ] + self.assertEqual(upgrades, ["ponytail", "12ui-plugin"]) + self.assertEqual( + sum( + item["command"][1:] == ["--version"] + for item in power_calls if Path(item["command"][0]).name.startswith("repowise") + ), + 2, + ) + self.assertFalse( + any( + item["command"][1:3] == ["tool", "install"] + for item in power_calls if Path(item["command"][0]).name == "uv" + ) + ) + self.assertTrue( + all( + item["home"] == str(self.paths.codex_home) + for item in power_calls if item["command"][:2] == ["codex", "plugin"] + ) + ) + self.assertEqual((self.default_home / "sentinel").read_bytes(), self.default_before) + self.assertEqual((self.paths.codex_home / "plugins/cache/ponytail/revision").read_text(), "refreshed") + self.assertEqual((self.paths.codex_home / "plugins/cache/12ui-plugin/revision").read_text(), "refreshed") + + self.commit_source_lock() + state = json.loads((fixture / "plugins.json").read_text()) + state["fail_http"] = True + (fixture / "plugins.json").write_text(json.dumps(state)) + for script in ("check-updates.sh", "check-updates.ps1"): + clear_calls() + failed = invoke(script) + self.assertEqual(failed.returncode, 2) + self.assertEqual(failed.stdout, "") + self.assertEqual(failed.stderr.count("ERROR:"), 1) + self.assertIn("fixture offline", failed.stderr) + self.assertEqual(len(calls()), 1) + self.assertEqual(calls()[0]["command"][0], "http") + state["fail_http"] = False + state["fail"] = "12ui-plugin" + (fixture / "plugins.json").write_text(json.dumps(state)) + for script in ("apply-updates.sh", "apply-updates.ps1"): + clear_calls() + failed = invoke(script, *options(self.paths)) + self.assertEqual(failed.returncode, 2) + self.assertNotIn("Managed component updates applied.", failed.stdout + failed.stderr) + self.assertEqual(failed.stderr.count("ERROR:"), 1) + self.assertIn("fixture upgrade failed", failed.stderr) + failed_calls = calls() + failed_upgrade = [ + item for item in failed_calls + if item["command"][1:5] == ["plugin", "marketplace", "upgrade", "12ui-plugin"] + ] + self.assertEqual(len(failed_upgrade), 1) + self.assertEqual(failed_calls[-1], failed_upgrade[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/update_subprocess_fixture.py b/tests/update_subprocess_fixture.py new file mode 100644 index 0000000..ff02fcd --- /dev/null +++ b/tests/update_subprocess_fixture.py @@ -0,0 +1,206 @@ +"""External-system fixture loaded by launcher subprocess tests.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import urllib.parse +import urllib.request +import urllib.error + + +FIXTURE = Path(os.environ["CPK_UPDATE_FIXTURE"]) +STATE = FIXTURE / "plugins.json" +CALLS = FIXTURE / "calls.jsonl" +REAL_RUN = subprocess.run +SKILLS = { + "neuroarxiv": { + "repository": "UditAkhourii/neuroarxiv", + "commit": "1" * 40, + "files": { + "skills/neuroarxiv/SKILL.md": b"# neuroarxiv selected\n", + "LICENSE": b"MIT neuroarxiv\n", + }, + }, + "simple-english": { + "repository": "AminBlg/SimpleEnglish", + "commit": "2" * 40, + "files": { + "skills/simple-english/SKILL.md": b"# simple English selected\n", + "LICENSE": b"MIT simple English\n", + }, + }, +} + + +class Response: + def __init__(self, value): + self.body = value if isinstance(value, bytes) else json.dumps(value).encode() + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return self.body + + +def blob(data): + return hashlib.sha1(f"blob {len(data)}\0".encode() + data).hexdigest() + + +def urlopen(request, timeout=60): + if timeout != 60: + raise AssertionError(timeout) + url = request.full_url + record(["http", url], None) + if load_state().get("fail_http"): + raise urllib.error.URLError("fixture offline") + if url == "https://pypi.org/pypi/repowise/json": + return Response({"info": {"version": "9.9.9"}}) + releases = { + "/repos/astral-sh/uv/releases/latest": "v8.8.8", + "/repos/DietrichGebert/ponytail/releases/latest": "v4.12.0", + "/repos/just-every/12ui-plugin/releases/latest": "design-v0.2.107", + } + for suffix, tag in releases.items(): + if url.endswith(suffix): + return Response({"tag_name": tag}) + if url.startswith("https://astral.sh/uv/"): + return Response(b"fixture installer\n") + for item in SKILLS.values(): + repository = item["repository"] + if f"/repos/{repository}/commits?per_page=1" in url: + return Response([{"sha": item["commit"]}]) + if f"/repos/{repository}/git/trees/" in url: + return Response( + { + "truncated": False, + "tree": [ + {"type": "blob", "path": path, "sha": blob(data)} + for path, data in item["files"].items() + ], + } + ) + prefix = f"https://raw.githubusercontent.com/{repository}/" + if url.startswith(prefix): + path = urllib.parse.urlsplit(url).path.lstrip("/").split("/", 3)[3] + return Response(item["files"][path]) + raise AssertionError(f"Unexpected HTTP request: {url}") + + +def load_state(): + return json.loads(STATE.read_text()) + + +def save_state(state): + STATE.write_text(json.dumps(state, sort_keys=True)) + + +def record(command, home): + with CALLS.open("a") as stream: + stream.write(json.dumps({"command": command, "home": home}) + "\n") + + +def plugin(name, enabled=True): + marketplace = "ponytail" if name == "ponytail" else "12ui-plugin" + plugin_name = "ponytail" if name == "ponytail" else "12ui-design" + repository = ( + "https://github.com/DietrichGebert/ponytail.git" + if name == "ponytail" else "https://github.com/just-every/12ui-plugin.git" + ) + package_source = ( + {"source": "git", "url": repository} + if name == "ponytail" + else {"source": "local", "path": "."} + ) + return { + "name": plugin_name, + "pluginId": f"{plugin_name}@{marketplace}", + "installed": True, + "version": "fixture", + "enabled": enabled, + "source": package_source, + "marketplaceSource": {"sourceType": "git", "source": repository}, + } + + +def run(command, *args, **kwargs): + command = [str(value) for value in command] + executable = Path(command[0]).name + if executable == "git": + return REAL_RUN(command, *args, **kwargs) + home = (kwargs.get("env") or os.environ).get("CODEX_HOME") + record(command, home) + if executable == "node": + return subprocess.CompletedProcess(command, 0, "v22.0.0\n", "") + if executable in {"sh", "pwsh"} and kwargs.get("input") is not None: + target = Path(kwargs["env"]["UV_INSTALL_DIR"]) + target.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if executable == "pwsh" else "" + (target / f"uv{suffix}").write_text("fixture") + return subprocess.CompletedProcess(command, 0, "", "") + if executable in {"uv", "uv.exe"}: + if command[1:] == ["--version"]: + return subprocess.CompletedProcess(command, 0, "uv 8.8.8\n", "") + if command[1:3] == ["tool", "install"]: + target = Path(kwargs["env"]["UV_TOOL_BIN_DIR"]) + target.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if executable.endswith(".exe") else "" + (target / f"repowise{suffix}").write_text("fixture") + return subprocess.CompletedProcess(command, 0, "", "") + if executable in {"repowise", "repowise.exe"}: + return subprocess.CompletedProcess(command, 0, "RepoWise 9.9.9\n", "") + if executable != "codex": + raise AssertionError(f"Unexpected process: {command}") + if command[1:] == ["--version"]: + return subprocess.CompletedProcess(command, 0, "codex-cli fixture\n", "") + if command[1:] == ["login", "status"]: + return subprocess.CompletedProcess(command, 0, "Logged in\n", "") + state = load_state() + values = command[2:-1] + if values == ["marketplace", "list"]: + output = {"marketplaces": state["marketplaces"]} + elif values[:2] == ["list", "--marketplace"]: + output = { + "installed": [ + item for item in state["installed"] + if item["pluginId"].endswith("@" + values[2]) + ] + } + elif values[:2] == ["marketplace", "add"]: + name = "ponytail" if "ponytail" in values[2].lower() else "12ui-plugin" + repository = ( + "https://github.com/DietrichGebert/ponytail.git" + if name == "ponytail" else "https://github.com/just-every/12ui-plugin.git" + ) + state["marketplaces"].append( + {"name": name, "marketplaceSource": {"sourceType": "git", "source": repository}} + ) + output = {"added": True} + elif values[:1] == ["add"]: + state["installed"].append(plugin("ponytail" if values[1].startswith("ponytail@") else "12ui")) + output = {"installed": True} + elif values[:2] == ["marketplace", "upgrade"]: + if state.get("fail") == values[2]: + return subprocess.CompletedProcess(command, 1, "", "fixture upgrade failed") + for item in state["installed"]: + if item["pluginId"].endswith("@" + values[2]): + item["version"] = "refreshed" + cache = Path(home) / "plugins" / "cache" / values[2] / "revision" + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text("refreshed") + output = {"upgraded": values[2]} + else: + raise AssertionError(f"Unexpected Codex command: {command}") + save_state(state) + return subprocess.CompletedProcess(command, 0, json.dumps(output), "") + + +urllib.request.urlopen = urlopen +subprocess.run = run diff --git a/upstream.lock.json b/upstream.lock.json index 561b4b4..65c92a6 100644 --- a/upstream.lock.json +++ b/upstream.lock.json @@ -25,10 +25,19 @@ } }, "runtime_tools": { + "12ui": { + "install_mode": "native Codex plugin; install when absent; refresh through its named marketplace", + "license": "MIT", + "marketplace": "just-every/12ui-plugin", + "marketplace_name": "12ui-plugin", + "plugin_id": "12ui-design@12ui-plugin", + "repository": "https://github.com/just-every/12ui-plugin" + }, "ponytail": { - "install_mode": "native Codex plugin; install when absent; updates and removal owned by Codex", + "install_mode": "native Codex plugin; install when absent; refresh through its named marketplace", "license": "MIT", "marketplace": "DietrichGebert/ponytail", + "marketplace_name": "ponytail", "plugin_id": "ponytail@ponytail", "repository": "https://github.com/DietrichGebert/ponytail" }, @@ -72,6 +81,8 @@ } ], "license": "MIT", + "license_path": "LICENSE", + "root": "skills/neuroarxiv", "repository": "https://github.com/UditAkhourii/neuroarxiv" }, "simple-english": { @@ -99,6 +110,8 @@ } ], "license": "MIT", + "license_path": "LICENSE", + "root": "skills/simple-english", "repository": "https://github.com/AminBlg/SimpleEnglish" } } From 4a5a657a9f01ce11de33ed9759db05450164aefe Mon Sep 17 00:00:00 2001 From: Michael Beutler Date: Mon, 5 Oct 2026 15:36:02 -0400 Subject: [PATCH 3/6] fix: stop reinstalling incompatible 12UI hooks --- CODEX-INSTALL-PROMPT.md | 2 +- MANIFEST.sha256 | 21 +- README.md | 21 +- THIRD_PARTY.md | 25 +- docs/REPOWISE.md | 2 +- .../plans/cpk-063-12ui-codex-compatibility.md | 78 +++++++ docs/roadmap.md | 1 + kit.py | 14 +- tests/test_kit.py | 23 +- tests/test_ponytail.py | 8 +- tests/test_updates.py | 213 ++++++++---------- upstream.lock.json | 70 +++--- 12 files changed, 264 insertions(+), 214 deletions(-) create mode 100644 docs/plans/cpk-063-12ui-codex-compatibility.md diff --git a/CODEX-INSTALL-PROMPT.md b/CODEX-INSTALL-PROMPT.md index acbe2ce..d37b570 100644 --- a/CODEX-INSTALL-PROMPT.md +++ b/CODEX-INSTALL-PROMPT.md @@ -22,7 +22,7 @@ Review the changes in `~/.codex/AGENTS.md`, `~/.codex/PLANS.md`, and `~/.codex/c Confirm that these skills are installed: `delivery-lifecycle`, `neuroarxiv`, `plan-history`, `publication`, `repository-knowledge`, `roadmap-maintainer`, and `toolkit-maintainer`. Confirm that `codex-practical-kit-rules` and `task-brief` are absent. Confirm that the RepoWise MCP table has `default_tools_approval_mode = "approve"`, `required = true`, and `startup_timeout_sec = 1800`. -Verify that Node.js and native Codex plugin commands work before you change an existing installation. Install `ponytail@ponytail` and `12ui-design@12ui-plugin` only when absent. Preserve each existing plugin version, configuration, and enabled state. Use the selected Codex home for native commands. Reject a conflicting marketplace source or unowned standalone Ponytail skill. Remove the old copied skill only when the toolkit manifest owns it and native installation succeeds. Make sure that Ponytail provides all six skills and its lifecycle hooks. Do not change Ponytail modes. Use 12UI only for website projects. Explain the Codex `@ponytail` skill controls and link to the complete-removal steps in README. +Verify that Node.js and native Codex plugin commands work before you change an existing installation. Install `ponytail@ponytail` only when absent. Preserve its existing version, configuration, and enabled state. Use the selected Codex home for native commands. Reject a conflicting marketplace source or unowned standalone Ponytail skill. Remove the old copied skill only when the toolkit manifest owns it and native installation succeeds. Make sure that Ponytail provides all six skills and its lifecycle hooks. Do not change Ponytail modes. Do not install or refresh the `just-every/12ui-plugin` Git package. Preserve separately installed 12UI plugins. Use 12UI only for website projects. Explain the Codex `@ponytail` skill controls and link to the complete-removal steps in README. Do not trust toolkit or Ponytail hooks on my behalf. Tell me to open `/hooks` in a new Codex session. If version 0.2.0 was active, do not resume that old session. That session can retain the stale continuation that version 0.2.1 corrected. diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index f9ed6d2..0bb8a45 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -20,11 +20,11 @@ a4d0bc13747cf250f25fb599b37f90cf519c95a29382f4da544afe9ff8095372 ./.agent/plan- d60f352d0db1404c70afb4bb8b2ca3fd1c610572aa40720e8a0b7baa7885418c ./.gitattributes 13d683d7a117f01846724725f15a844768d16f52d226ec6d3f76143b1fc3f6f8 ./.github/workflows/ci.yml 15cefba6945d22a52b5b8ef6dd656fffcb698627b9fb23dacc5c8ec27ad1680d ./.gitignore -b463073a4cc7942388b920d0d23749685dad47ab7fe8d894e61e5c626f8f5f94 ./CODEX-INSTALL-PROMPT.md +289d0dd251ded6c2e7df393a3a08433878a2ddf94514db47ad545c239a28e110 ./CODEX-INSTALL-PROMPT.md 6612129aca01053f15098204fb4bbaafdf85a667956202530bdcbc339e58e462 ./LICENSE e4912f4f8d644555dcd57d5e336a249cf8a9d7f6222b78096d6b403f45a8a215 ./PLAYGROUND.md -b1cdff1ca02a427364817a87ce75580340b440316227ef11542f2356b129bc6f ./README.md -3e5b0840dd197d740c5f7345d86a8658d9e45aeecd16d5785fc7b307e9ba59b3 ./THIRD_PARTY.md +3fdaf82fc173bdf11c9a6b437974750231bae94a383cb2adc13df1056d159625 ./README.md +a2802fdddcfb519f086723b9edf8835cafb38303b8af5dbd8dbbf85fb281029c ./THIRD_PARTY.md 3d652285f38f181fa273e1fc993b08dafa2fc3113c2109ccbdabf1a0c7fec573 ./apply-updates.ps1 74d504bff9dc0b10f116f6b83a452a1c757ca7ad7c65797c7439e928bfbbe45e ./apply-updates.sh d0ab843abb452ecba5386cbc56659aac83400cd4c5fb086db501e232f2e4d0a9 ./assets/AGENTS.block.md @@ -71,7 +71,7 @@ b97c626ce8e220598d12f7bd0cce5099d331aae71a45f692a9eb57d669d0c038 ./check-update 78823831a732ebf870c45b9acc92abddfdddbdbb47db6db81a38ada0497de00f ./docs/OPERATING-MANUAL.md 97dd789229fa55905bd20290c2767ef7bd274cc81965a6f4eb1136dda9ed0adb ./docs/OPTIONAL-REVIEW-TOOLS.md 9bee78762d4d1f446cfb816878e36671cdd823ab62521d32833cc915a572796e ./docs/REMOVE-CBM.md -62dba022bbc34c4239deb7749e58052a05c7d71919e78db459bc31db0c56014b ./docs/REPOWISE.md +d69f7436f838d6daa56b7a311cfdbe7f77fbbf1b9c6c2835a813709e75187416 ./docs/REPOWISE.md e27b3d72ff47ebf7230a96b3a5f2d6ea3f410e22ef382f8e7d9cd27c7bb59892 ./docs/REVIEW.md e86355a4eae8ec5cd42e7674461ebaf1acc4f14f20fa9600da2bb5248efb8e43 ./docs/SPEC-KIT.md f7298a2735489830b11a83c5a852984916147e8c3408214cc722d58a4f7a139f ./docs/WHY-THIS-SHAPE.md @@ -131,12 +131,13 @@ a5d8013da3d22afba28c1259e29e3255d1d19969d0934b2f59069f0de80ea31b ./docs/plans/c 8cea5185f3a1181dc9a1419f3f980712b2b9e35936ec2a8011629d67cd0d041f ./docs/plans/cpk-060-windows-codex-resolution.md ccc9e20f5cd288592bde3dc81fd49820e9f7d62ef2274a99af8ac9781d5d3f5c ./docs/plans/cpk-061-component-updates-agent-cleanup.md 487d05e84cc1c1849f7dff4fc08eac2adad08af9dab8091b60bac48c8ad085c4 ./docs/plans/cpk-062-managed-update-application-12ui.md -3f747ebc949ed432154864b2dd8c1da1e423fc5aec2ffa3e3490a2d5b7353a3e ./docs/roadmap.md +471a4a4af80c1f234983f629dc007708f94fde7868952f15004be57a5176eb82 ./docs/plans/cpk-063-12ui-codex-compatibility.md +d0d2e85b0b519df2fc189c060bbad9e9e64630c47037d890cb1e6b5b4830c393 ./docs/roadmap.md 6f8f14ca5fd8ad935eee66256b3f7c49d5c40957eedff938ba21365e30869719 ./doctor.ps1 ab56a88d9188979f06ea142d1f58ea0d6ad9088e83a965bd324209d83da513ad ./doctor.sh 8087bdb3ee654c0baac278a8e3bf85e33e314f8acfd486e7ebf675126a697058 ./install.ps1 3466d2e34145cfdaa83d793960794b2aefc96f3d9c48d22fd2af930ec819e9c4 ./install.sh -301d556ad0dbbde620b77fab53081821b70f67beb98cfa99608144eb4271538f ./kit.py +4ed95b2f645a3d81aa0b27eeb6daba9678498304dbe90756275e62b471a47055 ./kit.py 331f17ca1486b5779d9b57a3a73834328eb3089ce612e636e6a8e5085899d61e ./run-tests.ps1 e6df9f1da02280a66161583529b06a34f0103041e9587afad0fac6074eecc15b ./run-tests.sh 85a2ba72e2b0cd1ab6c25a51f39059502415c3ac62a6bffa613d7e31b6422cd4 ./setup-repo.ps1 @@ -147,10 +148,10 @@ e1e231aac51822f7e1bb3aa61aba138cf8c5df9cefd3079b0c1b5a98eea03bd9 ./tests/fixtur a269775204ae7cad2457d00f5565c90da2df41a9719479f3489ec01afdd2519c ./tests/fixtures/cpk056-agent-cases.json e32941eb74be1f2cdcb6c82cf71e113b841651bf0d01786f364176de2da1b9c5 ./tests/fixtures/cpk056-agent-grading.json 289367b7090a0d71936b41f08c4a354bc2758013dceb709819de36a1b73d9aa6 ./tests/live_plan_history.py -cdfd409f6ba98e599ec9cd642c8c9c58e42fe747ec2723baf0db73e45d91ebd0 ./tests/test_kit.py -0be6f880195f412efa9c9b7cf8d03c5514254a9b1d31fa1e4e1b1d838ec7a760 ./tests/test_ponytail.py -6d70829e4edaa08bbfa507a3444189278edaa850e5f8c4671553fa27d403d838 ./tests/test_updates.py +fe686dbf58fdd350c8dd9b5a72b7bee50573d5c51d47c1f56706ba0f41b8441b ./tests/test_kit.py +dbeb390655fbe5321578256db17b6a7c9edc1a4a7e7499ff5fe524dccbfc1719 ./tests/test_ponytail.py +0657ab41fb029202f2c30262768d1b6142c45f5214a96589705cc7c3c6ae73ff ./tests/test_updates.py 54673ace20ec6333c73ead405c82d52560848bc8b9aa3a0102fc39935cc3ab87 ./tests/update_subprocess_fixture.py d16f7eadc8c668e82a1d0d2554ed04def15cb0a4b2ce13ac1ce1339bc0e62161 ./uninstall.ps1 f12cff94ef7f9b0ca489cbe43e1de737011476966e04c1578f4977177b0da217 ./uninstall.sh -16c555f5b2ac5588132173fff8125d17278a4b290479d54cc1d7b80df57246b7 ./upstream.lock.json +c7dfb29d1712e1617306f327bdc4d66d4aecfce048def64382c797290e242631 ./upstream.lock.json diff --git a/README.md b/README.md index bc0dd26..ebc9772 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The managed `AGENTS.md` is a small skill router. Skill descriptions keep unrelat ## Included components -The kit installs the official Ponytail and 12UI plugins. It also installs these task skills: +The kit installs the official Ponytail plugin. It also installs these task skills: - `simple-english` - `neuroarxiv` @@ -44,7 +44,7 @@ pwsh -File .\install.ps1 pwsh -File .\doctor.ps1 ``` -The installer copies managed skills to `~/.agents/skills/`. Do not edit installed copies. Codex manages Ponytail and 12UI as native plugins. +The installer copies managed skills to `~/.agents/skills/`. Do not edit installed copies. Codex manages Ponytail as a native plugin. The installer writes a managed block in `~/.codex/AGENTS.md`. It renders the absolute PLANS path and names installed skills. @@ -147,11 +147,22 @@ Migration removes an old standalone Ponytail skill only when the toolkit manifes ## 12UI -The installer uses the official `just-every/12ui-plugin` marketplace. It installs `12ui-design@12ui-plugin` when the plugin is absent. +Install a compatible 12UI plugin through the Codex plugin directory if you want to use it. The toolkit does not install, update, or require 12UI. The toolkit uses 12UI only for website projects. It does not use 12UI for native applications or other user-interface work. -The toolkit does not copy or edit the upstream skill. The apply command refreshes its marketplace and preserves its enabled or disabled state. +The toolkit preserves separately installed 12UI plugins and does not copy or edit their skills. + +The `just-every/12ui-plugin` Git package at version `0.2.108` includes a Claude Code pane. Its hook configuration contains `modules`, which Codex `0.160.0` rejects. + +If an older toolkit installed that package, remove it and its marketplace registration: + +```text +codex plugin remove 12ui-design@12ui-plugin +codex plugin marketplace remove 12ui-plugin +``` + +These commands leave a separately installed `12ui-design@openai-curated-remote` plugin intact. Later toolkit install and apply commands do not restore the Git package. ## Rule owners @@ -276,8 +287,6 @@ Set `CODEX_HOME` to the selected directory with `export CODEX_HOME="$cpk_codex_h ```text codex plugin remove ponytail@ponytail codex plugin marketplace remove ponytail -codex plugin remove 12ui-design@12ui-plugin -codex plugin marketplace remove 12ui-plugin ``` Keep the marketplace if you still use another plugin from it. Normal toolkit uninstall never runs these commands. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 5e205b3..beeb0ce 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -10,25 +10,16 @@ - Version policy: install when absent; preserve existing versions and settings; Codex owns updates and removal - License: MIT -### 12UI - -- Source: https://github.com/just-every/12ui-plugin -- Installation: official native Codex plugin `12ui-design@12ui-plugin` from the `just-every/12ui-plugin` marketplace -- Use: website projects only -- Version policy: install when absent; refresh through the named marketplace; preserve enabled state and user data -- License: MIT - ### SimpleEnglish - Source: https://github.com/AminBlg/SimpleEnglish -- Pinned commit: `59bf6702197a5aadc96d197ea17f290d8d50dcd3` -- Installed files: the Simple English skill, its two reference files, and `LICENSE` +- Pinned commit and installed files: see `skills.simple-english` in `upstream.lock.json` - License: MIT ### NeuroArxiv - Source: https://github.com/UditAkhourii/neuroarxiv -- Pinned commit: `b5d20efa12dd1ba177ce890d56809d2e027f8055` +- Pinned commit: see `skills.neuroarxiv` in `upstream.lock.json` - Installed files: `skills/neuroarxiv/SKILL.md`, `LICENSE` - License: MIT - Integration: required prior-art workflow for qualifying open technical mechanisms @@ -36,7 +27,7 @@ ### RepoWise - Source: https://github.com/repowise-dev/repowise -- Pinned runtime version: `0.45.0` +- Pinned runtime version: see `runtime_tools.repowise` in `upstream.lock.json` - License: AGPL-3.0-or-later - Integration: persistent uv tool, MCP server, and marker-delimited Git hook - RepoWise is not copied into or linked with this kit. @@ -44,13 +35,19 @@ ### uv - Source: https://github.com/astral-sh/uv -- Pinned installer version: `0.12.4` -- Installer SHA-256: `f1ee4a249799525a330df57643335120150c9102db7483b1d37546cc43af3a16` +- Pinned installer version and platform SHA-256 values: see `runtime_tools.uv` in `upstream.lock.json` - License: Apache-2.0 OR MIT - Integration: checksum-verified upstream installer when `uv` is absent ## Optional components +### 12UI + +- Installation and updates: Codex plugin directory, outside toolkit management +- Use: website projects only +- Compatibility: the toolkit does not install the `just-every/12ui-plugin` Git package because version `0.2.108` includes unsupported Claude Code hooks +- Existing plugins: retained without changes + ### OpenAI ExecPlan guidance - Source: https://developers.openai.com/cookbook/articles/codex_exec_plans/ diff --git a/docs/REPOWISE.md b/docs/REPOWISE.md index 79059be..3568b02 100644 --- a/docs/REPOWISE.md +++ b/docs/REPOWISE.md @@ -52,7 +52,7 @@ Agent evidence and review-packet rules are in [Repository knowledge](../assets/s RepoWise is a large external tool and uses AGPL-3.0. The kit does not copy it, modify it, or depend on its database format. -Core installation reuses `uv` when present. If it is absent, the kit installs pinned uv 0.12.4 from a checksum-verified upstream script. Windows uses the official PowerShell installer. macOS and Linux use the official shell installer. The kit installs RepoWise 0.45.0 as a persistent uv tool. A later kit install upgrades only the RepoWise executable recorded at the kit-owned user-bin path. It does not replace an unrecorded command. +Core installation reuses `uv` when present. If it is absent, the kit installs the version from `upstream.lock.json` through a checksum-verified upstream script. Windows uses the official PowerShell installer. macOS and Linux use the official shell installer. The kit installs the locked RepoWise version as a persistent uv tool. A later kit install upgrades only the RepoWise executable recorded at the kit-owned user-bin path. It does not replace an unrecorded command. Doctor uses the runtime paths in the selected installation manifest before it searches `PATH`. Doctor reports the selected uv version. Ordinary installation can reuse an existing uv. Update application installs and records the lock-selected uv. diff --git a/docs/plans/cpk-063-12ui-codex-compatibility.md b/docs/plans/cpk-063-12ui-codex-compatibility.md new file mode 100644 index 0000000..99e3e17 --- /dev/null +++ b/docs/plans/cpk-063-12ui-codex-compatibility.md @@ -0,0 +1,78 @@ +# Stop reinstalling incompatible 12UI hooks + +This ExecPlan is a living specification until review closure. Follow `.agent/PLANS.md`. + +## Purpose / Big Picture + +After removing the incompatible 12UI Git plugin, users must be able to install or apply toolkit updates without restoring its startup warning. The toolkit retains its website-only instruction for separately installed 12UI skills. + +## Context and Orientation + +`kit.py` owns check, apply, install, and Doctor commands. `upstream.lock.json` declares managed components. `native_plugin_status`, `install_core`, and `apply_updates` select native plugins. Doctor currently requires both Ponytail and the incompatible 12UI Git package. `tests/test_updates.py` exercises the commands through external-system fakes and real shell launchers. `tests/test_ponytail.py` covers native installation. + +The task branch is `cpk-063-12ui-codex-compatibility`, based on `cpk-062-managed-update-application-12ui` at `62c549376f2f53a48484ddba49bad607feb426cf`. One writer uses the current checkout. Local main and origin/main match. Preserve the user's applied upstream pins and unrelated untracked files. + +## Product Boundary + +Follow [Scope boundaries](../../assets/skills/design-preflight/references/scope-boundaries.md) and [Supported model](../../assets/skills/design-preflight/references/supported-model.md). The authoritative source is the user's request to update the toolkit after removing the incompatible Git plugin. This explicitly supersedes CPK-062's automatic 12UI Git installation and refresh decision. No immutable Plan history record applies to this focused correction. + +Managed inventory, native plugin selection, Doctor, documentation, and website routing compose the result. Codex plugin internals, upstream packages, and separately installed 12UI are opaque owners. The normal model is one personal user with a selected Codex home and normal Git files. The ceiling excludes editing plugin caches, creating a fork or adapter, downgrading 12UI, installing a replacement automatically, and removing user plugins. + +## Decision Log + +- Decision: remove 12UI from managed runtime inventory and native plugin operations. Keep Ponytail as the managed native plugin. + Rationale: 12UI 0.2.108's `hooks/hooks.json` contains `modules`, which Codex 0.160.0 rejects. Its README identifies the module as a Claude Code pane. The separately installed directory plugin has its own lifecycle. +- Decision: retain the website-only routing instruction unchanged and document separately installed 12UI. + Rationale: the user still wants 12UI for websites. Removing an incompatible package does not require changing that use restriction. +- Decision: use the strict-simplification challenger exemption. + Rationale: this correction deletes an unsupported installation path and adds no interface, persistent state, dependency, fallback, or supported use case. Native review and trace closure still apply. +- Decision: preserve the user's already-applied runtime and skill pins. + Rationale: those source-lock changes are an existing application result, not a request to downgrade installed tools. + +## Boundary Inventory and Scenario Proof + +Follow [Scenario discrimination](../../assets/skills/design-preflight/references/scenario-discrimination.md), [Owner composition](../../assets/skills/design-preflight/references/owner-composition.md), and [Full-set results](../../assets/skills/design-preflight/references/full-set-results.md). + +Every request clause is mapped below. The startup warning is evidence for B1. The user already removed the package, so automatic uninstall is outside scope. Unrelated plugins and upstream workflow internals are opaque. + +| Boundary | Source and invariant | Contrast and required oracle | Entry points and exact named check | +| --- | --- | --- | --- | +| B1 | User correction: toolkit commands must not restore the incompatible 12UI package. | 12UI absent versus a separately installed disabled 12UI with user data. Install and apply succeed, never add or refresh 12UI, and preserve existing state. | `main` install, apply, Doctor; `ComposedUpdateTests.test_12ui_is_unmanaged_across_install_apply_and_doctor` | +| B2 | Retained website-only restriction. | Installed router retains website scope and rejects native-app scope, regardless of plugin presence. | Same composed check as B1. | +| B3 | Managed update completeness and existing launcher behavior remain supported. | Managed components still update, and a Ponytail operation error stops after one attempt. 12UI is absent from the report and provider requests. | Check/apply Bash and PowerShell launchers; `ComposedUpdateTests.test_update_launchers_run_complete_actions_and_propagate_failures` | + +Presence, preservation, and repeated install/apply sequences are applicable input and transition cases. B3 covers every remaining managed record. Unsupported failure models and unrelated plugin operations are excluded. + +## Plan of Work + +One atomic subtask, CP1, removes managed 12UI from `upstream.lock.json`, status selection, install, apply, and Doctor. Delete its special package-source validator and status wrapper. Adapt existing command and launcher tests to preserve separately installed 12UI as an external plugin. Retain Ponytail's official-source checks and update failure checks. Add the B1 composed regression using real command owners and only external fakes. + +Update README, installation prompt, and third-party notices to explain the directory-installed plugin and removal of the old Git package. Keep completed plans unchanged. Regenerate distribution checksums after final documentation. Review the complete task from its base, then perform roadmap closure and one local commit. + +## Concrete Steps and Acceptance + +From the repository root, run: + + PYTHONPATH=tests python3 -m unittest test_updates test_ponytail + ./run-tests.sh + sh -n apply-updates.sh check-updates.sh doctor.sh install.sh run-tests.sh setup-repo.sh uninstall.sh + git diff --check + sha256sum --check MANIFEST.sha256 + +Run B1 and B3 individually and retain their exact results in ignored `.agent/test-results/CPK-063.md`. Require no 12UI native mutation or provider query, preservation of existing 12UI state, the website-only router, and Doctor success with 12UI absent. Perform the required fresh trace closure before native review. After a clean review, update the roadmap and matching checksum, freeze tracked records, and commit locally. Reinstall the corrected toolkit and require Doctor to report ready. + +## Idempotence and Dependencies + +Install and apply remain repeatable under their existing ownership rules. The correction uses Python's standard library and existing native commands. Attempt each external operation once and report an error. No new dependency or plugin format is introduced. + +## Outcomes & Retrospective + +The toolkit no longer installs, queries, refreshes, or requires the incompatible Git package. Separately installed 12UI retains its state, and the website-only router continues to apply. Managed version facts come from the source lock, including the user's applied pins. + +This correction removes the unsupported package path instead of maintaining a modified plugin cache. The package host and hook format must agree before a third-party Git package enters managed installation. + +## Surprises & Discoveries + +The latest upstream package and installed package both contain `{"hooks": {}, "modules": ["./register.mjs"]}`. The [upstream README](https://github.com/just-every/12ui-plugin) describes the pane as a Claude Code feature. [OpenAI plugin documentation](https://developers.openai.com/plugins/build/plugins) distinguishes local Git marketplaces from the shared directory. The existing curated 12UI cache has no `hooks/hooks.json`. + +The change supersedes automatic Git-package installation because that path produces a startup warning on the supported host. diff --git a/docs/roadmap.md b/docs/roadmap.md index 04373c7..1b27175 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,6 +33,7 @@ No residual risk is accepted. ## Completed +- ✅ **CPK-063 — Stop reinstalling incompatible 12UI hooks.** Remove the Claude Code Git package from managed installation, updates, and Doctor. Preserve separately installed 12UI and the website-only routing rule. [ExecPlan](plans/cpk-063-12ui-codex-compatibility.md) - ✅ **CPK-062 — Apply managed updates and add website-only 12UI.** Add explicit update application through thin Bash and PowerShell launchers, reconcile the source lock with the selected installation, install or refresh the official 12UI plugin, and route 12UI only for website projects. [ExecPlan](plans/cpk-062-managed-update-application-12ui.md) - ✅ **CPK-061 — Report managed component updates and clean up finished agent threads.** Add a read-only update check for RepoWise and every other managed component. Teach coordinators to preserve required follow-ups, close terminal delegated threads, and archive finished history with native tools. [ExecPlan](plans/cpk-061-component-updates-agent-cleanup.md) - ✅ **CPK-060 — Resolve the Codex command on Windows.** Use the Windows npm command shim for native plugin installation and Doctor checks. Publish version `0.24.1`. [ExecPlan](plans/cpk-060-windows-codex-resolution.md) diff --git a/kit.py b/kit.py index cc96245..33781a0 100755 --- a/kit.py +++ b/kit.py @@ -301,7 +301,7 @@ def managed_candidates(*, include_payloads: bool = False) -> tuple[dict[str, Any candidate["runtime_tools"][name]["installers"] = installers elif component.get("plugin_id"): upstream = release_identity(repository) - if name in {"ponytail", "12ui"}: + if name == "ponytail": lines.append( f"{name}: Codex-owned plugin; upstream release {upstream}; use the Codex plugin manager" ) @@ -465,7 +465,6 @@ def apply_updates(args: argparse.Namespace, paths: InstallPaths) -> None: candidate, _, payloads = managed_candidates(include_payloads=True) plugin_states = { "ponytail": ponytail_status(paths), - "12ui": twelve_ui_status(paths), } require_clean_update_source(paths) install_core( @@ -743,9 +742,7 @@ def native_plugin_status(paths: InstallPaths, name: str) -> tuple[bool, dict[str elif item.get("pluginId") == plugin_id and item.get("installed") is True: package = item.get("source", {}) package_ok = isinstance(package, dict) and ( - package.get("source") == "local" - if name == "12ui" - else package.get("source") == "git" + package.get("source") == "git" and official_plugin_source(str(package.get("url", "")), repository) ) if not package_ok: @@ -758,10 +755,6 @@ def ponytail_status(paths: InstallPaths) -> tuple[bool, dict[str, Any] | None]: return native_plugin_status(paths, "ponytail") -def twelve_ui_status(paths: InstallPaths) -> tuple[bool, dict[str, Any] | None]: - return native_plugin_status(paths, "12ui") - - def install_core( args: argparse.Namespace, paths: InstallPaths, @@ -775,7 +768,6 @@ def install_core( paths.codex_home.mkdir(parents=True, exist_ok=True) plugin_states = plugin_states or { "ponytail": ponytail_status(paths), - "12ui": twelve_ui_status(paths), } paths.install_root.parent.mkdir(parents=True, exist_ok=True) paths.install_root.mkdir(parents=True, exist_ok=True) @@ -1320,7 +1312,7 @@ def check(condition: bool, label: str, detail: str = "") -> bool: def doctor(args: argparse.Namespace, paths: InstallPaths) -> int: ok = True - for label, status in (("Ponytail", ponytail_status), ("12UI", twelve_ui_status)): + for label, status in (("Ponytail", ponytail_status),): try: _, plugin = status(paths) detail = ( diff --git a/tests/test_kit.py b/tests/test_kit.py index a7d3d3b..945c4b9 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -66,13 +66,10 @@ def setUp(self): self.runtime.start() self.ponytail = mock.patch.object(kit, "ponytail_status", return_value=(True, {"installed": True})) self.ponytail.start() - self.twelve_ui = mock.patch.object(kit, "twelve_ui_status", return_value=(True, {"installed": True})) - self.twelve_ui.start() def tearDown(self): self.runtime.stop() self.ponytail.stop() - self.twelve_ui.stop() def test_run_decodes_utf8_subprocess_output(self): result = kit.run( @@ -1514,7 +1511,7 @@ def fake_run(command, **kwargs): self.assertEqual( commands, [ - ["/usr/bin/uv", "tool", "install", "repowise==0.45.0"], + ["/usr/bin/uv", "tool", "install", f"repowise=={kit.load_lock()['runtime_tools']['repowise']['version']}"], ["/usr/bin/uv", "tool", "update-shell"], ], ) @@ -1582,8 +1579,6 @@ def fake_run(command, **_kwargs): kit, "stage_upstream_skills", side_effect=fake_stage ), mock.patch.object(kit, "run", side_effect=fake_run), mock.patch.object( kit, "ponytail_status", return_value=(True, {"installed": True}) - ), mock.patch.object( - kit, "twelve_ui_status", return_value=(True, {"installed": True}) ): kit.install_core(Namespace(), paths) @@ -1592,7 +1587,7 @@ def fake_run(command, **_kwargs): self.assertEqual(repowise.read_bytes(), b"repaired") self.assertEqual(manifest["repowise"], str(repowise)) self.assertIn( - [r"C:\Tools\uv.exe", "tool", "install", "--force", "repowise==0.45.0"], + [r"C:\Tools\uv.exe", "tool", "install", "--force", f"repowise=={kit.load_lock()['runtime_tools']['repowise']['version']}"], commands, ) @@ -1653,7 +1648,7 @@ def fake_run(command, **_kwargs): self.assertEqual(result, str(repowise)) self.assertEqual(repowise.read_bytes(), b"unchanged") self.assertIn( - ["/usr/bin/uv", "tool", "install", "--force", "repowise==0.45.0"], + ["/usr/bin/uv", "tool", "install", "--force", f"repowise=={kit.load_lock()['runtime_tools']['repowise']['version']}"], commands, ) @@ -1666,7 +1661,7 @@ def test_matching_repowise_version_is_reused(self): ), mock.patch.object( kit, "run", - return_value=subprocess.CompletedProcess([], 0, "RepoWise 0.45.0", ""), + return_value=subprocess.CompletedProcess([], 0, f"RepoWise {kit.load_lock()['runtime_tools']['repowise']['version']}", ""), ) as run: result = kit.ensure_repowise(paths, r"C:\Tools\uv.exe") self.assertEqual(result, str(repowise)) @@ -1697,7 +1692,7 @@ def fake_run(command, **_kwargs): self.assertEqual(kit.ensure_repowise(paths, "/usr/bin/uv"), str(repowise)) self.assertIn( - ["/usr/bin/uv", "tool", "install", "--force", "repowise==0.45.0"], + ["/usr/bin/uv", "tool", "install", "--force", f"repowise=={kit.load_lock()['runtime_tools']['repowise']['version']}"], commands, ) @@ -2135,9 +2130,6 @@ def responses(self): runtime = lock["runtime_tools"] skills = lock["skills"] return { - kit.github_api_url(runtime["12ui"]["repository"], "releases/latest"): { - "tag_name": "design-v0.2.107" - }, kit.github_api_url(runtime["ponytail"]["repository"], "releases/latest"): { "tag_name": "v4.12.0" }, @@ -2195,7 +2187,6 @@ def test_check_updates_reports_complete_managed_inventory(self): lock = kit.load_lock() expected_lines = [ "Managed component updates:", - "- 12ui: Codex-owned plugin; upstream release design-v0.2.107; use the Codex plugin manager", "- ponytail: Codex-owned plugin; upstream release v4.12.0; use the Codex plugin manager", ( f"- repowise: toolkit pin {lock['runtime_tools']['repowise']['version']}; " @@ -2456,7 +2447,7 @@ def observe(condition, label, detail=""): kit, "command_version", side_effect=lambda command: ( - "RepoWise 0.45.0" if "repowise" in command[0] else "codex-cli 0.149" + f"RepoWise {kit.load_lock()['runtime_tools']['repowise']['version']}" if "repowise" in command[0] else "codex-cli 0.149" ), ), mock.patch.object(kit, "run", side_effect=doctor_run): self.assertEqual(kit.doctor(Namespace(repo=str(linked)), paths), 0) @@ -3100,7 +3091,7 @@ def test_neuroarxiv_is_pinned_and_required_only_for_qualifying_research(self): optional = (ROOT / "docs" / "OPTIONAL-REVIEW-TOOLS.md").read_text() self.assertIn("neuroarxiv", kit.UPSTREAM_SKILLS) - self.assertEqual(neuro["commit"], "b5d20efa12dd1ba177ce890d56809d2e027f8055") + self.assertRegex(neuro["commit"], r"^[0-9a-f]{40}$") self.assertEqual(neuro["license"], "MIT") self.assertEqual( {(item["destination"], item["git_blob_sha1"]) for item in neuro["files"]}, diff --git a/tests/test_ponytail.py b/tests/test_ponytail.py index 7882482..6109992 100644 --- a/tests/test_ponytail.py +++ b/tests/test_ponytail.py @@ -164,7 +164,7 @@ def test_fresh_install_registers_complete_plugin(self): result, output = self.cli("install") self.assertEqual(result, 0, output) self.assertIn("`/hooks` to review and trust toolkit and plugin hooks", output) - self.assertEqual(len(self.mutations()), 4) + self.assertEqual(len(self.mutations()), 2) package = self.paths.codex_home / "plugins/cache/ponytail" self.assertEqual(sorted(p.parent.name for p in package.glob("skills/*/SKILL.md")), sorted(self.SKILLS)) self.assertEqual(set(json.loads((package / "hooks.json").read_text())["hooks"]), {"SessionStart", "UserPromptSubmit", "SubagentStart"}) @@ -207,7 +207,7 @@ def test_legacy_skill_migration_requires_recorded_ownership(self): def test_existing_official_plugin_is_reused(self): kit.write_file(self.paths.codex_home / "config.toml", '# unrelated user configuration\n') self.assertEqual(self.cli("install")[0], 0) - self.assertEqual(len(self.mutations()), 4) + self.assertEqual(len(self.mutations()), 2) self.calls.clear() for enabled in (True, False): with self.subTest(enabled=enabled): @@ -320,9 +320,7 @@ def test_foreign_marketplace_is_preserved(self): self.write_state(state) self.assertEqual(self.cli("install")[0], 0) self.assertFalse((self.paths.skills_home / "ponytail").exists()) - installed_marketplaces = self.state()["marketplaces"] - self.assertEqual(installed_marketplaces[: len(state["marketplaces"])], state["marketplaces"]) - self.assertEqual(installed_marketplaces[-1]["name"], "12ui-plugin") + self.assertEqual(self.state()["marketplaces"], state["marketplaces"]) if __name__ == "__main__": diff --git a/tests/test_updates.py b/tests/test_updates.py index 8711999..07e34dd 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -222,34 +222,33 @@ def stage(destination): def test_apply_native_plugins_preserves_user_state(self): disabled = {"installed": True, "enabled": False, "user": "keep"} - states = {"ponytail": (True, {"installed": True, "enabled": True}), "12ui": (True, disabled)} + states = {"ponytail": (True, disabled)} paths, calls = self._run_install_plugins(states, refresh=True) self.assertEqual( calls, - [("marketplace", "upgrade", "ponytail"), ("marketplace", "upgrade", "12ui-plugin")], + [("marketplace", "upgrade", "ponytail")], ) self.assertFalse(disabled["enabled"]) self.assertEqual(disabled["user"], "keep") self.assertTrue((paths.codex_home / "AGENTS.md").is_file()) - def test_install_12ui_absent_or_existing(self): - absent = {"ponytail": (True, {"installed": True}), "12ui": (False, None)} + def test_install_ponytail_absent_or_existing(self): + absent = {"ponytail": (False, None)} _paths, calls = self._run_install_plugins(absent, refresh=False) self.assertEqual( calls, - [("marketplace", "add", "just-every/12ui-plugin"), ("add", "12ui-design@12ui-plugin")], + [("marketplace", "add", "DietrichGebert/ponytail"), ("add", "ponytail@ponytail")], ) - existing = {"ponytail": (True, {"installed": True}), "12ui": (True, {"installed": True, "enabled": False})} + existing = {"ponytail": (True, {"installed": True, "enabled": False})} _paths, calls = self._run_install_plugins(existing, refresh=False) self.assertEqual(calls, []) - existing_marketplace = {"ponytail": (True, {"installed": True}), "12ui": (True, None)} + existing_marketplace = {"ponytail": (True, None)} _paths, calls = self._run_install_plugins(existing_marketplace, refresh=True) self.assertEqual( calls, [ ("marketplace", "upgrade", "ponytail"), - ("marketplace", "upgrade", "12ui-plugin"), - ("add", "12ui-design@12ui-plugin"), + ("add", "ponytail@ponytail"), ], ) @@ -266,11 +265,10 @@ def test_update_set_cardinality_and_late_record(self): "repowise": lock["runtime_tools"]["repowise"]["version"], "uv": "v" + lock["runtime_tools"]["uv"]["version"], "ponytail": "v4.12.0", - "12ui": "design-v0.2.107", } def release(repository): - for name in ("12ui", "ponytail", "uv"): + for name in ("ponytail", "uv"): if repository == lock["runtime_tools"][name]["repository"]: return responses[name] self.fail(repository) @@ -586,6 +584,8 @@ def cli(self, command): "--skills-home", str(self.paths.skills_home), "--install-root", str(self.paths.install_root), ] + if command == "check-updates": + args = [command] stdout, stderr = io.StringIO(), io.StringIO() with mock.patch.object(subprocess, "run", side_effect=self.fake_run), contextlib.redirect_stdout( stdout @@ -609,13 +609,13 @@ def test_apply_main_reconciles_complete_installation_and_plugins(self): selected = json.loads((self.source / "upstream.lock.json").read_text()) self.assertEqual(selected["runtime_tools"]["repowise"]["version"], "9.9.9") self.assertEqual(selected["runtime_tools"]["uv"]["version"], "8.8.8") - self.assertEqual(set(selected["runtime_tools"]), {"12ui", "ponytail", "repowise", "uv"}) + self.assertEqual(set(selected["runtime_tools"]), {"ponytail", "repowise", "uv"}) self.assertEqual(set(selected["skills"]), set(self.skill_payloads)) self.assertFalse( any(name in url for name in selected["optional_tools"] for url in self.http_calls) ) self.assertEqual(self.http_calls.count("https://pypi.org/pypi/repowise/json"), 1) - for name in ("12ui", "ponytail", "uv"): + for name in ("ponytail", "uv"): self.assertEqual( self.http_calls.count( kit.github_api_url(selected["runtime_tools"][name]["repository"], "releases/latest") @@ -645,8 +645,7 @@ def test_apply_main_reconciles_complete_installation_and_plugins(self): {name: item["commit"] for name, item in manifest["upstream"].items()}, self.skill_commits, ) - installed_12ui = next(item for item in self.plugin_state["installed"] if item["name"] == "12ui-design") - self.assertTrue(installed_12ui["enabled"]) + self.assertFalse(any(item["name"] == "12ui-design" for item in self.plugin_state["installed"])) ponytail = next(item for item in self.plugin_state["installed"] if item["name"] == "ponytail") self.assertFalse(ponytail["enabled"]) self.assertEqual(ponytail["user"], "keep") @@ -670,25 +669,21 @@ def test_apply_main_reconciles_complete_installation_and_plugins(self): ) self.assertEqual( sum(call[1:3] == ["plugin", "add"] and call[3] == "12ui-design@12ui-plugin" for call in first_processes), - 1, + 0, ) self.assertEqual( sum(call[1:4] == ["plugin", "marketplace", "add"] and call[4] == "just-every/12ui-plugin" for call in first_processes), - 1, + 0, ) stale = self.paths.skills_home / "simple-english" (stale / "SKILL.md").write_text("stale") (stale / "obsolete.md").write_text("obsolete") - installed_12ui["enabled"] = False - installed_12ui["user"] = "preserve" status, stdout, stderr = self.cli("apply-updates") self.assertEqual((status, stderr), (0, "")) self.assertIn("Managed component updates applied.", stdout) self.assertEqual((stale / "SKILL.md").read_bytes(), self.skill_payloads["simple-english"]["skills/simple-english/SKILL.md"]) self.assertFalse((stale / "obsolete.md").exists()) - self.assertFalse(installed_12ui["enabled"]) - self.assertEqual(installed_12ui["user"], "preserve") router = (self.paths.codex_home / "AGENTS.md").read_text() self.assertIn("Use 12ui-design only for website projects", router) self.assertNotIn("all non-trivial UI", router.lower()) @@ -696,15 +691,12 @@ def test_apply_main_reconciles_complete_installation_and_plugins(self): call[0][4] for call in self.process_calls if call[0][1:4] == ["plugin", "marketplace", "upgrade"] ] - self.assertEqual(upgrades[-2:], ["ponytail", "12ui-plugin"]) + self.assertEqual(upgrades, ["ponytail", "ponytail"]) self.assertEqual( (self.paths.codex_home / "plugins/cache/ponytail/revision").read_text(), "refreshed", ) - self.assertEqual( - (self.paths.codex_home / "plugins/cache/12ui-plugin/revision").read_text(), - "refreshed", - ) + self.assertFalse((self.paths.codex_home / "plugins/cache/12ui-plugin").exists()) self.assertTrue( all( home == str(self.paths.codex_home) @@ -715,83 +707,69 @@ def test_apply_main_reconciles_complete_installation_and_plugins(self): self.assertEqual(self.plugin_state["marketplaces"][0], {"name": "unrelated", "root": "keep"}) self.assertEqual(self.cli("doctor")[0], 0) - def test_install_main_fresh_and_preserves_disabled_12ui(self): + def test_12ui_is_unmanaged_across_install_apply_and_doctor(self): candidate, _lines, _payloads = kit.managed_candidates(include_payloads=True) kit.write_file( self.source / "upstream.lock.json", json.dumps(candidate, indent=2, sort_keys=True) + "\n", ) self.commit_source_lock() - status, stdout, stderr = self.cli("install") - self.assertEqual((status, stderr), (0, "")) - self.assertIn("Core kit installed.", stdout) - self.assertEqual( - sum(item["name"] == "12ui-plugin" for item in self.plugin_state["marketplaces"]), - 1, - ) - self.assertEqual( - sum(item["name"] == "12ui-design" for item in self.plugin_state["installed"]), - 1, - ) - installed = next(item for item in self.plugin_state["installed"] if item["name"] == "12ui-design") - self.assertTrue(installed["enabled"]) - self.assertIn( - "Use 12ui-design only for website projects", - (self.paths.codex_home / "AGENTS.md").read_text(), - ) - self.assertNotIn( - "all non-trivial UI", - (self.paths.codex_home / "AGENTS.md").read_text().lower(), - ) - self.assertEqual(self.cli("doctor")[0], 0) - - installed["enabled"] = False - installed["user"] = "keep" - before = len(self.process_calls) - self.assertEqual(self.cli("install")[0], 0) - mutations = [ - command for command, _home in self.process_calls[before:] - if command[:2] == ["codex", "plugin"] - and (command[2:4] == ["marketplace", "add"] or command[2] == "add") - ] - self.assertEqual(mutations, []) - self.assertFalse(installed["enabled"]) - self.assertEqual(installed["user"], "keep") - self.assertEqual( - sum(item["name"] == "12ui-design" for item in self.plugin_state["installed"]), - 1, - ) - self.assertNotIn( - "all non-trivial UI", - (self.paths.codex_home / "AGENTS.md").read_text().lower(), - ) - self.assertEqual(self.cli("doctor")[0], 0) - - self.plugin_state["installed"] = [ - item for item in self.plugin_state["installed"] if item["name"] != "12ui-design" - ] - before = len(self.process_calls) - self.assertEqual(self.cli("install")[0], 0) - install_calls = [call[0] for call in self.process_calls[before:]] - self.assertTrue(any(call[1:3] == ["plugin", "add"] and call[3] == "12ui-design@12ui-plugin" for call in install_calls)) - installed_12ui = next(item for item in self.plugin_state["installed"] if item["name"] == "12ui-design") - installed_12ui["enabled"] = False - installed_12ui["user"] = "ordinary-install-preserves" - before = len(self.process_calls) - self.assertEqual(self.cli("install")[0], 0) - self.assertFalse(installed_12ui["enabled"]) - self.assertEqual(installed_12ui["user"], "ordinary-install-preserves") - self.assertFalse( - any( - call[0][1:3] == ["plugin", "add"] and call[0][3] == "12ui-design@12ui-plugin" - for call in self.process_calls[before:] - ) - ) - self.assertEqual(self.cli("doctor")[0], 0) + # Retained Codex listing shape, independent of the toolkit's request generator. + curated = { + "pluginId": "12ui-design@openai-curated-remote", + "name": "12ui-design", + "marketplaceName": "openai-curated-remote", + "version": "0.2.65", + "installed": True, + "enabled": False, + "source": {"source": "remote", "id": "plugins_6a8915941e8c8191ae58d10e41cc322f"}, + "user": "keep", + } + cache = self.paths.codex_home / "plugins/cache/openai-curated-remote/12ui-design/0.2.65/skills/12ui-design/SKILL.md" + for existing in (False, True): + with self.subTest(existing_12ui=existing): + if existing: + self.plugin_state["installed"].append(copy.deepcopy(curated)) + kit.write_file(cache, "# separately installed 12UI\n") + kit.write_file( + self.paths.codex_home / "config.toml", + kit.read_text(self.paths.codex_home / "config.toml") + + '\n[plugins."12ui-design@openai-curated-remote"]\nenabled = false\n', + ) + before = len(self.process_calls) + http_before = len(self.http_calls) + for command in ("check-updates", "install", "apply-updates", "install", "doctor"): + status, stdout, stderr = self.cli(command) + self.assertEqual((status, stderr), (0, ""), command) + if command == "check-updates": + self.assertNotIn("12ui", stdout.lower()) + elif command == "doctor": + self.assertIn("Result: ready", stdout) + self.assertNotIn("12UI", stdout) + plugins = [ + item for item in self.plugin_state["installed"] + if item["name"] == "12ui-design" + ] + self.assertEqual(plugins, [curated] if existing else []) + if existing: + self.assertEqual(cache.read_bytes(), b"# separately installed 12UI\n") + self.assertIn( + '[plugins."12ui-design@openai-curated-remote"]\nenabled = false', + (self.paths.codex_home / "config.toml").read_text(), + ) + router = (self.paths.codex_home / "AGENTS.md").read_text() + self.assertIn("Use 12ui-design only for website projects", router) + self.assertIn("Do not use it for native apps or other UI work", router) + self.assertFalse( + any("12ui" in argument.lower() for command, _home in self.process_calls[before:] for argument in command) + ) + self.assertFalse(any("12ui" in url.lower() for url in self.http_calls[http_before:])) + self.assertFalse((self.paths.codex_home / "plugins/cache/12ui-plugin").exists()) + self.assertEqual((self.default_home / "sentinel").read_bytes(), self.default_before) def test_apply_main_stops_after_first_operation_error(self): self.assertEqual(self.cli("apply-updates")[0], 0) - self.fail_upgrade = "12ui-plugin" + self.fail_upgrade = "ponytail" before = len(self.process_calls) status, stdout, stderr = self.cli("apply-updates") calls = self.process_calls[before:] @@ -802,18 +780,18 @@ def test_apply_main_stops_after_first_operation_error(self): self.assertNotIn("Managed component updates applied.", stderr) self.assertEqual( [call[0][4] for call in calls if call[0][1:4] == ["plugin", "marketplace", "upgrade"]], - ["ponytail", "12ui-plugin"], + ["ponytail"], ) self.assertEqual( sum( - call[0][1:5] == ["plugin", "marketplace", "upgrade", "12ui-plugin"] + call[0][1:5] == ["plugin", "marketplace", "upgrade", "ponytail"] for call in calls ), 1, ) self.assertEqual( calls[-1][0][1:5], - ["plugin", "marketplace", "upgrade", "12ui-plugin"], + ["plugin", "marketplace", "upgrade", "ponytail"], ) self.assertFalse(any(Path(call[0][0]).name.startswith("uv") for call in calls)) @@ -932,7 +910,6 @@ def expected_check_output(): lines = ["Managed component updates:"] lines.extend( ( - "- 12ui: Codex-owned plugin; upstream release design-v0.2.107; use the Codex plugin manager", "- ponytail: Codex-owned plugin; upstream release v4.12.0; use the Codex plugin manager", ( f"- repowise: toolkit pin {lock['runtime_tools']['repowise']['version']}; " @@ -961,7 +938,7 @@ def assert_ready(paths): lock = json.loads((self.source / "upstream.lock.json").read_text()) self.assertEqual(lock["runtime_tools"]["repowise"]["version"], "9.9.9") self.assertEqual(lock["runtime_tools"]["uv"]["version"], "8.8.8") - self.assertEqual(set(lock["runtime_tools"]), {"12ui", "ponytail", "repowise", "uv"}) + self.assertEqual(set(lock["runtime_tools"]), {"ponytail", "repowise", "uv"}) self.assertEqual(set(lock["skills"]), {"neuroarxiv", "simple-english"}) expected_files = { "neuroarxiv": { @@ -1010,6 +987,9 @@ def assert_ready(paths): self.assertEqual(bash_check.returncode, 0, bash_check.stderr) self.assertEqual(bash_check.stdout, expected_check_output()) self.assertEqual(snapshot(self.paths), before) + self.assertFalse( + any("12ui" in item["command"][1].lower() for item in calls() if item["command"][0] == "http") + ) clear_calls() bash_apply = invoke("apply-updates.sh", *options(self.paths)) @@ -1018,6 +998,7 @@ def assert_ready(paths): assert_ready(self.paths) bash_calls = calls() bash_urls = [item["command"][1] for item in bash_calls if item["command"][0] == "http"] + self.assertFalse(any("12ui" in url.lower() for url in bash_urls)) lock = json.loads((self.source / "upstream.lock.json").read_text()) self.assertFalse(any(name in url for name in lock["optional_tools"] for url in bash_urls)) for name, component in lock["skills"].items(): @@ -1029,7 +1010,7 @@ def assert_ready(paths): ) self.assertEqual(bash_urls.count(tree_url), 1, name) self.assertEqual(bash_urls.count("https://pypi.org/pypi/repowise/json"), 1) - for name in ("12ui", "ponytail", "uv"): + for name in ("ponytail", "uv"): self.assertEqual( bash_urls.count( kit.github_api_url(lock["runtime_tools"][name]["repository"], "releases/latest") @@ -1049,11 +1030,11 @@ def assert_ready(paths): ) self.assertEqual( [item for item in mutations if item[:2] == ["marketplace", "add"]], - [["marketplace", "add", "just-every/12ui-plugin"]], + [], ) self.assertEqual( [item for item in mutations if item[:1] == ["add"]], - [["add", "12ui-design@12ui-plugin"]], + [], ) self.assertEqual( sum( @@ -1091,9 +1072,8 @@ def assert_ready(paths): self.assertEqual(bash_install.returncode, 0, bash_install.stderr) assert_ready(bash_paths) state = json.loads((fixture / "plugins.json").read_text()) - installed_12ui = next(item for item in state["installed"] if item["name"] == "12ui-design") - installed_12ui["enabled"] = False - installed_12ui["user"] = "keep-bash" + self.assertFalse(any(item["name"] == "12ui-design" for item in state["installed"])) + state["installed"].append(self.plugin("12ui", enabled=False, user="keep-bash")) (fixture / "plugins.json").write_text(json.dumps(state)) clear_calls() self.assertEqual(invoke("install.sh", *options(bash_paths)).returncode, 0) @@ -1121,9 +1101,8 @@ def assert_ready(paths): self.assertEqual(powershell_install.returncode, 0, powershell_install.stderr) assert_ready(powershell_paths) state = json.loads((fixture / "plugins.json").read_text()) - installed_12ui = next(item for item in state["installed"] if item["name"] == "12ui-design") - installed_12ui["enabled"] = False - installed_12ui["user"] = "keep-powershell" + self.assertFalse(any(item["name"] == "12ui-design" for item in state["installed"])) + state["installed"].append(self.plugin("12ui", enabled=False, user="keep-powershell")) (fixture / "plugins.json").write_text(json.dumps(state)) clear_calls() self.assertEqual(invoke("install.ps1", *options(powershell_paths)).returncode, 0) @@ -1147,14 +1126,15 @@ def assert_ready(paths): self.assertEqual(powershell_check.returncode, 0, powershell_check.stderr) self.assertEqual(powershell_check.stdout, expected_check_output()) self.assertEqual(snapshot(self.paths), before) + self.assertFalse( + any("12ui" in item["command"][1].lower() for item in calls() if item["command"][0] == "http") + ) stale = self.paths.skills_home / "simple-english" (stale / "SKILL.md").write_text("stale") (stale / "obsolete.md").write_text("obsolete") state = json.loads((fixture / "plugins.json").read_text()) - installed_12ui = next(item for item in state["installed"] if item["name"] == "12ui-design") - installed_12ui["enabled"] = False - installed_12ui["user"] = "keep-apply" + state["installed"].append(self.plugin("12ui", enabled=False, user="keep-apply")) (fixture / "plugins.json").write_text(json.dumps(state)) clear_calls() powershell_apply = invoke("apply-updates.ps1", *options(self.paths)) @@ -1177,7 +1157,7 @@ def assert_ready(paths): item["command"][1] for item in power_calls if item["command"][0] == "http" ] self.assertEqual(power_urls.count("https://pypi.org/pypi/repowise/json"), 1) - for name in ("12ui", "ponytail", "uv"): + for name in ("ponytail", "uv"): self.assertEqual( power_urls.count( kit.github_api_url(lock["runtime_tools"][name]["repository"], "releases/latest") @@ -1208,7 +1188,7 @@ def assert_ready(paths): item["command"][4] for item in power_calls if item["command"][1:4] == ["plugin", "marketplace", "upgrade"] ] - self.assertEqual(upgrades, ["ponytail", "12ui-plugin"]) + self.assertEqual(upgrades, ["ponytail"]) self.assertEqual( sum( item["command"][1:] == ["--version"] @@ -1230,7 +1210,8 @@ def assert_ready(paths): ) self.assertEqual((self.default_home / "sentinel").read_bytes(), self.default_before) self.assertEqual((self.paths.codex_home / "plugins/cache/ponytail/revision").read_text(), "refreshed") - self.assertEqual((self.paths.codex_home / "plugins/cache/12ui-plugin/revision").read_text(), "refreshed") + self.assertFalse((self.paths.codex_home / "plugins/cache/12ui-plugin").exists()) + self.assertFalse(any("12ui" in url.lower() for url in power_urls)) self.commit_source_lock() state = json.loads((fixture / "plugins.json").read_text()) @@ -1246,7 +1227,7 @@ def assert_ready(paths): self.assertEqual(len(calls()), 1) self.assertEqual(calls()[0]["command"][0], "http") state["fail_http"] = False - state["fail"] = "12ui-plugin" + state["fail"] = "ponytail" (fixture / "plugins.json").write_text(json.dumps(state)) for script in ("apply-updates.sh", "apply-updates.ps1"): clear_calls() @@ -1258,7 +1239,7 @@ def assert_ready(paths): failed_calls = calls() failed_upgrade = [ item for item in failed_calls - if item["command"][1:5] == ["plugin", "marketplace", "upgrade", "12ui-plugin"] + if item["command"][1:5] == ["plugin", "marketplace", "upgrade", "ponytail"] ] self.assertEqual(len(failed_upgrade), 1) self.assertEqual(failed_calls[-1], failed_upgrade[0]) diff --git a/upstream.lock.json b/upstream.lock.json index 65c92a6..f86888d 100644 --- a/upstream.lock.json +++ b/upstream.lock.json @@ -1,5 +1,5 @@ { - "generated_on": "2026-08-24", + "generated_on": "2026-10-05", "optional_tools": { "compound-engineering": { "commit": "05f3b798b0e5fb76c6bb1830bee7cbc84ff6c4c4", @@ -25,14 +25,6 @@ } }, "runtime_tools": { - "12ui": { - "install_mode": "native Codex plugin; install when absent; refresh through its named marketplace", - "license": "MIT", - "marketplace": "just-every/12ui-plugin", - "marketplace_name": "12ui-plugin", - "plugin_id": "12ui-design@12ui-plugin", - "repository": "https://github.com/just-every/12ui-plugin" - }, "ponytail": { "install_mode": "native Codex plugin; install when absent; refresh through its named marketplace", "license": "MIT", @@ -45,74 +37,84 @@ "install_mode": "uv tool install; not bundled", "license": "AGPL-3.0-or-later", "repository": "https://github.com/repowise-dev/repowise", - "version": "0.45.0" + "version": "0.55.0" }, "uv": { "install_mode": "verified upstream installer when missing", "installers": { "posix": { - "sha256": "f1ee4a249799525a330df57643335120150c9102db7483b1d37546cc43af3a16", - "url": "https://astral.sh/uv/0.12.4/install.sh" + "sha256": "b8e6c43099ee9f9a550984d3ad56948457c689e7a99c090b35377234ac241491", + "url": "https://astral.sh/uv/0.12.23/install.sh" }, "windows": { - "sha256": "76a0c027f3d47a7ced56f9e63e67a21cb1bcbf525c8ba9ef7ec0d633cc8f89e4", - "url": "https://astral.sh/uv/0.12.4/install.ps1" + "sha256": "536e6ebe00d41efc96b0ab1121bf6f969b0e9cbd88d1e19cfd09e63722e96160", + "url": "https://astral.sh/uv/0.12.23/install.ps1" } }, "license": "Apache-2.0 OR MIT", "repository": "https://github.com/astral-sh/uv", - "version": "0.12.4" + "version": "0.12.23" } }, "schema_version": 1, "skills": { "neuroarxiv": { - "commit": "b5d20efa12dd1ba177ce890d56809d2e027f8055", + "commit": "7d59a92553762e6307b973312e0d2ed32118e6a8", "files": [ - { - "destination": "neuroarxiv/SKILL.md", - "git_blob_sha1": "cc47304c76f7cb53aa4e8a324702dc8e4f1d5639", - "path": "skills/neuroarxiv/SKILL.md" - }, { "destination": "neuroarxiv/LICENSE", "git_blob_sha1": "aaf95a45d3b11d68387ab0c3a0cee964a4ecd98c", "path": "LICENSE" + }, + { + "destination": "neuroarxiv/SKILL.md", + "git_blob_sha1": "cc47304c76f7cb53aa4e8a324702dc8e4f1d5639", + "path": "skills/neuroarxiv/SKILL.md" } ], "license": "MIT", "license_path": "LICENSE", - "root": "skills/neuroarxiv", - "repository": "https://github.com/UditAkhourii/neuroarxiv" + "repository": "https://github.com/UditAkhourii/neuroarxiv", + "root": "skills/neuroarxiv" }, "simple-english": { - "commit": "59bf6702197a5aadc96d197ea17f290d8d50dcd3", + "commit": "a6fcb4fde098b33617cc1578d151ebf58774b883", "files": [ + { + "destination": "simple-english/LICENSE", + "git_blob_sha1": "e3dd42a3e6dc3ba07a742a83d27b5377a719e814", + "path": "LICENSE" + }, { "destination": "simple-english/SKILL.md", - "git_blob_sha1": "7c41a83908b8adac6a83571df38ef4b731a0a532", + "git_blob_sha1": "18968a8a2c631b577327fde0271940335c6a375c", "path": "skills/simple-english/SKILL.md" }, { - "destination": "simple-english/references/checklist.md", - "git_blob_sha1": "727536d895f51a977237c61335848aec4cc77602", - "path": "skills/simple-english/references/checklist.md" + "destination": "simple-english/references/rule-catalog.md", + "git_blob_sha1": "23fd3b823e529b2e2c3f134dd51bf6e2056c88d3", + "path": "skills/simple-english/references/rule-catalog.md" + }, + { + "destination": "simple-english/references/strict-vocabulary.md", + "git_blob_sha1": "2233a6da3b35c3e6845722496644e6d555b66b7f", + "path": "skills/simple-english/references/strict-vocabulary.md" }, { "destination": "simple-english/references/use-cases.md", - "git_blob_sha1": "61036a228be7e7f2b017ffbbfdc034900bc1bf24", + "git_blob_sha1": "10b2862740a57f3b091a8c7ff7e5399d548dea18", "path": "skills/simple-english/references/use-cases.md" }, { - "destination": "simple-english/LICENSE", - "git_blob_sha1": "e3dd42a3e6dc3ba07a742a83d27b5377a719e814", - "path": "LICENSE" + "destination": "simple-english/references/word-swaps.md", + "git_blob_sha1": "5747f6cb58c029c52b09620f2212acd3ae843336", + "path": "skills/simple-english/references/word-swaps.md" } ], "license": "MIT", "license_path": "LICENSE", - "root": "skills/simple-english", - "repository": "https://github.com/AminBlg/SimpleEnglish" + "repository": "https://github.com/AminBlg/SimpleEnglish", + "root": "skills/simple-english" } } } From 635cebb34e37859567c886aac638384f51a33f0f Mon Sep 17 00:00:00 2001 From: Michael Beutler Date: Mon, 5 Oct 2026 16:03:00 -0400 Subject: [PATCH 4/6] feat: identify managed update commands as version 0.25.0 --- MANIFEST.sha256 | 7 ++- docs/plans/cpk-064-publish-managed-updates.md | 59 +++++++++++++++++++ docs/roadmap.md | 1 + kit.py | 2 +- tests/test_kit.py | 2 +- 5 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 docs/plans/cpk-064-publish-managed-updates.md diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 0bb8a45..911a8a3 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -132,12 +132,13 @@ a5d8013da3d22afba28c1259e29e3255d1d19969d0934b2f59069f0de80ea31b ./docs/plans/c ccc9e20f5cd288592bde3dc81fd49820e9f7d62ef2274a99af8ac9781d5d3f5c ./docs/plans/cpk-061-component-updates-agent-cleanup.md 487d05e84cc1c1849f7dff4fc08eac2adad08af9dab8091b60bac48c8ad085c4 ./docs/plans/cpk-062-managed-update-application-12ui.md 471a4a4af80c1f234983f629dc007708f94fde7868952f15004be57a5176eb82 ./docs/plans/cpk-063-12ui-codex-compatibility.md -d0d2e85b0b519df2fc189c060bbad9e9e64630c47037d890cb1e6b5b4830c393 ./docs/roadmap.md +92f55f0ad064584f3aba2b4231d8172a318999a314923028281063887157f8cf ./docs/plans/cpk-064-publish-managed-updates.md +4009ec9c82628acaff788261ed0206cdc7051b37daf63f8eb2dd41ac8f6f77fd ./docs/roadmap.md 6f8f14ca5fd8ad935eee66256b3f7c49d5c40957eedff938ba21365e30869719 ./doctor.ps1 ab56a88d9188979f06ea142d1f58ea0d6ad9088e83a965bd324209d83da513ad ./doctor.sh 8087bdb3ee654c0baac278a8e3bf85e33e314f8acfd486e7ebf675126a697058 ./install.ps1 3466d2e34145cfdaa83d793960794b2aefc96f3d9c48d22fd2af930ec819e9c4 ./install.sh -4ed95b2f645a3d81aa0b27eeb6daba9678498304dbe90756275e62b471a47055 ./kit.py +6b7f2ffe9cb74301a80d1ae2d78e46c5ff1a7725844ca40fe0c0a199cf72d8ef ./kit.py 331f17ca1486b5779d9b57a3a73834328eb3089ce612e636e6a8e5085899d61e ./run-tests.ps1 e6df9f1da02280a66161583529b06a34f0103041e9587afad0fac6074eecc15b ./run-tests.sh 85a2ba72e2b0cd1ab6c25a51f39059502415c3ac62a6bffa613d7e31b6422cd4 ./setup-repo.ps1 @@ -148,7 +149,7 @@ e1e231aac51822f7e1bb3aa61aba138cf8c5df9cefd3079b0c1b5a98eea03bd9 ./tests/fixtur a269775204ae7cad2457d00f5565c90da2df41a9719479f3489ec01afdd2519c ./tests/fixtures/cpk056-agent-cases.json e32941eb74be1f2cdcb6c82cf71e113b841651bf0d01786f364176de2da1b9c5 ./tests/fixtures/cpk056-agent-grading.json 289367b7090a0d71936b41f08c4a354bc2758013dceb709819de36a1b73d9aa6 ./tests/live_plan_history.py -fe686dbf58fdd350c8dd9b5a72b7bee50573d5c51d47c1f56706ba0f41b8441b ./tests/test_kit.py +a9eb553c7dec86316708fac170e4488c7173822db790f9c217bc0d745c1dec98 ./tests/test_kit.py dbeb390655fbe5321578256db17b6a7c9edc1a4a7e7499ff5fe524dccbfc1719 ./tests/test_ponytail.py 0657ab41fb029202f2c30262768d1b6142c45f5214a96589705cc7c3c6ae73ff ./tests/test_updates.py 54673ace20ec6333c73ead405c82d52560848bc8b9aa3a0102fc39935cc3ab87 ./tests/update_subprocess_fixture.py diff --git a/docs/plans/cpk-064-publish-managed-updates.md b/docs/plans/cpk-064-publish-managed-updates.md new file mode 100644 index 0000000..adda8b6 --- /dev/null +++ b/docs/plans/cpk-064-publish-managed-updates.md @@ -0,0 +1,59 @@ +# Set the managed-update publication version + +This ExecPlan is a living specification until review closure. Follow `.agent/PLANS.md`. + +## Purpose / Big Picture + +Users must be able to identify the combined managed-update feature and 12UI compatibility correction as toolkit version `0.25.0`. Publication carries CPK-061, CPK-062, and CPK-063 together without changing their final behavior. + +## Context and Orientation + +`kit.py::KIT_VERSION` owns the toolkit identity. `IntegrationTests.test_lifecycle_skills_and_version` checks that identity. `MANIFEST.sha256` records distribution file checksums. The managed update commands and retained website-only routing are documented in README and the completed CPK-061 through CPK-063 specifications. + +The metadata task uses branch `cpk-064-publish-managed-updates` from `cpk-063-12ui-codex-compatibility` at `4a5a657a9f01ce11de33ed9759db05450164aefe`. One writer uses the primary checkout. Local main and origin/main match at `00f3af7e9e91fdb7ddd31c529863c616522cdd82`. No applicable Plan Mode record or untracked Plan handoff applies to this publication. + +## Product Boundary + +The source is the user's explicit `publish` request. Follow [Scope boundaries](../../assets/skills/design-preflight/references/scope-boundaries.md), [Versioning](../../assets/skills/publication/references/versioning.md), and [Publication](../../assets/skills/publication/references/publication.md). + +Version identity, its existing check, and distribution checksums compose this metadata task. Completed runtime behavior is opaque and retains its accepted result. Publication uses the existing protected-main pull-request workflow. No owner is promoted from deferred behavior. The model is one personal user and one writer with normal local files and Git. + +The ceiling excludes new runtime behavior, dependency changes, unrelated fixes, tag creation, a GitHub release, and branch or worktree cleanup. Design Preflight uses its small-change exception: one proven version owner and one direct existing check. No behavioral boundary or non-trivial runtime path changes. + +## Decision Log + +- Decision: use source version `0.25.0`. + Rationale: committed version `0.24.1` precedes the new explicit update check and apply commands. This publication includes a feature, not only the later compatibility fix. +- Decision: keep the completed CPK-061 through CPK-063 commits and specifications unchanged. + Rationale: version preparation is a new metadata task. It does not rewrite completed implementation decisions. +- Decision: publish through a draft pull request and squash integration. + Rationale: main requires a pull request, `toolkit-tests`, and resolved conversations. The existing CI workflow starts on readiness and main pushes, not task-branch pushes or draft creation. + +## Plan of Work + +One atomic metadata subtask, CP1, changes `kit.py::KIT_VERSION` and its existing assertion to `0.25.0`. Add this specification and its roadmap entry, then update distribution checksums. Existing README and installation guidance contain no current toolkit version literal that needs replacement. + +Validate and review the metadata task from its base. The previous task commits retain their recorded local review gates. Follow the linked publication procedure for the combined pull request, hosted review, required CI, squash merge, and local main integration. + +## Concrete Steps + +Run from `/home/mbeutler/Projects/codex-practical-kit`: + + PYTHONPATH=tests python3 -m unittest test_kit.IntegrationTests.test_lifecycle_skills_and_version -v + ./run-tests.sh + git diff --check + sha256sum --check MANIFEST.sha256 + +After local review and its closure, run `./install.sh` and `./doctor.sh` from the frozen candidate before pushing the task branch. + +## Validation and Acceptance + +Require the existing version check and required repository checks to pass. Doctor must report toolkit version `0.25.0` and `Result: ready`. The hosted review must cover the current pull-request head. Publication finishes with local main equal to origin/main and containing the recorded squash result. + +## Idempotence and Dependencies + +Version preparation uses the existing constant and test. It adds no dependency or interface. Preserve unrelated untracked files. Use the existing Git and GitHub operations, and report an operation failure without inventing recovery. + +## Outcomes & Retrospective + +The source and selected installation identify the managed-update feature and compatible 12UI ownership model as version `0.25.0`. The toolkit retains the user's applied component pins and separately installed 12UI state. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b27175..ffe411c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,6 +33,7 @@ No residual risk is accepted. ## Completed +- ✅ **CPK-064 — Set the managed-update publication version.** Identify the combined update commands and 12UI compatibility correction as version `0.25.0`. [ExecPlan](plans/cpk-064-publish-managed-updates.md) - ✅ **CPK-063 — Stop reinstalling incompatible 12UI hooks.** Remove the Claude Code Git package from managed installation, updates, and Doctor. Preserve separately installed 12UI and the website-only routing rule. [ExecPlan](plans/cpk-063-12ui-codex-compatibility.md) - ✅ **CPK-062 — Apply managed updates and add website-only 12UI.** Add explicit update application through thin Bash and PowerShell launchers, reconcile the source lock with the selected installation, install or refresh the official 12UI plugin, and route 12UI only for website projects. [ExecPlan](plans/cpk-062-managed-update-application-12ui.md) - ✅ **CPK-061 — Report managed component updates and clean up finished agent threads.** Add a read-only update check for RepoWise and every other managed component. Teach coordinators to preserve required follow-ups, close terminal delegated threads, and archive finished history with native tools. [ExecPlan](plans/cpk-061-component-updates-agent-cleanup.md) diff --git a/kit.py b/kit.py index 33781a0..ce65f66 100755 --- a/kit.py +++ b/kit.py @@ -25,7 +25,7 @@ from typing import Any, Iterable KIT_ID = "codex-practical-kit" -KIT_VERSION = "0.24.1" +KIT_VERSION = "0.25.0" ROOT = Path(__file__).resolve().parent AGENTS_START = "" AGENTS_END = "" diff --git a/tests/test_kit.py b/tests/test_kit.py index 945c4b9..f462a2b 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -3760,7 +3760,7 @@ def test_lifecycle_skills_and_version(self): self.assertIn("`pwsh -File .\\doctor.ps1`", publication) self.assertIn("from the reviewed candidate", publication) self.assertIn("`Result: ready`", publication) - self.assertEqual(kit.KIT_VERSION, "0.24.1") + self.assertEqual(kit.KIT_VERSION, "0.25.0") self.assertNotIn("Version `0.22.0`", (ROOT / "README.md").read_text()) self.assertNotIn("version 0.22.0", (ROOT / "CODEX-INSTALL-PROMPT.md").read_text()) From 7d8e4fa2b7fb211dfcb14627cc3a30ca3956452a Mon Sep 17 00:00:00 2001 From: Michael Beutler Date: Mon, 5 Oct 2026 16:38:43 -0400 Subject: [PATCH 5/6] Restore requested native archival of finished agent threads --- MANIFEST.sha256 | 7 +- .../references/coordination.md | 5 +- docs/plans/cpk-065-restore-thread-archival.md | 75 +++++++++++++++++++ docs/roadmap.md | 1 + tests/test_kit.py | 16 ++-- 5 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 docs/plans/cpk-065-restore-thread-archival.md diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 911a8a3..4ea6e80 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -40,7 +40,7 @@ a564a3b00a86f43d38318b76168593bc965b2f1decf67a328d499a054aa35f36 ./assets/skill baa988b652f856bce1a4e183bf327879a021896eb65617b2fd4903e34a461339 ./assets/skills/defect-diagnostic/agents/openai.yaml f98f3661b7b4cfbe6a1b061e075898ae21b65cbb6765abec76b332b03cab2f9b ./assets/skills/defect-diagnostic/references/diagnostic-summary.md 20fb4b743b3ed14fa58ae1710bf5c621eb1f0b20dad552212221594368fa2d4c ./assets/skills/delivery-lifecycle/SKILL.md -a149cc13be4525d74bab07edca41b3d84770c9aa924c3ae037a5c808c248dfb4 ./assets/skills/delivery-lifecycle/references/coordination.md +2995e2472f94e403ab399be52c0497dd591346bb4edcf86b055768551e33fefc ./assets/skills/delivery-lifecycle/references/coordination.md b24f6d4c5d6ba9d3a4ab4215eac302764e8dcb0fe2c6dc76789b3ffe77a8e21f ./assets/skills/delivery-lifecycle/references/decision-handoffs.md d9d882f331eaed467f783b899073d992b9f9872927f76ce8c7a2069295a8a080 ./assets/skills/delivery-lifecycle/references/delivery-lifecycle.md 2536a374b63658e5e0bbca17d1a9ed25a41b61eeb539702cc8ee7c6f41bf1a31 ./assets/skills/delivery-lifecycle/references/git-isolation.md @@ -133,7 +133,8 @@ ccc9e20f5cd288592bde3dc81fd49820e9f7d62ef2274a99af8ac9781d5d3f5c ./docs/plans/c 487d05e84cc1c1849f7dff4fc08eac2adad08af9dab8091b60bac48c8ad085c4 ./docs/plans/cpk-062-managed-update-application-12ui.md 471a4a4af80c1f234983f629dc007708f94fde7868952f15004be57a5176eb82 ./docs/plans/cpk-063-12ui-codex-compatibility.md 92f55f0ad064584f3aba2b4231d8172a318999a314923028281063887157f8cf ./docs/plans/cpk-064-publish-managed-updates.md -4009ec9c82628acaff788261ed0206cdc7051b37daf63f8eb2dd41ac8f6f77fd ./docs/roadmap.md +1c2e4ea4987be8065f417c1bd8296dd6db50cb2e63760dd1f6a952573f18e6b4 ./docs/plans/cpk-065-restore-thread-archival.md +9d7541b95d56e2e3b14f2351721b28cfea3bb9ec0cc81b4a0e3322faf50e5558 ./docs/roadmap.md 6f8f14ca5fd8ad935eee66256b3f7c49d5c40957eedff938ba21365e30869719 ./doctor.ps1 ab56a88d9188979f06ea142d1f58ea0d6ad9088e83a965bd324209d83da513ad ./doctor.sh 8087bdb3ee654c0baac278a8e3bf85e33e314f8acfd486e7ebf675126a697058 ./install.ps1 @@ -149,7 +150,7 @@ e1e231aac51822f7e1bb3aa61aba138cf8c5df9cefd3079b0c1b5a98eea03bd9 ./tests/fixtur a269775204ae7cad2457d00f5565c90da2df41a9719479f3489ec01afdd2519c ./tests/fixtures/cpk056-agent-cases.json e32941eb74be1f2cdcb6c82cf71e113b841651bf0d01786f364176de2da1b9c5 ./tests/fixtures/cpk056-agent-grading.json 289367b7090a0d71936b41f08c4a354bc2758013dceb709819de36a1b73d9aa6 ./tests/live_plan_history.py -a9eb553c7dec86316708fac170e4488c7173822db790f9c217bc0d745c1dec98 ./tests/test_kit.py +2424e4d7cbf073c2b7da64f4e455b375ebd4f2c99be1df9a37aedd07e8e8676d ./tests/test_kit.py dbeb390655fbe5321578256db17b6a7c9edc1a4a7e7499ff5fe524dccbfc1719 ./tests/test_ponytail.py 0657ab41fb029202f2c30262768d1b6142c45f5214a96589705cc7c3c6ae73ff ./tests/test_updates.py 54673ace20ec6333c73ead405c82d52560848bc8b9aa3a0102fc39935cc3ab87 ./tests/update_subprocess_fixture.py diff --git a/assets/skills/delivery-lifecycle/references/coordination.md b/assets/skills/delivery-lifecycle/references/coordination.md index a98f666..70a1b2d 100644 --- a/assets/skills/delivery-lifecycle/references/coordination.md +++ b/assets/skills/delivery-lifecycle/references/coordination.md @@ -3,7 +3,6 @@ - @@ -22,9 +21,9 @@ While an implementation subagent writes, the coordinator performs read-only work A stopped turn does not finish an assignment when a required follow-up remains. Keep the same agent available until the complete assignment reports `complete` or `blocked`. -Capture each terminal delegated result. Then call the exposed native `close_agent` operation for that exact agent. Close finished agent threads before spending another agent slot. Close only threads whose results you already captured. +Capture each terminal delegated result. Then use exposed native thread archival to archive that exact finished agent thread. Always specify the finished thread identifier. -Archive only an explicitly targeted finished agent subtree. Closure releases open-agent capacity. Archival cleans persisted thread history and does not prove that closure occurred. If the platform exposes native thread archival, specify the finished thread identifier after closure. Do not use a default target that can select the coordinator. Archiving a parent can also archive its descendants, so wait until every descendant has finished and owes no required follow-up. Do not delete session files or edit Codex state directly. +Archive only an explicitly targeted finished agent subtree. Archival cleans persisted thread history and does not prove that agent capacity was released. Do not use a default target that can select the coordinator. Archiving a parent can also archive its descendants, so wait until every descendant has finished and owes no required follow-up. Do not delete session files or edit Codex state directly. Elapsed time alone cannot authorize a duplicate or restart of an active delegated task. Keep and poll the same task until it completes, reports blocked, or the platform reports that it stopped without coordinator action. diff --git a/docs/plans/cpk-065-restore-thread-archival.md b/docs/plans/cpk-065-restore-thread-archival.md new file mode 100644 index 0000000..153df3b --- /dev/null +++ b/docs/plans/cpk-065-restore-thread-archival.md @@ -0,0 +1,75 @@ +# Restore finished-thread archival + +This ExecPlan follows [`.agent/PLANS.md`](../../.agent/PLANS.md). + +## Purpose / Big Picture + +Coordinators must archive finished delegated threads as the user requested. They must not require an unrelated agent-close operation before archival. + +The user clarified the original request on October 5, 2026: “I specically said to archive threads, not close_agent in the original request.” This clarification supplies the accepted result. It rejects the closure requirement introduced in CPK-061. + +## Context and Orientation + +`assets/skills/delivery-lifecycle/references/coordination.md` owns the live coordination rule. `tests/test_kit.py` contains three direct checks for terminal cleanup, archive targeting, and required follow-ups. `MANIFEST.sha256` records package contents. + +Native archival changes persisted thread history. The exposed tool accepts an explicit thread identifier and can archive descendants. Toolkit code does not implement that operation or control agent capacity. + +## Decision Log + +- Decision: Restore archival in the existing rule and delete the invented closure prerequisite. + Rationale: The user explicitly rejected the substitution. There is no evidence that closure was necessary to fulfill the request. +- Decision: Preserve finished-target and required-follow-up safeguards. + Rationale: Archival can affect descendants. A returned turn does not prove that a complete assignment finished. +- Decision: Keep completed CPK-061 records unchanged and supersede their closure decision here. + Rationale: Historical evidence must not hide the incorrect substitution. This correction replaces only CPK-061-B, B5, and S7 closure requirements. Component-update behavior and all other boundaries remain unchanged. +- Decision: Use the strict-simplification challenger exemption in [Design Preflight](../../assets/skills/design-preflight/SKILL.md). + Rationale: The correction deletes an invented prerequisite and uses existing native archival. It adds no interface, persistent state, dependency, fallback, or supported behavior. +- Decision: Keep the existing publication branch `cpk-064-publish-managed-updates` from corrective base `635cebb34e37859567c886aac638384f51a33f0f` in the primary checkout. + Rationale: This is one corrective write stream within the authorized draft publication. No Plan Mode record applies to this correction. + +## Product Boundary and Supported Model + +Apply [Scope boundaries](../../assets/skills/design-preflight/references/scope-boundaries.md) and [Supported model](../../assets/skills/design-preflight/references/supported-model.md). The user clarification is the authoritative product source. + +The canonical coordination rule and its direct tests compose this result. Existing delegation rules and the two-phase challenger requirement remain opaque and unchanged. Native archival implementation, thread identifiers, and platform capacity also remain opaque. + +Normal use has one personal user, one writer, captured terminal results, and an exposed native archive tool. The rule must preserve unfinished assignments and descendants. Do not add closure, capacity-release claims, fallback tools, thread-limit handling, bulk archival, direct session deletion, or Codex state-file edits. No owner is promoted from deferred behavior. + +## Boundary Inventory and Checks + +Apply [Scenario discrimination](../../assets/skills/design-preflight/references/scenario-discrimination.md). The clauses below exhaust the clarification and the composing cleanup rule. These are instruction boundaries, not executable production-code entry points. Native archival semantics remain opaque. Trace closure does not apply. + +| Boundary | Source and discriminator | Contrast and required oracle | Instruction path and check | +| --- | --- | --- | --- | +| B1 | User clarification and terminal-result clause: capture, then archive | Archive a captured finished assignment, not a close-only substitute. The rule orders capture before native archival and contains no close operation or capacity prerequisite. | Canonical coordination owner. `test_coordination_cleans_terminal_agent_threads` | +| B2 | Existing archive-target clause: exact finished subtree | Explicit finished identifier, not a coordinator default. The rule waits for every descendant to finish with no required follow-up and forbids direct session or state edits. | Canonical coordination owner. `test_coordination_archive_targets_preserve_active_work` and B1 check | +| B3 | Existing assignment-completion clause: whole assignment | A Phase 1 return with Phase 2 owed, not full completion. The same agent remains available for required follow-ups. The challenger requirement remains unchanged. | Coordination and Design Preflight owners. `test_coordination_cleanup_preserves_required_followups` | + +All three boundaries are mapped. The rest of coordination is unchanged and opaque. The clarification's explanation of why the user accepted the discrepancy is non-boundary context. Agent-capacity changes are outside scope. + +## Plan of Work + +CPK-065-A is one atomic instruction correction. Edit the canonical cleanup paragraph and remove its closure guard. Order result capture before native archival with an explicit finished identifier. Preserve descendant protection and the existing required-follow-up rule. Update the first direct test to reject the substituted close operation and capacity claim. Keep the other two tests unchanged. + +The allowed edits are the canonical owner, its direct test, this specification, the roadmap, and matching manifest entries. The full review and local commit boundary is this complete corrective task from its recorded base. Existing completed runtime work retains its prior review evidence. + +## Concrete Steps and Acceptance + +Run from `/home/mbeutler/Projects/codex-practical-kit`: + + PYTHONPATH=tests python3 -m unittest test_kit.IntegrationTests.test_coordination_cleans_terminal_agent_threads test_kit.IntegrationTests.test_coordination_archive_targets_preserve_active_work test_kit.IntegrationTests.test_coordination_cleanup_preserves_required_followups -v + ./run-tests.sh + sha256sum --check MANIFEST.sha256 + git diff --check + +The first direct test must reject the old closure rule. All three checks must pass with the corrected rule. The complete suite must pass, apart from its existing platform-specific skip. Update only changed manifest records and add this specification. Finalize documentation before the native review. Keep detailed results in ignored `.agent/test-results/CPK-065.md`. + +## Idempotence and Recovery + +Source edits are ordinary Git changes. Preserve unrelated untracked files and completed history. Do not archive live threads as a test. Stop and report a failed operation rather than inventing recovery. + +## Interfaces and Dependencies + +No runtime interface or dependency changes. The existing native archival tool remains responsible for thread history. + +Plan note: This correction restores the original archive request and explicitly supersedes only the mistaken closure requirement. diff --git a/docs/roadmap.md b/docs/roadmap.md index ffe411c..c4f652e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,6 +33,7 @@ No residual risk is accepted. ## Completed +- ✅ **CPK-065 — Restore the requested finished-thread archival.** Capture terminal delegated results and archive explicitly targeted finished threads with native tools. Remove the substituted closure prerequisite and preserve active work and required follow-ups. [ExecPlan](plans/cpk-065-restore-thread-archival.md) - ✅ **CPK-064 — Set the managed-update publication version.** Identify the combined update commands and 12UI compatibility correction as version `0.25.0`. [ExecPlan](plans/cpk-064-publish-managed-updates.md) - ✅ **CPK-063 — Stop reinstalling incompatible 12UI hooks.** Remove the Claude Code Git package from managed installation, updates, and Doctor. Preserve separately installed 12UI and the website-only routing rule. [ExecPlan](plans/cpk-063-12ui-codex-compatibility.md) - ✅ **CPK-062 — Apply managed updates and add website-only 12UI.** Add explicit update application through thin Bash and PowerShell launchers, reconcile the source lock with the selected installation, install or refresh the official 12UI plugin, and route 12UI only for website projects. [ExecPlan](plans/cpk-062-managed-update-application-12ui.md) diff --git a/tests/test_kit.py b/tests/test_kit.py index f462a2b..b882285 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -3160,15 +3160,13 @@ def test_coordination_cleans_terminal_agent_threads(self): ).read_text() capture = coordination.index("Capture each terminal delegated result.") - close = coordination.index("`close_agent`") - next_slot = coordination.index("Close finished agent threads before spending another agent slot.", close) - archive = coordination.index("Archive only an explicitly targeted finished agent subtree.", next_slot) - self.assertLess(capture, close) - self.assertLess(close, next_slot) - self.assertLess(next_slot, archive) - self.assertIn("for that exact agent", coordination) - self.assertIn("Closure releases open-agent capacity", coordination) - self.assertIn("does not prove that closure occurred", coordination) + archive = coordination.index("Then use exposed native thread archival", capture) + self.assertLess(capture, archive) + self.assertIn("archive that exact finished agent thread", coordination) + self.assertIn("specify the finished thread identifier", coordination.lower()) + self.assertNotIn("close_agent", coordination) + self.assertNotIn("Close finished agent threads before spending another agent slot", coordination) + self.assertIn("does not prove that agent capacity was released", coordination) self.assertIn("Do not delete session files or edit Codex state directly", coordination) def test_coordination_archive_targets_preserve_active_work(self): From 15215e2eb8865dcbd80cdbbb894f8e8a47b771a2 Mon Sep 17 00:00:00 2001 From: Michael Beutler Date: Mon, 5 Oct 2026 16:56:27 -0400 Subject: [PATCH 6/6] Match exact RepoWise versions and report managed plugins accurately --- MANIFEST.sha256 | 6 +++--- docs/roadmap.md | 1 + kit.py | 6 +++--- tests/test_updates.py | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 4ea6e80..9112af4 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -134,12 +134,12 @@ ccc9e20f5cd288592bde3dc81fd49820e9f7d62ef2274a99af8ac9781d5d3f5c ./docs/plans/c 471a4a4af80c1f234983f629dc007708f94fde7868952f15004be57a5176eb82 ./docs/plans/cpk-063-12ui-codex-compatibility.md 92f55f0ad064584f3aba2b4231d8172a318999a314923028281063887157f8cf ./docs/plans/cpk-064-publish-managed-updates.md 1c2e4ea4987be8065f417c1bd8296dd6db50cb2e63760dd1f6a952573f18e6b4 ./docs/plans/cpk-065-restore-thread-archival.md -9d7541b95d56e2e3b14f2351721b28cfea3bb9ec0cc81b4a0e3322faf50e5558 ./docs/roadmap.md +5907afa0a540861224ec35d1742854ad42af8985598930d53a7affc4b5774f15 ./docs/roadmap.md 6f8f14ca5fd8ad935eee66256b3f7c49d5c40957eedff938ba21365e30869719 ./doctor.ps1 ab56a88d9188979f06ea142d1f58ea0d6ad9088e83a965bd324209d83da513ad ./doctor.sh 8087bdb3ee654c0baac278a8e3bf85e33e314f8acfd486e7ebf675126a697058 ./install.ps1 3466d2e34145cfdaa83d793960794b2aefc96f3d9c48d22fd2af930ec819e9c4 ./install.sh -6b7f2ffe9cb74301a80d1ae2d78e46c5ff1a7725844ca40fe0c0a199cf72d8ef ./kit.py +fd117b24c3810c2078a610c9a97034d711b0bf2551e7ff832b5fce62f51c94c2 ./kit.py 331f17ca1486b5779d9b57a3a73834328eb3089ce612e636e6a8e5085899d61e ./run-tests.ps1 e6df9f1da02280a66161583529b06a34f0103041e9587afad0fac6074eecc15b ./run-tests.sh 85a2ba72e2b0cd1ab6c25a51f39059502415c3ac62a6bffa613d7e31b6422cd4 ./setup-repo.ps1 @@ -152,7 +152,7 @@ e32941eb74be1f2cdcb6c82cf71e113b841651bf0d01786f364176de2da1b9c5 ./tests/fixtur 289367b7090a0d71936b41f08c4a354bc2758013dceb709819de36a1b73d9aa6 ./tests/live_plan_history.py 2424e4d7cbf073c2b7da64f4e455b375ebd4f2c99be1df9a37aedd07e8e8676d ./tests/test_kit.py dbeb390655fbe5321578256db17b6a7c9edc1a4a7e7499ff5fe524dccbfc1719 ./tests/test_ponytail.py -0657ab41fb029202f2c30262768d1b6142c45f5214a96589705cc7c3c6ae73ff ./tests/test_updates.py +8fb5aefac7fa8a9cef45296f2dacbcc0663bed19a0daee5d94f71cebce15b012 ./tests/test_updates.py 54673ace20ec6333c73ead405c82d52560848bc8b9aa3a0102fc39935cc3ab87 ./tests/update_subprocess_fixture.py d16f7eadc8c668e82a1d0d2554ed04def15cb0a4b2ce13ac1ce1339bc0e62161 ./uninstall.ps1 f12cff94ef7f9b0ca489cbe43e1de737011476966e04c1578f4977177b0da217 ./uninstall.sh diff --git a/docs/roadmap.md b/docs/roadmap.md index c4f652e..22640f6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,6 +33,7 @@ No residual risk is accepted. ## Completed +- ✅ **CPK-066 — Correct runtime identity and install output.** Compare the complete RepoWise version token during installation and Doctor checks. Report only the managed Ponytail plugin in successful install output. - ✅ **CPK-065 — Restore the requested finished-thread archival.** Capture terminal delegated results and archive explicitly targeted finished threads with native tools. Remove the substituted closure prerequisite and preserve active work and required follow-ups. [ExecPlan](plans/cpk-065-restore-thread-archival.md) - ✅ **CPK-064 — Set the managed-update publication version.** Identify the combined update commands and 12UI compatibility correction as version `0.25.0`. [ExecPlan](plans/cpk-064-publish-managed-updates.md) - ✅ **CPK-063 — Stop reinstalling incompatible 12UI hooks.** Remove the Claude Code Git package from managed installation, updates, and Doctor. Preserve separately installed 12UI and the website-only routing rule. [ExecPlan](plans/cpk-063-12ui-codex-compatibility.md) diff --git a/kit.py b/kit.py index ce65f66..7c9265f 100755 --- a/kit.py +++ b/kit.py @@ -1007,7 +1007,7 @@ def ensure_repowise( result = None detail = ((result.stdout or result.stderr).strip() if result else "") version = detail.splitlines()[0] if detail else None - if result and result.returncode == 0 and version and version_expected in version: + if result and result.returncode == 0 and version and version.split()[-1] == version_expected: return repowise manifest = json_load(manifest_path(paths), {}) recorded = manifest.get("repowise") if isinstance(manifest, dict) else None @@ -1391,7 +1391,7 @@ def doctor(args: argparse.Namespace, paths: InstallPaths) -> int: repowise_version = load_lock()["runtime_tools"]["repowise"]["version"] version = command_version([repowise, "--version"]) ok &= check( - bool(version and repowise_version in version), + bool(version and version.split()[-1] == repowise_version), f"RepoWise {repowise_version}", version or "unavailable", ) @@ -1541,7 +1541,7 @@ def main(argv: list[str] | None = None) -> int: print(f"Global instructions: {paths.codex_home / 'AGENTS.md'}") print(f"Global ExecPlan rules: {plans_path(paths)}") print(f"Hooks: {paths.codex_home / 'config.toml'}") - print("Ponytail and 12UI: native Codex plugins; existing versions and settings preserved.") + print("Ponytail: native Codex plugin; existing versions and settings preserved.") print("Open a new Codex session and use `/hooks` to review and trust toolkit and plugin hooks.") if args.repo: repo_args = argparse.Namespace(repo=args.repo, prose=args.repowise_prose) diff --git a/tests/test_updates.py b/tests/test_updates.py index 07e34dd..6fd5830 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -417,6 +417,7 @@ def setUp(self): "installed": [self.plugin("ponytail", enabled=False, user="keep")], } self.fail_upgrade = None + self.repowise_version = "9.9.9" self.skill_payloads = { "neuroarxiv": { "skills/neuroarxiv/SKILL.md": b"# neuroarxiv selected\n", @@ -527,9 +528,10 @@ def fake_run(self, command, **kwargs): target.mkdir(parents=True, exist_ok=True) suffix = ".exe" if executable.endswith(".exe") else "" (target / f"repowise{suffix}").write_text("fixture") + self.repowise_version = command[-1].split("==", 1)[1] return subprocess.CompletedProcess(command, 0, "", "") if executable in {"repowise", "repowise.exe"}: - return subprocess.CompletedProcess(command, 0, "RepoWise 9.9.9\n", "") + return subprocess.CompletedProcess(command, 0, f"RepoWise {self.repowise_version}\n", "") if executable != "codex": self.fail(f"Unexpected process: {command}") if command[1:] == ["--version"]: @@ -707,6 +709,33 @@ def test_apply_main_reconciles_complete_installation_and_plugins(self): self.assertEqual(self.plugin_state["marketplaces"][0], {"name": "unrelated", "root": "keep"}) self.assertEqual(self.cli("doctor")[0], 0) + def test_apply_matches_complete_repowise_version(self): + self.assertEqual(self.cli("apply-updates")[0], 0) + for action in ("install", "apply-updates"): + for version in ("9.9.9", "19.9.9", "9.9.9.post1"): + with self.subTest(action=action, reported_version=version): + self.repowise_version = version + self.assertEqual(self.cli("doctor")[0], 0 if version == "9.9.9" else 1) + before = len(self.process_calls) + status, stdout, stderr = self.cli(action) + self.assertEqual((status, stderr), (0, "")) + self.assertIn("Core kit installed." if action == "install" else "Managed component updates applied.", stdout) + installs = [ + command for command, _home in self.process_calls[before:] + if command[1:3] == ["tool", "install"] + ] + self.assertEqual(len(installs), 0 if version == "9.9.9" else 1) + if installs: + self.assertEqual(installs[0][-1], "repowise==9.9.9") + self.assertEqual(self.repowise_version, "9.9.9") + selected = json.loads((self.source / "upstream.lock.json").read_text()) + self.assertEqual(selected["runtime_tools"]["repowise"]["version"], "9.9.9") + self.assertEqual( + (self.source / "upstream.lock.json").read_bytes(), + (self.paths.install_root / "upstream.lock.json").read_bytes(), + ) + self.assertEqual(self.cli("doctor")[0], 0) + def test_12ui_is_unmanaged_across_install_apply_and_doctor(self): candidate, _lines, _payloads = kit.managed_candidates(include_payloads=True) kit.write_file( @@ -743,6 +772,9 @@ def test_12ui_is_unmanaged_across_install_apply_and_doctor(self): self.assertEqual((status, stderr), (0, ""), command) if command == "check-updates": self.assertNotIn("12ui", stdout.lower()) + elif command == "install": + self.assertIn("Ponytail: native Codex plugin", stdout) + self.assertNotIn("12UI", stdout) elif command == "doctor": self.assertIn("Result: ready", stdout) self.assertNotIn("12UI", stdout)