diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index e2a4520..bbd1811 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -51,7 +51,7 @@ jobs: echo "- Release: \`$RELEASE_TAG\`" echo "- Result: \`$SMOKE_OUTCOME\`" echo "- Runner architecture: \`$(uname -m)\`" - echo "- Proved: published bootstrap, manifest, checksum, candidate version and Codex compatibility self-test; dry-run collision refusal; exact 2.2.1 reset fingerprint, --reset gate, filesystem reset, and teardown; title-core readiness without the updater; an ordinary stateless title-policy helper with zero App Server or title-state access; installed mounted-response decoder presence plus simulated raw JSON-string read/write results and one-attempt failure; complete multi-page onboarding preview with deduplication and failure-before-writes; uncapped complete-snapshot action preparation with zero per-target production RPCs, followed by simulated serial mounted read-before-set revalidation, drift and wrong-task-ID skipping, one-attempt writes, exact accounting, and no retry; fixed-path Codex resolution, no hooks.json mutation, and no SQLite dependency; real-launchd automatic and direct current updates with restart reporting; an update/uninstall overlap; and complete uninstall JSON with binary-last teardown and unrelated-content preservation." + echo "- Proved: published bootstrap, manifest, checksum, candidate version and Codex compatibility self-test; dry-run collision refusal; exact 2.2.1 reset fingerprint, --reset gate, filesystem reset, and teardown; title-core readiness without the updater; an ordinary stateless title-policy helper with zero App Server or title-state access; installed mounted-response decoder presence plus simulated raw JSON-string read/write results and one-attempt failure; complete multi-page uninstall preview with deduplication and failure-before-writes; one fresh complete-snapshot cleanup preparation with the initiating task last and zero per-target production RPCs, followed by simulated serial mounted read-before-set revalidation, drift and unconfirmed-result teardown blocking, one-attempt writes, exact accounting, no retry, and no final scan; fixed-path Codex resolution, no hooks.json mutation, and no SQLite dependency; real-launchd automatic and direct current updates with restart reporting; an update/uninstall overlap; and complete uninstall JSON with cleanup before binary-last teardown and unrelated-content preservation." echo "- Not proved: real Codex auth, native automation deletion or exact-task unpin, rendered Desktop repaint, clean-restart persistence, native timeout behavior, a newer-version partial update, or architectures other than this runner. Those seams require the recorded local Desktop and fault-injection canaries." echo "- Deployment timing: a Pages/CDN lag can make the live bootstrap older than the release commit; that red result still requires operator investigation." echo "- A red result marks the published release for operator action; this workflow does not delete, demote, or retry a release." diff --git a/CHANGELOG.md b/CHANGELOG.md index 436ee7c..a820c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Fixed + +- Restored uninstall title cleanup by reusing the complete catalog planner and serial exact mounted title writer before managed artifacts are removed. + +### Removed + +- Removed the non-semantic neutral bear title state and its historical onboarding command, install flag, prompts, and guidance. + ## v3.0.1 - 2026-08-08 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index e44f519..6d02524 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,6 @@ ThreadBear is a playful, token-conscious Codex title manager for macOS: one smal - Before title-path architecture or live experiments, run `python3 scripts/validate-experiments.py`, consult `docs/experiments/registry.json`, and satisfy the preflight in `docs/experiments/README.md`. Contradictory records remain conditional until one changed variable is isolated. Automation proves mechanical integrity; the active issue and pull-request review judge whether the unknown and changed variable are meaningful. - Private eval corpus: `ericlitman/threadbear-eval` (real user messages — must never enter this public tree). - Voice: playful, bear-themed, never at the expense of operational clarity. -- The only scheduler is the daily `sh.threadbear.update` LaunchAgent. It may run only the verified update command; it never reads tasks, invokes a model, onboards titles, or archives anything. Do not add another schedule, persistent ThreadBear task, pending-title queue, detached title writer, or background classifier. +- The only scheduler is the daily `sh.threadbear.update` LaunchAgent. It may run only the verified update command; it never reads tasks, invokes a model, changes titles, or archives anything. Do not add another schedule, persistent ThreadBear task, pending-title queue, detached title writer, or background classifier. - Changelog: every PR with user-visible changes must append a concise entry under `CHANGELOG.md`'s `Unreleased` section. Release preparation renames that section to `vN.N.N - YYYY-MM-DD` and adds a fresh `Unreleased` section; the release workflow rejects stable tags without the matching version section. - Shipping evidence: unit and fixture tests are necessary but never sufficient. Before calling a native lifecycle or release path shippable, exercise the reviewed candidate end to end against the real supported native control or official release service in an isolated, recoverable canary, record exact candidate/ref/results, and clean up the canary without visual inspection. diff --git a/INSTALL.md b/INSTALL.md index 82058aa..8186420 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -16,7 +16,7 @@ Open with this orientation: > > ThreadBear adds one useful status icon while keeping the rest of each safe task title intact. Codex reads and applies the title itself. > -> I'll check this Mac, show you exactly what will change, and ask before installing anything. Afterward, Codex needs one restart. Then you can say **ThreadBear onboard** in any task to update every safe existing local task—there is no 50-task cap or persistent ThreadBear task. +> I'll check this Mac, show you exactly what will change, and ask before installing anything. Installation leaves existing task titles alone. Afterward, Codex needs one restart. Codex collapses commentary after a turn finishes, so the final answer that asks for consent must repeat the orientation, readiness result, complete recommendation, and question. If a check fails, report it plainly and do not ask for install consent. @@ -54,7 +54,7 @@ if [ -x "$HOME/.local/bin/threadbear" ]; then fi ``` -ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop 0.146.0 or newer, and HTTPS access to the official guide and GitHub Releases. The check prints every fixed Codex Desktop command it finds; ThreadBear uses the first one that actually reports a compatible version. It needs no `sudo` or Full Disk Access. Ordinary title updates work with Codex's default workspace permissions. Historical onboarding asks once for permission to read the complete local task catalog. ThreadBear never opens Codex SQLite. +ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop 0.146.0 or newer, and HTTPS access to the official guide and GitHub Releases. The check prints every fixed Codex Desktop command it finds; ThreadBear uses the first one that actually reports a compatible version. It needs no `sudo` or Full Disk Access. Ordinary title updates work with Codex's default workspace permissions. Uninstall cleanup asks once for permission to read the complete local task catalog. ThreadBear never opens Codex SQLite. For an official release, run the verified bootstrap preview: @@ -79,7 +79,7 @@ Only after the checks and dry run succeed, present this complete card in the sam > ## Here's what will happen > > - ThreadBear adds one helpful status icon without rewriting your task's subject or emoji. -> - Existing tasks stay unchanged until you preview onboarding and approve it separately. +> - Existing task titles stay unchanged during installation. > - A small local helper, Codex instructions, and a ThreadBear skill are added. > - Once a day, ThreadBear checks for and installs only verified official releases. Updates never read tasks or change titles. > - Unclear or unsafe titles are left alone, and there is no persistent ThreadBear task. @@ -90,7 +90,7 @@ Only after the checks and dry run succeed, present this complete card in the sam For a 2.2.1 reset, add: “I'll remove only the verified old ThreadBear automation, unpin its former task without renaming it, and install the simpler version fresh. Old title history will not be guessed or imported, so some existing icons may remain.” -A clear yes to the unchanged recommendation is consent. Ask again only if the effect changes or the answer is ambiguous. If the user does not want historical onboarding, accept that preference and add `--no-onboard` to the confirmed install. +A clear yes to the unchanged recommendation is consent. Ask again only if the effect changes or the answer is ambiguous. ## 3. Install after consent @@ -111,7 +111,7 @@ For a local candidate, run: /path/to/threadbear install --noninteractive --confirm --json ``` -Add `--no-onboard` only when the user opted out. Add `--reset` only after the exact legacy cleanup is verified. Then run: +Add `--reset` only after the exact legacy cleanup is verified. Then run: ```sh ~/.local/bin/threadbear version --json @@ -121,64 +121,23 @@ Add `--no-onboard` only when the user opted out. Add `--reset` only after the ex Core `ready` is healthy when the installed binary, private lifecycle state, compatible Codex Desktop, managed guidance, and skill match the candidate. Report the daily updater separately; missing automatic updates do not make title handling globally unready. Core readiness does not depend on historical title counts. -No controller, worker, migration phase, persistent task, or hidden onboarding job should exist after installation. If installation fails after mutation starts, report `partial:true`, the failed stage, whether restart is required, and the one safe rerun action. `planned_changes` is a plan, not a claim that every item ran. +No controller, worker, migration phase, persistent task, or hidden historical-title job should exist after installation. If installation fails after mutation starts, report `partial:true`, the failed stage, whether restart is required, and the one safe rerun action. `planned_changes` is a plan, not a claim that every item ran. After the checks finish, end the final response with this plain-language receipt, filled with the real result: > ## ThreadBear recap 🐻 > > - ThreadBear is installed and automatic updates are [ready / need attention]. -> - Existing tasks have not been changed yet, and unrelated Codex settings stayed untouched. -> - Next: restart Codex, then open any task and say **ThreadBear onboard**. +> - Existing task titles and unrelated Codex settings stayed untouched. +> - Next: restart Codex so open tasks load the new instructions. -## 4. Restart and onboard +## 4. Restart -Say: “Installation is finished. One restart loads the new instructions; onboarding stays a separate previewed choice.” +Say: “Installation is finished. One restart loads the new instructions. Existing task titles were not changed.” After a successful install say: > ThreadBear is installed. Restart Codex so open tasks load the new managed guidance. -> -> After restart, open any task and say: **ThreadBear onboard** - -When that request arrives, read the installed skill and follow this protocol: - -1. Run `~/.local/bin/threadbear status --json`. Explain that Codex will ask once so ThreadBear can read the complete task list and that the preview changes nothing. Then run `~/.local/bin/threadbear onboard --dry-run --json` with `sandbox_permissions:"require_escalated"` and that plain-language justification. - If the host says approval requests are disabled, stop without running around that policy. End with **ThreadBear recap 🐻**: “No tasks changed. This task cannot ask for onboarding permission. Next: use a task where Codex can ask, then say **ThreadBear onboard**.” Do not change the user's permission settings. -2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any preparation or title write. If enumeration fails, make zero changes. -3. Explain `total`, `safe`, and `needs_update` with this card: - -> ## Here's what will happen -> -> - I found N existing tasks. X have safe titles, and Y need a ThreadBear icon. -> - The rest stay untouched. -> - I'll check each task again immediately before its one possible title change. -> - If a title changed before its turn, I'll leave it alone. -> - If a change cannot be confirmed, I won't retry it and I'll tell you. -> -> Update these existing tasks now? - -The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. -4. Ask for explicit consent unless unchanged install consent covered this first pass. -5. After consent, follow the installed skill's single onboarding JavaScript cell. Its first action runs exactly: - -```sh -~/.local/bin/threadbear onboard --noninteractive --confirm --json -``` - -The confirmed command asks for the same one-time permission, takes a fresh complete catalog snapshot, and returns one `prepared` action containing the snapshot title and desired title. It stores no titles and makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, wrong-ID, or changed-title response is skipped. Only an exact task ID and snapshot title may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. - -Codex can keep an already-mounted historical row cached after an exact native write. Do not retry or add refresh machinery. The persisted title appears when its project is reopened or Codex restarts; say this plainly in the onboarding summary. - -Report `updated`, `skipped`, `unchanged`, and `unconfirmed`. Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. - -End with: - -> ## ThreadBear recap 🐻 -> -> - Checked N existing tasks: updated X, left Y unchanged, and could not confirm Z. -> - No uncertain task was retried. Older sidebar rows may refresh when their project reopens or Codex restarts. -> - Next: [ThreadBear is ready / rerun **ThreadBear onboard** after resolving the named problem]. ## Commands and updater @@ -186,7 +145,6 @@ End with: ~/.local/bin/threadbear help ~/.local/bin/threadbear status --json ~/.local/bin/threadbear title --status complete --json -~/.local/bin/threadbear onboard --dry-run --json ~/.local/bin/threadbear update --json ``` @@ -220,31 +178,29 @@ Preview first: ~/.local/bin/threadbear uninstall --dry-run --json ``` +Run the preview with `sandbox_permissions:"require_escalated"` and explain that Codex is asking once to read the complete unarchived task catalog. Require `ready:true`, `plan_complete:true`, and `read_only:true`. If permission is unavailable or catalog enumeration fails, stop without changing titles or files. + End the consent turn with: > ## Here's what will happen > -> - I'll remove ThreadBear's local helper, Codex instructions, skill, and automatic updates. -> - Your tasks, other Codex settings, and unrelated files stay untouched. -> - Existing title icons may remain until those tasks are renamed. +> - I'll remove one ThreadBear status prefix from each safe unarchived task title, then remove ThreadBear's local helper, Codex instructions, skill, and automatic updates. +> - Plain, user-authored, ambiguous, unsafe, and archived titles stay unchanged, as do other Codex settings and unrelated files. +> - I'll reread every prepared task immediately before its one possible title change. Any drift or unconfirmed result stops before ThreadBear files are removed. > - After removal, you'll restart Codex once. > > Uninstall ThreadBear now? -After consent: - -```sh -~/.local/bin/threadbear uninstall --noninteractive --confirm --json -``` +After consent, follow the installed skill's single uninstall JavaScript cell. It first runs exact `uninstall --prepare --noninteractive --confirm --json`, taking one fresh complete plan. It then serially rereads and removes one owned prefix from every prepared target, with the initiating task last. Any missing, drifted, malformed, wrong-target, wrong-title, or thrown result blocks teardown and gets one fresh-rerun action; never retry in the same pass. Only after every prepared write returns the exact target and title does the cell run exact `uninstall --commit --noninteractive --confirm --json`. A bare confirmed uninstall is refused. There is no final catalog scan, marker, queue, controller, or resume state. -Require committed removal and verify unrelated AGENTS content, skills, settings, files, and LaunchAgents remain byte-for-byte intact. After commit, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. +Require committed removal and verify unrelated AGENTS content, skills, settings, files, titles, and LaunchAgents remain byte-for-byte intact. After commit, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. The final response after committed removal is: > ## ThreadBear recap 🐻 > -> - ThreadBear and its automatic updates were removed. -> - Your tasks and unrelated Codex content stayed untouched; old title icons may remain. +> - ThreadBear and its automatic updates were removed after cleaning X task titles. +> - Y task titles were left unchanged. Your task content and unrelated Codex content stayed untouched. > - Next: restart Codex so open tasks drop the old instructions. ## Release proof @@ -256,8 +212,8 @@ Release acceptance additionally requires one reviewed candidate live-tested end - the stateless terminal helper works under Codex's default workspace permissions and starts no App Server or title-state write; - the mounted app-native reader supplies the exact current title, and the setter receives no explicit current-task ID and returns the exact task ID/title; - the rendered sidebar shows the expected title before and after a clean restart; -- a full onboarding preview enumerates every local task, confirmed preparation writes no title, and the consented serial app-native pass accounts for every prepared target while skipping title drift before any write; -- failures and unconfirmed results are reported locally without retries or global failure state; +- a full uninstall preview enumerates every unarchived task, confirmed preparation writes no title, and the consented serial app-native pass processes the initiating task last; +- drift and unconfirmed results block teardown without retries or global failure state, while all-exact cleanup proceeds directly to artifact removal with no final inventory scan or post-commit title call; - automatic update and uninstall preserve neighboring user content. If the mounted app-native writer causes practical title corruption or response blocking, disable rewriting instead of adding reconciliation machinery. diff --git a/README.md b/README.md index 5632a7d..62d5206 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,12 @@ ThreadBear is a small local title decorator for Codex Desktop. Immediately befor | 🤖 | healthy automation | | ➡️ | next steps | | ✅ | complete | -| 🐻 | existing task onboarded, status not yet known | -The visible shape is ` `. ThreadBear reserves those six exact leading icon prefixes. Every other safe leading emoji and subject byte stays exact, except an ambiguous old ThreadBear prefix is deliberately left unchanged rather than guessed. Owners and actions stay in response prose. A title it cannot handle safely stays unchanged rather than truncated. +The visible shape is ` `. ThreadBear writes only those five status prefixes. It also recognizes the obsolete neutral `🐻 ` prefix so it can remove that decoration without ever emitting it. Every other safe leading emoji and subject byte stays exact, except an ambiguous old ThreadBear prefix is deliberately left unchanged rather than guessed. Owners and actions stay in response prose. A title it cannot handle safely stays unchanged rather than truncated. ## Install -Open [INSTALL.md](INSTALL.md) in a Codex task and follow the guided preview and consent flow. There is no persistent ThreadBear task or controller. After installation, restart Codex so open tasks load the new managed guidance, then ask for **ThreadBear onboard** if you want existing local titles updated. +Open [INSTALL.md](INSTALL.md) in a Codex task and follow the guided preview and consent flow. There is no persistent ThreadBear task or controller. Installation leaves historical task titles unchanged; restart Codex so open tasks load the new managed guidance. ThreadBear installs one Go binary, one managed instruction block, one skill, and one daily update-only LaunchAgent. It keeps no per-task title database. A consented reset from 2.2.1 deletes the exact old automation, unpins the exact former persistent task without renaming it, replaces managed artifacts, imports no old state, and does not guess at legacy title cleanup. @@ -24,8 +23,6 @@ ThreadBear installs one Go binary, one managed instruction block, one skill, and ```text threadbear install threadbear title --status complete -threadbear onboard --dry-run -threadbear onboard --noninteractive --confirm threadbear status threadbear self-test threadbear update @@ -37,11 +34,11 @@ Every command accepts `--json`; the installed binary's `help` output is authorit The terminal `title` command accepts exactly `complete`, `next_steps`, `needs_input`, `blocked`, or `automation`. It returns the calling task ID and fixed icon/safety policy without reading Codex or writing state. In the same cell, the mounted app reads the exact current title, safely derives the subject, and—only when needed—applies one title to the calling task. The exact returned task ID and title must match. A failure stays local and is never retried. -`onboard --dry-run --json` asks once for permission to enumerate the complete unarchived App Server catalog and reports a read-only plan. After separate consent, `onboard --noninteractive --confirm --json` takes a fresh complete snapshot and returns prepared actions with no arbitrary item cap or local title records. For each action the mounted app rereads the exact task immediately, skips drift, and makes at most one native write. The receipt accounts honestly for updated, skipped, unchanged, and unconfirmed tasks. +`uninstall --dry-run --json` asks once for permission to enumerate the complete unarchived App Server catalog and reports which exact ThreadBear prefixes will be removed before managed artifacts. After consent, `uninstall --prepare --noninteractive --confirm --json` takes one fresh complete snapshot. The mounted app rereads each prepared task and makes at most one exact prefix-removal write, with the initiating task last. Any drift or unconfirmed result stops before artifact removal; a fresh rerun makes a new plan. Successful cleanup is followed by `uninstall --commit --noninteractive --confirm --json`, with no final rescan or title call. A bare confirmed uninstall is refused. ## Boundaries -Ordinary turns use only mounted Codex reads and writes, so they work under Codex's default workspace permissions. The short-lived official App Server is used only for explicitly approved, complete-catalog onboarding and is launched from a fixed Desktop executable path, never repository `PATH`. ThreadBear does not open Codex SQLite, edit Desktop caches or task prose, archive tasks, retry title writes, or maintain a database, queue, controller, repair pass, or persistent management task. +Ordinary turns use only mounted Codex reads and writes, so they work under Codex's default workspace permissions. The short-lived official App Server is used only for explicitly approved uninstall cleanup and is launched from a fixed Desktop executable path, never repository `PATH`. ThreadBear does not open Codex SQLite, edit Desktop caches or task prose, archive tasks, retry title writes, or maintain a database, queue, controller, repair pass, or persistent management task. The daily LaunchAgent does one job: check for a verified official update. Network and candidate-verification failures leave the old install untouched. A later managed-surface write can produce a truthful rerunnable partial, with the binary written last. Successful updates report whether Codex must restart. Updater health is separate from title-core `ready`; it never reads tasks or changes titles. diff --git a/assets/AGENTS.threadbear.md b/assets/AGENTS.threadbear.md index b466a02..c694516 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -81,7 +81,7 @@ text(JSON.stringify({ready:true, task_id:plan.task_id, title:renamed.title, upda The local command only returns the calling task ID and fixed title policy. The mounted Codex app reads the exact current title and is the sole writer. It receives no explicit task ID when writing, so it can target only the calling task. Make at most one native write attempt. Never run the cell as a progress update. If the outer cell yields, wait only for that same cell; the yield does not cancel a slow native call. Never start another cell, poll the title, retry, or reconcile. A returned failure is local to this turn. -The status controls only the visible icon. ThreadBear reserves its six exact current icon prefixes, preserves every other safe subject and user-authored emoji, and leaves an ambiguous old ThreadBear prefix unchanged rather than guessing. It never puts an owner or action in the title. Use: +The status controls only the visible icon. ThreadBear emits five exact status prefixes and recognizes the obsolete neutral bear prefix only so it can remove it. It preserves every other safe subject and user-authored emoji, and leaves an ambiguous old ThreadBear prefix unchanged rather than guessing. It never puts an owner or action in the title. Use: - `complete` when the work is finished with no warranted follow-up. - `next_steps` when the response establishes one concrete next action for the user, agent, or an external party. diff --git a/assets/help.txt b/assets/help.txt index 7b81217..c37bd9a 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -6,7 +6,6 @@ Usage: Commands: install Show what will change, then install ThreadBear title Return this task's status icon policy for Codex to apply - onboard Preview or prepare existing-task onboarding status Check whether ThreadBear and daily updates are ready self-test Validate a release candidate update Verify and install the latest release @@ -21,22 +20,17 @@ Title: The managed guidance runs title exactly once immediately before a final response. The helper is stateless. The mounted Codex app reads the exact current title and applies at most one icon change. A returned failure stays local and is never retried. -Onboard: - threadbear onboard --dry-run --json - threadbear onboard --noninteractive --confirm --json - -The preview enumerates the complete local catalog before any write. The confirmed command takes a fresh complete snapshot and prepares every safe target with no item cap. Managed guidance immediately rereads each prepared task, skips drift, applies at most once, and reports updated, skipped, unchanged, and unconfirmed counts. - Install flags: --dry-run Preview without changing the machine --noninteractive Do not prompt --confirm Confirm the described install or reset --reset Replace a consented, native-cleaned legacy 2.2.1 installation - --no-onboard Install without offering historical onboarding - Uninstall flags: --dry-run Preview without changing the machine + --prepare Prepare exact title cleanup after consent --noninteractive Do not prompt --confirm Confirm removal +Uninstall preview enumerates the complete unarchived task catalog. After consent, managed guidance takes one fresh plan, rereads each prepared task, removes one exact ThreadBear prefix at most once, and stops before artifact removal on any drift or unconfirmed write. + Guided actions preview what will happen and what stays untouched before consent. After tools finish, the final response must recap the result, uncertainty, and next action so the summary stays visible. diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index e5501d5..2d2496a 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: threadbear -description: Install, onboard, update, check, or uninstall ThreadBear for Codex Desktop on macOS. +description: Install, update, check, or uninstall ThreadBear for Codex Desktop on macOS. --- # ThreadBear @@ -14,55 +14,56 @@ Put the recap in the final answer. Call safe skips “left unchanged.” Give pa ## Install or reset -Follow `https://threadbear.sh/install`. Preview the helper, instructions, skill, and daily updates; leave tasks, settings, and titles alone. Restart, then onboard. +Follow `https://threadbear.sh/install`. Preview the helper, instructions, skill, and daily updates; leave tasks, settings, and titles alone. Restart after installation. For 2.2.1, touch only the verified old task and automation; stop on mismatch. After consent, install; verify `version`, `self-test`, `status`. Recap: -> Open any task after restart and say: **ThreadBear onboard** +> Restart Codex so open tasks load the new ThreadBear instructions. -## Onboard existing tasks +## Uninstall -1. Run `status --json`. Say: “Codex will ask once so ThreadBear can read your complete task list. This preview changes nothing.” Run `onboard --dry-run --json` with `sandbox_permissions:"require_escalated"`. Require `ready:true`, `plan_complete:true`, `read_only:true`. - If Codex says approval requests are disabled, stop. Never change settings or bypass permission. Recap: “No tasks changed. This task cannot ask. Next: use a task that can, then say **ThreadBear onboard**.” -2. Say: “N tasks found. X are safe; Y need an icon. The rest stay untouched. I'll recheck each before one change.” Ask consent. +1. Say: “Codex will ask once so ThreadBear can read the complete task list. This preview changes nothing.” Run `uninstall --dry-run --json` with `sandbox_permissions:"require_escalated"`. Require `ready:true`, `plan_complete:true`, `read_only:true`. Preview removing one ThreadBear prefix from each safe unarchived task, then the helper, instructions, skill, and updates. Tasks, settings, files, user-authored titles, ambiguous prefixes, unsafe titles, and archived tasks stay unchanged. Ask consent. +2. If Codex says approval requests are disabled, stop. Never change settings or bypass permission. 3. After consent, run this exact cell once: ```js // @exec: {"yield_time_ms": 30000, "max_output_tokens": 4000} -notify("ThreadBear onboarding: preparing"); -let local = await tools.exec_command({ - cmd:"\"$HOME/.local/bin/threadbear\" onboard --noninteractive --confirm --json", - yield_time_ms:30000, - max_output_tokens:200000, - sandbox_permissions:"require_escalated", - justification:"Allow ThreadBear to read the full Codex task list for the onboarding you approved?" -}); -let output = local.output || ""; -while (local.session_id !== undefined) { - notify("ThreadBear onboarding: preparing"); - local = await tools.write_stdin({ - session_id:local.session_id, - yield_time_ms:30000, - max_output_tokens:200000 - }); - output += local.output || ""; -} -if (local.exit_code !== 0) { text(local); exit(); } +const runCLI = async (cmd, justification) => { + let call = await tools.exec_command({cmd,yield_time_ms:30000,max_output_tokens:200000, + sandbox_permissions:"require_escalated",justification}); + let output = call.output || ""; + while (call.session_id !== undefined) { + call = await tools.write_stdin({session_id:call.session_id,yield_time_ms:30000, + max_output_tokens:200000}); + output += call.output || ""; + } + return {call,output}; +}; +notify("ThreadBear uninstall: preparing title cleanup"); +const preparedCall = await runCLI( + "\"$HOME/.local/bin/threadbear\" uninstall --prepare --noninteractive --confirm --json", + "Allow ThreadBear to read the complete Codex task list for the uninstall you approved?" +); +if (preparedCall.call.exit_code !== 0) { text(preparedCall.call); exit(); } let plan; -try { plan = JSON.parse(output); } +try { plan = JSON.parse(preparedCall.output); } catch { text(JSON.stringify({ready:false,reason:"Malformed plan"})); exit(); } if (!plan || plan.ready !== true || plan.plan_complete !== true || - plan.read_only !== false || !Number.isInteger(plan.total) || !Array.isArray(plan.items)) { + plan.read_only !== false || !Number.isInteger(plan.total) || + !Number.isInteger(plan.needs_cleanup) || !Number.isInteger(plan.prepared) || + !Number.isInteger(plan.unchanged) || !Number.isInteger(plan.skipped) || + plan.needs_cleanup !== plan.prepared || + !Array.isArray(plan.items)) { text(JSON.stringify({ready:false,reason:"Incomplete plan"})); exit(); } const prepared = plan.items.filter(item => item.outcome === "prepared"); -if (prepared.some(item => !item || typeof item.task_id !== "string" || +if (prepared.length !== plan.prepared || prepared.some(item => !item || typeof item.task_id !== "string" || typeof item.title !== "string" || typeof item.desired_title !== "string")) { text(JSON.stringify({ready:false,reason:"Invalid item"})); exit(); } -let updated = 0, skipped = 0, unconfirmed = 0; +let updated = 0, drifted = 0, unconfirmed = 0; const parseNative = value => { if (typeof value !== "string") return value; try { return JSON.parse(value); } catch { return null; } @@ -73,7 +74,7 @@ for (const item of prepared) { current = parseNative(await tools.codex_app__read_thread({threadId:item.task_id, includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})); } catch { current = null; } - if (current?.thread?.id !== item.task_id || current.thread.title !== item.title) skipped++; + if (current?.thread?.id !== item.task_id || current.thread.title !== item.title) drifted++; else { let renamed; try { @@ -84,28 +85,32 @@ for (const item of prepared) { renamed.title === item.desired_title) updated++; else unconfirmed++; } - const done = updated + skipped + unconfirmed; - if (done % 25 === 0 || done === prepared.length) notify(`ThreadBear onboarding: ${done}/${prepared.length}`); + const done = updated + drifted + unconfirmed; + if (done % 25 === 0 || done === prepared.length) notify(`ThreadBear uninstall: titles ${done}/${prepared.length}`); } -const accounted = updated + skipped + unconfirmed === prepared.length; -text(JSON.stringify({ - ready:accounted && unconfirmed === 0, - plan_complete:true, - onboarding_complete:accounted && unconfirmed === 0, - total:plan.total, - updated, - skipped, - unchanged:plan.total - updated - unconfirmed, - unconfirmed -})); +const accounted = updated + drifted + unconfirmed === prepared.length; +if (!accounted || drifted !== 0 || unconfirmed !== 0) { + text(JSON.stringify({ready:false,uninstalled:false,plan_complete:true, + cleanup_complete:false,total:plan.total,prepared:plan.prepared,updated,drifted, + unchanged:plan.unchanged,skipped:plan.skipped,unconfirmed, + safe_rerun:"threadbear uninstall --dry-run --json"})); + exit(); +} +notify("ThreadBear uninstall: removing managed artifacts"); +const removedCall = await runCLI( + "\"$HOME/.local/bin/threadbear\" uninstall --commit --noninteractive --confirm --json", + "Allow ThreadBear to remove the managed artifacts from the uninstall you approved?" +); +let removed; +try { removed = JSON.parse(removedCall.output); } +catch { text(JSON.stringify({ready:false,uninstalled:false,reason:"Malformed uninstall result", + title_cleanup:{total:plan.total,updated,unchanged:plan.unchanged,skipped:plan.skipped}})); exit(); } +removed.title_cleanup = {total:plan.total,updated,unchanged:plan.unchanged,skipped:plan.skipped}; +text(JSON.stringify(removed)); ``` -Recap: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` No retry, cap, or persistent task. +Only `uninstalled:true` means removed. Otherwise recap the partial and its one next action. Never retry a drifted or unconfirmed title in the same pass. After artifact commit, make no title call. Successful recap: “ThreadBear was removed. X task titles were cleaned; Y were left unchanged. Its helper, instructions, skill, and automatic updates are gone. Tasks, settings, and files stayed. Restart Codex.” ## Update Preview download, checks, replacement, and restart. With consent run `update --json`; recap version and next action. - -## Uninstall - -Run `uninstall --dry-run --json`. Preview removing the helper, instructions, skill, and updates; keep tasks, settings, and files; icons may remain. Ask consent. Run `uninstall --noninteractive --confirm --json`. Only `uninstalled:true` means removed; otherwise recap the partial and next action. After commit, no title cell. Recap exactly: “ThreadBear was removed. Its helper, instructions, skill, and automatic updates are gone. Tasks, settings, and files stayed; icons may remain. Restart Codex.” diff --git a/cmd/threadbear/appserver_list.go b/cmd/threadbear/appserver_list.go index 7df1aeb..5b100be 100644 --- a/cmd/threadbear/appserver_list.go +++ b/cmd/threadbear/appserver_list.go @@ -13,14 +13,14 @@ import ( ) const ( - appServerListLimit = 100 - appServerListTimeout = 30 * time.Second - appServerOnboardingTimeout = 10 * time.Minute + appServerListLimit = 100 + appServerListTimeout = 30 * time.Second + appServerCleanupTimeout = 10 * time.Minute ) var ( - appServerListBudget = appServerListTimeout - appServerOnboardingBudget = appServerOnboardingTimeout + appServerListBudget = appServerListTimeout + appServerCleanupBudget = appServerCleanupTimeout ) type appServerRPCMessage struct { diff --git a/cmd/threadbear/appserver_list_test.go b/cmd/threadbear/appserver_list_test.go index 1b270fd..0f9d05b 100644 --- a/cmd/threadbear/appserver_list_test.go +++ b/cmd/threadbear/appserver_list_test.go @@ -16,7 +16,7 @@ import ( func TestAppServerInventoryExhaustsPagesBeforeDedupe(t *testing.T) { _, _ = testIndex(t) installAppServerFixture(t, "multipage") - result, err := runOnboarding(t.Context(), false, "") + result, err := runTitleCleanup(t.Context(), false, "") if err != nil { t.Fatal(err) } @@ -29,7 +29,7 @@ func TestAppServerInventoryExhaustsPagesBeforeDedupe(t *testing.T) { t.Fatalf("inventory order = %#v", tasks) } } - if tasks[3].Title != "First duplicate title" || tasks[1].Safe { + if tasks[3].Title != "First duplicate title" || tasks[1].Outcome != cleanupSkipped { t.Fatalf("inventory authority = %#v", tasks) } } @@ -43,7 +43,7 @@ func TestAppServerInventoryFailsBeforeStateWrites(t *testing.T) { if apply { activeTaskID = testActiveID } - if result, err := runOnboarding(t.Context(), apply, activeTaskID); err == nil || result.PlanComplete || + if result, err := runTitleCleanup(t.Context(), apply, activeTaskID); err == nil || result.PlanComplete || !strings.Contains(err.Error(), "page 2") { t.Fatalf("failed inventory = %#v, %v", result, err) } @@ -61,10 +61,10 @@ func TestAppServerInventoryFailsBeforeStateWrites(t *testing.T) { func TestAppServerNonzeroExitCannotOverturnCompleteProof(t *testing.T) { _, index := testIndex(t) installAppServerFixture(t, "close-nonzero") - index.setTitle(t, testAlphaID, "Alpha") - if result, err := runOnboarding(t.Context(), true, testActiveID); err != nil || !result.Ready || - result.OnboardingComplete || result.Prepared != 1 || result.NeedsUpdate != 1 { - t.Fatalf("onboarding proof = %#v, %v", result, err) + index.setTitle(t, testAlphaID, "✅ Alpha") + if result, err := runTitleCleanup(t.Context(), true, testActiveID); err != nil || !result.Ready || + result.Prepared != 1 || result.NeedsCleanup != 1 { + t.Fatalf("cleanup proof = %#v, %v", result, err) } } diff --git a/cmd/threadbear/core_test.go b/cmd/threadbear/core_test.go index ea4f5cc..4fcdc11 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -133,64 +133,67 @@ func TestCurrentTitleRejectsInvalidContextWithoutSideEffects(t *testing.T) { } } -func TestOnboardingPlanAndPreparationAreCompleteAndStateless(t *testing.T) { +func TestTitleCleanupPlanAndPreparationAreCompleteAndStateless(t *testing.T) { _, index := testIndex(t) - index.setTitle(t, testActiveID, "Active task") + index.setTitle(t, testActiveID, "✅ Active task") index.setTitle(t, testAlphaID, "Alpha") index.setTitle(t, testAlreadyID, "🐻 Beta") index.setRaw(t, testRawID) - plan, err := runOnboarding(t.Context(), false, testActiveID) - if err != nil || !plan.Ready || !plan.PlanComplete || !plan.ReadOnly || plan.OnboardingComplete || - plan.Total != 4 || plan.Safe != 3 || plan.NeedsUpdate != 1 || plan.Unchanged != 2 || plan.Skipped != 1 { - t.Fatalf("onboarding preview = %#v, %v", plan, err) + plan, err := runTitleCleanup(t.Context(), false, "") + if err != nil || !plan.Ready || !plan.PlanComplete || !plan.ReadOnly || + plan.Total != 4 || plan.NeedsCleanup != 2 || plan.Unchanged != 1 || plan.Skipped != 1 { + t.Fatalf("cleanup preview = %#v, %v", plan, err) } - if active := onboardingItemByID(t, plan.Items, testActiveID); active.Outcome != onboardingUnchanged { - t.Fatalf("active preview item = %#v", active) + if bear := cleanupItemByID(t, plan.Items, testAlreadyID); bear.Outcome != cleanupNeedsUpdate || bear.DesiredTitle != "Beta" { + t.Fatalf("bear preview item = %#v", bear) } clearFixtureRequests(t) - prepared, err := runOnboarding(t.Context(), true, testActiveID) - if err != nil || !prepared.Ready || !prepared.PlanComplete || prepared.ReadOnly || prepared.OnboardingComplete || - prepared.Prepared != 1 || prepared.NeedsUpdate != 1 || prepared.Unchanged != 2 || prepared.Skipped != 1 { - t.Fatalf("onboarding preparation = %#v, %v", prepared, err) + prepared, err := runTitleCleanup(t.Context(), true, testActiveID) + if err != nil || !prepared.Ready || !prepared.PlanComplete || prepared.ReadOnly || + prepared.Prepared != 2 || prepared.NeedsCleanup != 2 || prepared.Unchanged != 1 || prepared.Skipped != 1 { + t.Fatalf("cleanup preparation = %#v, %v", prepared, err) } - alpha := onboardingItemByID(t, prepared.Items, testAlphaID) - if alpha.Outcome != onboardingPrepared || alpha.Title != "Alpha" || alpha.DesiredTitle != "🐻 Alpha" { - t.Fatalf("prepared item = %#v", alpha) + bear := cleanupItemByID(t, prepared.Items, testAlreadyID) + if bear.Outcome != cleanupPrepared || bear.Title != "🐻 Beta" || bear.DesiredTitle != "Beta" { + t.Fatalf("prepared item = %#v", bear) + } + if last := prepared.Items[len(prepared.Items)-1]; last.TaskID != testActiveID || last.DesiredTitle != "Active task" { + t.Fatalf("active task was not prepared last: %#v", prepared.Items) } if requests := fixtureRequests(t); countFixtureMethod(requests, "thread/list") != 1 || countFixtureMethod(requests, "thread/read") != 0 || countFixtureMethod(requests, "thread/name/set") != 0 { - t.Fatalf("onboarding RPCs = %#v", requests) + t.Fatalf("cleanup RPCs = %#v", requests) } if _, err := os.Stat(stateDir()); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("onboarding preparation created ThreadBear state: %v", err) + t.Fatalf("cleanup preparation created ThreadBear state: %v", err) } } -func TestUnsafeActiveOnboardingTaskDoesNotInflateSafeCount(t *testing.T) { +func TestUnsafeActiveCleanupTaskStaysSkipped(t *testing.T) { _, index := testIndex(t) index.setRaw(t, testActiveID) - result, err := runOnboarding(t.Context(), false, testActiveID) - if err != nil || !result.Ready || result.Safe != 0 || result.NeedsUpdate != 0 || - result.Unchanged != 1 || !result.OnboardingComplete { + result, err := runTitleCleanup(t.Context(), true, testActiveID) + if err != nil || !result.Ready || result.NeedsCleanup != 0 || result.Prepared != 0 || + result.Unchanged != 0 || result.Skipped != 1 { t.Fatalf("unsafe active plan = %#v, %v", result, err) } - item := onboardingItemByID(t, result.Items, testActiveID) - if item.Safe || item.Title != "" || item.Subject != "" || item.DesiredTitle != "" || item.Outcome != onboardingUnchanged { + item := cleanupItemByID(t, result.Items, testActiveID) + if item.Title != "" || item.DesiredTitle != "" || item.Outcome != cleanupSkipped { t.Fatalf("unsafe active item = %#v", item) } } -func onboardingItemByID(t testing.TB, items []onboardingItem, id string) onboardingItem { +func cleanupItemByID(t testing.TB, items []cleanupItem, id string) cleanupItem { t.Helper() for _, item := range items { if item.TaskID == id { return item } } - t.Fatalf("missing onboarding item %q", id) - return onboardingItem{} + t.Fatalf("missing cleanup item %q", id) + return cleanupItem{} } func fixtureRequests(t testing.TB) []fixtureMessage { diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index 013e217..dc8f154 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -30,12 +30,12 @@ type lifecyclePaths struct { } type installOptions struct { - DryRun, Confirmed, Reset, NoOnboard, Automatic bool - SelectedVersion string + DryRun, Confirmed, Reset, Automatic bool + SelectedVersion string } type uninstallOptions struct { - DryRun, Confirmed bool + DryRun, Prepare, Commit, Confirmed bool } type legacyInstall struct{ MainTaskID string } @@ -80,9 +80,6 @@ func install(ctx context.Context, options installOptions) (any, error) { if options.SelectedVersion != "" && options.SelectedVersion != version { return nil, fmt.Errorf("installer selected version %q but candidate is %q", options.SelectedVersion, version) } - if options.Automatic { - options.NoOnboard = true - } source, err := os.Executable() if err != nil { return nil, err @@ -221,7 +218,7 @@ func install(ctx context.Context, options installOptions) (any, error) { } partial := installPartial(result, stage, true, options) if legacyCleanupCommitted { - partial["safe_rerun"] = confirmedInstallRerun(p, options.NoOnboard) + partial["safe_rerun"] = confirmedInstallRerun(p) } return partial, checkErr } @@ -229,7 +226,6 @@ func install(ctx context.Context, options installOptions) (any, error) { } func installResult(options installOptions, legacy legacyInstall, legacyFound, dry bool) map[string]any { - onboarding := !options.NoOnboard && !options.Automatic result := map[string]any{ "ready": dry, "installed": false, @@ -237,7 +233,6 @@ func installResult(options installOptions, legacy legacyInstall, legacyFound, dr "dry_run": dry, "legacy_reset_required": legacyFound, "reset": options.Reset, - "onboarding_requested": onboarding, "automatic_updates_enabled": false, "restart_required": false, "partial": false, @@ -250,9 +245,6 @@ func installResult(options installOptions, legacy legacyInstall, legacyFound, dr result["legacy_automation_kind"] = legacyAutomationKind result["legacy_automation_target_thread_id"] = legacy.MainTaskID } - if onboarding { - result["next_request"] = "threadbear onboard --dry-run --json" - } return result } @@ -264,12 +256,8 @@ func installPartial(result map[string]any, stage string, restart bool, options i return partialResult(result, stage, restart, rerun) } -func confirmedInstallRerun(p lifecyclePaths, noOnboard bool) string { - command := quoteArgument(p.binary) + " install" - if noOnboard { - command += " --no-onboard" - } - return command + " --noninteractive --confirm --json" +func confirmedInstallRerun(p lifecyclePaths) string { + return quoteArgument(p.binary) + " install --noninteractive --confirm --json" } func installChanges(p lifecyclePaths, legacy bool) []string { @@ -289,39 +277,40 @@ func installChanges(p lifecyclePaths, legacy bool) []string { return changes } -func onboard(ctx context.Context, dryRun, confirmed bool) (any, error) { - if dryRun && confirmed { - return nil, errors.New("onboarding preview cannot also be confirmed") +func uninstall(ctx context.Context, options uninstallOptions) (any, error) { + if options.DryRun && (options.Prepare || options.Commit || options.Confirmed) { + return nil, errors.New("uninstall preview cannot also be confirmed") } - if !dryRun && !confirmed { - return nil, errors.New("onboarding requires --dry-run or --noninteractive --confirm") + if options.Prepare && options.Commit { + return nil, errors.New("uninstall cannot prepare title cleanup and commit artifact removal together") } - p := installPaths() - if err := requireCurrentFormatInstall(p); err != nil { - return nil, fmt.Errorf("onboarding requires the current ThreadBear installation: %w", err) + if options.Prepare && !options.Confirmed { + return nil, errors.New("uninstall preparation requires --noninteractive --confirm") } - return runOnboarding(ctx, confirmed, os.Getenv("CODEX_THREAD_ID")) -} - -func uninstall(ctx context.Context, options uninstallOptions) (any, error) { - if options.DryRun && options.Confirmed { - return nil, errors.New("uninstall preview cannot also be confirmed") + if options.Commit && !options.Confirmed { + return nil, errors.New("uninstall artifact commit requires --noninteractive --confirm") } - preview := map[string]any{ - "ready": true, "dry_run": options.DryRun, "uninstalled": false, - "icons_may_remain": true, "restart_required": false, "partial": false, - "warning": "Existing ThreadBear title icons may remain until renamed.", - "planned_changes": uninstallChanges(installPaths()), + if options.Confirmed && !options.Prepare && !options.Commit { + return nil, errors.New("confirmed uninstall must use --prepare or --commit") } p := installPaths() + preview := uninstallResult(p, options.DryRun) + if options.Commit { + preview["planned_changes"] = uninstallArtifactChanges(p) + } partialAdmission, err := preflightUninstall(ctx, p) if err != nil { return preview, err } - if options.DryRun { + if options.DryRun || options.Prepare { + plan, planErr := runTitleCleanup(ctx, options.Prepare, os.Getenv("CODEX_THREAD_ID")) + preview = uninstallCleanupResult(p, options.DryRun, plan) + if planErr != nil { + return preview, planErr + } return preview, nil } - if !options.Confirmed { + if !options.Commit { return preview, errors.New("uninstall requires --noninteractive --confirm after its preview") } // Use the same update -> stable boundary -> lifecycle order as install. @@ -415,14 +404,35 @@ func uninstall(ctx context.Context, options uninstallOptions) (any, error) { } return map[string]any{ "ready": true, "dry_run": false, "uninstalled": true, - "icons_may_remain": true, "restart_required": true, "partial": false, - "warning": "Existing ThreadBear title icons may remain until renamed.", - "planned_changes": uninstallChanges(p), + "restart_required": true, "partial": false, + "planned_changes": uninstallArtifactChanges(p), }, nil } +func uninstallResult(p lifecyclePaths, dryRun bool) map[string]any { + return map[string]any{ + "ready": true, "dry_run": dryRun, "uninstalled": false, + "restart_required": false, "partial": false, + "planned_changes": uninstallChanges(p), + } +} + +func uninstallCleanupResult(p lifecyclePaths, dryRun bool, plan cleanupResult) map[string]any { + result := uninstallResult(p, dryRun) + result["plan_complete"] = plan.PlanComplete + result["read_only"] = plan.ReadOnly + result["total"] = plan.Total + result["needs_cleanup"] = plan.NeedsCleanup + result["prepared"] = plan.Prepared + result["unchanged"] = plan.Unchanged + result["skipped"] = plan.Skipped + result["items"] = plan.Items + result["ready"] = plan.Ready + return result +} + func uninstallRerun(p lifecyclePaths) string { - return quoteArgument(p.binary) + " uninstall --noninteractive --confirm --json" + return quoteArgument(p.binary) + " uninstall --commit --noninteractive --confirm --json" } func partialResult(result map[string]any, stage string, restart bool, rerun string) map[string]any { @@ -432,6 +442,12 @@ func partialResult(result map[string]any, stage string, restart bool, rerun stri } func uninstallChanges(p lifecyclePaths) []string { + return append([]string{ + "remove one ThreadBear prefix from each safe unarchived task title", + }, uninstallArtifactChanges(p)...) +} + +func uninstallArtifactChanges(p lifecyclePaths) []string { return []string{ "boot out and remove " + updateAgentLabel + " LaunchAgent " + p.launchAgent, "remove managed AGENTS block from " + p.agents, diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index e9233a9..e1b4444 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -83,14 +83,14 @@ func managedLaunchctlPrint(path, binary string) []byte { return []byte(output.String()) } -func TestInstallPreviewConfirmationAndOnboardingReceipt(t *testing.T) { +func TestInstallPreviewAndConfirmationHaveNoOnboardingSurface(t *testing.T) { p := isolatedLifecycle(t) preview, err := install(context.Background(), installOptions{DryRun: true}) if err != nil { t.Fatal(err) } got := preview.(map[string]any) - if got["installed"] != false || got["onboarding_requested"] != true || got["next_request"] != "threadbear onboard --dry-run --json" || got["automatic_updates_enabled"] != false { + if got["installed"] != false || got["automatic_updates_enabled"] != false || got["onboarding_requested"] != nil || got["next_request"] != nil { t.Fatalf("preview = %#v", got) } planned := got["planned_changes"].([]string) @@ -117,16 +117,9 @@ func TestInstallPreviewConfirmationAndOnboardingReceipt(t *testing.T) { t.Fatal(err) } got = result.(map[string]any) - if got["ready"] != true || got["installed"] != true || got["version"] != version || got["next_request"] != "threadbear onboard --dry-run --json" || got["restart_required"] != true || got["automatic_updates_enabled"] != true { + if got["ready"] != true || got["installed"] != true || got["version"] != version || got["next_request"] != nil || got["onboarding_requested"] != nil || got["restart_required"] != true || got["automatic_updates_enabled"] != true { t.Fatalf("install = %#v", got) } - without, err := install(context.Background(), installOptions{Confirmed: true, NoOnboard: true}) - if err != nil { - t.Fatal(err) - } - if value := without.(map[string]any); value["onboarding_requested"] != false || value["next_request"] != nil { - t.Fatalf("no-onboard install = %#v", value) - } } func TestLifecycleNeverTouchesCodexHooks(t *testing.T) { @@ -189,8 +182,9 @@ func TestLifecycleNeverTouchesCodexHooks(t *testing.T) { "renamed = parseNative(await tools.codex_app__set_thread_title", "current?.thread?.id !== item.task_id", "current.thread.title !== item.title", - "let updated = 0, skipped = 0, unconfirmed = 0", - "updated + skipped + unconfirmed === prepared.length", + "let updated = 0, drifted = 0, unconfirmed = 0", + "updated + drifted + unconfirmed === prepared.length", + "if (!accounted || drifted !== 0 || unconfirmed !== 0)", } { if !strings.Contains(string(skill), required) { t.Fatalf("installed skill lacks mounted revalidation contract %q: %q", required, skill) @@ -202,9 +196,15 @@ func TestLifecycleNeverTouchesCodexHooks(t *testing.T) { if !exactFile(p.skill, []byte(assets.SkillManagedContent)) { t.Fatal("managed skill is not exact") } - if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + committed, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}) + if err != nil { t.Fatal(err) } + for _, change := range committed.(map[string]any)["planned_changes"].([]string) { + if strings.Contains(change, "task title") { + t.Fatalf("artifact commit claimed title cleanup: %#v", committed) + } + } after, _ := os.ReadFile(hooks) if !bytes.Equal(after, wantHooks) { t.Fatalf("uninstall changed hooks.json: %q", after) @@ -225,7 +225,7 @@ func TestCurrentLifecycleIgnoresMalformedCodexHooks(t *testing.T) { if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatalf("reinstall depended on hooks.json: %v", err) } - if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + if _, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}); err != nil { t.Fatalf("uninstall depended on hooks.json: %v", err) } if got, err := os.ReadFile(hooks); err != nil || !bytes.Equal(got, want) { @@ -243,7 +243,7 @@ func TestManagedAgentsRoundTripPreservesMissingTrailingNewline(t *testing.T) { if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + if _, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}); err != nil { t.Fatal(err) } if got, err := os.ReadFile(p.agents); err != nil || !bytes.Equal(got, original) { @@ -357,9 +357,9 @@ func TestResetPostCleanupFailureUsesOrdinaryConfirmedRerun(t *testing.T) { } t.Cleanup(func() { postResetStatus = oldPostResetStatus }) - result, err := install(context.Background(), installOptions{Confirmed: true, Reset: true, NoOnboard: true}) + result, err := install(context.Background(), installOptions{Confirmed: true, Reset: true}) partial := result.(map[string]any) - wantRerun := confirmedInstallRerun(p, true) + wantRerun := confirmedInstallRerun(p) if err == nil || partial["partial"] != true || partial["stage"] != "status" || partial["legacy_reset_required"] != false || partial["safe_rerun"] != wantRerun { t.Fatalf("post-cleanup reset partial = %#v, %v; rerun want %q", partial, err, wantRerun) } @@ -371,7 +371,7 @@ func TestResetPostCleanupFailureUsesOrdinaryConfirmedRerun(t *testing.T) { } postResetStatus = oldPostResetStatus - if _, err := install(context.Background(), installOptions{Confirmed: true, NoOnboard: true}); err != nil { + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatalf("ordinary confirmed rerun failed: %v", err) } if result, err := status(context.Background()); err != nil || result.(map[string]any)["ready"] != true { @@ -604,39 +604,41 @@ func TestStatusSeparatesPhysicalBinaryPresenceFromReadiness(t *testing.T) { } } -func TestOnboardReturnsCompleteReadOnlyPlan(t *testing.T) { +func TestUninstallReturnsCompleteCleanupPreviewAndPreparation(t *testing.T) { isolatedLifecycle(t) requests := stubPagedAppServer(t) if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - result, err := onboard(context.Background(), true, false) + result, err := uninstall(context.Background(), uninstallOptions{DryRun: true}) if err != nil { t.Fatal(err) } - value := result.(onboardingResult) - if !value.Ready || !value.ReadOnly || !value.PlanComplete || value.OnboardingComplete || value.Total != 3 || value.Safe != 2 || value.NeedsUpdate != 1 || value.Prepared != 0 || value.Unchanged != 1 || value.Skipped != 1 { - t.Fatalf("onboard plan = %#v", value) + value := result.(map[string]any) + if value["ready"] != true || value["read_only"] != true || value["plan_complete"] != true || value["total"] != 3 || value["needs_cleanup"] != 1 || value["prepared"] != 0 || value["unchanged"] != 1 || value["skipped"] != 1 { + t.Fatalf("cleanup preview = %#v", value) } - items := value.Items - if items[2].TaskID != testSafeID || items[2].Title != "Exact subject" || items[2].DesiredTitle != "🐻 Exact subject" { - t.Fatalf("onboard items = %#v", items) + items := value["items"].([]cleanupItem) + legacy := cleanupItemByID(t, items, testLegacyID) + if legacy.Outcome != cleanupNeedsUpdate || legacy.Title != "✅ Maybe owned" || legacy.DesiredTitle != "Maybe owned" { + t.Fatalf("cleanup items = %#v", items) } - t.Setenv("CODEX_THREAD_ID", testSafeID) - activeResult, err := onboard(context.Background(), true, false) + t.Setenv("CODEX_THREAD_ID", testLegacyID) + preparedResult, err := uninstall(context.Background(), uninstallOptions{Prepare: true, Confirmed: true}) if err != nil { t.Fatal(err) } - active := activeResult.(onboardingResult) - if !active.OnboardingComplete || active.NeedsUpdate != 0 || active.Prepared != 0 || active.Unchanged != 2 || active.Items[2].Outcome != onboardingUnchanged || active.Items[2].Reason != "active task is handled by the terminal title writer" { - t.Fatalf("active-task onboarding plan = %#v", active) + prepared := preparedResult.(map[string]any) + preparedItems := prepared["items"].([]cleanupItem) + if prepared["ready"] != true || prepared["read_only"] != false || prepared["prepared"] != 1 || preparedItems[len(preparedItems)-1].TaskID != testLegacyID || !regularExecutable(installPaths().binary) { + t.Fatalf("cleanup preparation = %#v", prepared) } data, err := os.ReadFile(requests) if err != nil || !strings.Contains(string(data), `"method":"initialize"`) || !strings.Contains(string(data), `"cursor":"next"`) { t.Fatalf("App Server requests = %q, %v", data, err) } if _, err := os.Stat(legacySubjectDir()); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("read-only onboard created obsolete subject state: %v", err) + t.Fatalf("cleanup planning created obsolete subject state: %v", err) } } @@ -800,7 +802,7 @@ func TestUninstallInvalidatesPreopenedLifecycleWaiter(t *testing.T) { fake.mu.Unlock() uninstalled := make(chan error, 1) go func() { - _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + _, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}) uninstalled <- err }() select { @@ -872,7 +874,7 @@ func TestUninstallWaitsForInFlightUpdaterBeforeTeardown(t *testing.T) { }) done := make(chan error, 1) go func() { - _, uninstallErr := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + _, uninstallErr := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}) done <- uninstallErr }() select { @@ -919,10 +921,10 @@ func TestUninstallRemovesOwnedArtifactsAndPreservesNeighbors(t *testing.T) { mustWrite(t, ownedRecord, `{"subject":"Owned subject"}`+"\n") mustWrite(t, ownedLock, "") preview, err := uninstall(context.Background(), uninstallOptions{DryRun: true}) - if err != nil || preview.(map[string]any)["icons_may_remain"] != true { + if err != nil || preview.(map[string]any)["plan_complete"] != true || preview.(map[string]any)["icons_may_remain"] != nil { t.Fatalf("uninstall preview = %#v, %v", preview, err) } - if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + if _, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}); err != nil { t.Fatal(err) } for _, path := range []string{p.binary, p.skill, p.launchAgent} { @@ -996,7 +998,7 @@ func TestLifecycleIsolatesCorruptOwnedStateAndPreservesNeighbors(t *testing.T) { if _, err := uninstall(context.Background(), uninstallOptions{DryRun: true}); err != nil { t.Fatalf("isolated corruption blocked uninstall preview: %v", err) } - if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + if _, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}); err != nil { t.Fatalf("isolated corruption blocked uninstall: %v", err) } for _, path := range []string{owned, p.updateReceipt, p.binary} { @@ -1058,7 +1060,7 @@ func TestUninstallPartialNamesStageAndSafeRerun(t *testing.T) { } done := make(chan outcome, 1) go func() { - result, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + result, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}) done <- outcome{result: result, err: err} }() <-fake.bootoutStarted @@ -1070,7 +1072,7 @@ func TestUninstallPartialNamesStageAndSafeRerun(t *testing.T) { close(continueBootout) got := <-done partial := got.result.(map[string]any) - if got.err == nil || partial["partial"] != true || partial["stage"] != "managed_guidance" || partial["restart_required"] != true || partial["safe_rerun"] != "'"+p.binary+"' uninstall --noninteractive --confirm --json" { + if got.err == nil || partial["partial"] != true || partial["stage"] != "managed_guidance" || partial["restart_required"] != true || partial["safe_rerun"] != "'"+p.binary+"' uninstall --commit --noninteractive --confirm --json" { t.Fatalf("uninstall partial = %#v, %v", partial, got.err) } if _, err := os.Stat(p.binary); err != nil { @@ -1088,7 +1090,7 @@ func TestUninstallPartialNamesStageAndSafeRerun(t *testing.T) { } rerun := make(chan error, 1) go func() { - _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + _, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}) rerun <- err }() select { @@ -1124,7 +1126,7 @@ func TestUninstallLateBinaryFailureKeepsConfirmedRerunAdmissible(t *testing.T) { } t.Cleanup(func() { _ = os.Chmod(binDir, 0o700) }) - result, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + result, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}) partial := result.(map[string]any) if err == nil || partial["partial"] != true || partial["stage"] != "binary" || partial["safe_rerun"] != uninstallRerun(p) { t.Fatalf("late uninstall partial = %#v, %v", partial, err) @@ -1144,7 +1146,7 @@ func TestUninstallLateBinaryFailureKeepsConfirmedRerunAdmissible(t *testing.T) { t.Fatal(err) } mustWrite(t, p.skill, "foreign replacement") - if result, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err == nil || result.(map[string]any)["partial"] != false { + if result, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}); err == nil || result.(map[string]any)["partial"] != false { t.Fatalf("partial rerun removed a replacement skill: %#v, %v", result, err) } if got, err := os.ReadFile(p.skill); err != nil || string(got) != "foreign replacement" { @@ -1158,7 +1160,7 @@ func TestUninstallLateBinaryFailureKeepsConfirmedRerunAdmissible(t *testing.T) { if err := os.Chmod(binDir, 0o700); err != nil { t.Fatal(err) } - result, err = uninstall(context.Background(), uninstallOptions{Confirmed: true}) + result, err = uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}) if err != nil || result.(map[string]any)["uninstalled"] != true { t.Fatalf("confirmed uninstall rerun = %#v, %v", result, err) } @@ -1187,7 +1189,7 @@ func TestUninstallRemovesDriftedOwnedSurfaceAndPreservesNeighbors(t *testing.T) t.Fatal("managed AGENTS fixture did not contain expected text") } mustWrite(t, p.agents, drifted) - if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + if _, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}); err != nil { t.Fatalf("drifted uninstall = %v", err) } for _, path := range []string{p.binary, p.skill, p.launchAgent} { @@ -1230,7 +1232,7 @@ func TestUninstallRefusesMalformedMarkersAndUnsafeSkillLeaf(t *testing.T) { t.Fatal(err) } mutate(t, p) - if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err == nil { + if _, err := uninstall(context.Background(), uninstallOptions{Commit: true, Confirmed: true}); err == nil { t.Fatal("unsafe uninstall preflight succeeded") } if _, err := os.Stat(p.binary); err != nil { diff --git a/cmd/threadbear/main.go b/cmd/threadbear/main.go index 2c17a0c..94eeac3 100644 --- a/cmd/threadbear/main.go +++ b/cmd/threadbear/main.go @@ -37,22 +37,18 @@ func run(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writ noninteractive := flags.Bool("noninteractive", false, "run without prompts") confirm := flags.Bool("confirm", false, "confirm the previewed installation") reset := flags.Bool("reset", false, "replace an exact legacy 2.2.1 installation") - noOnboard := flags.Bool("no-onboard", false, "skip guided historical onboarding") automatic := flags.Bool("automatic", false, "internal verified-update installation") + legacyNoOnboard := flags.Bool("no-onboard", false, "accepted only from the v3.0.1 automatic updater") selectedVersion := flags.String("version", "", "installer-selected release version") action = func() (any, error) { + if *legacyNoOnboard && !*automatic { + return nil, errors.New("--no-onboard is accepted only with --automatic") + } return install(ctx, installOptions{ DryRun: *dry, Confirmed: *noninteractive && *confirm, Reset: *reset, - NoOnboard: *noOnboard, Automatic: *automatic, SelectedVersion: *selectedVersion, + Automatic: *automatic, SelectedVersion: *selectedVersion, }) } - case "onboard": - dry := flags.Bool("dry-run", false, "return the complete read-only onboarding plan") - noninteractive := flags.Bool("noninteractive", false, "run without prompts") - confirm := flags.Bool("confirm", false, "prepare all safe onboarding title changes") - action = func() (any, error) { - return onboard(ctx, *dry, *noninteractive && *confirm) - } case "title": selectedStatus := flags.String("status", "", "plan a title for complete, next_steps, needs_input, blocked, or automation") action = func() (any, error) { @@ -68,10 +64,12 @@ func run(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writ action = func() (any, error) { return update(ctx, *automatic) } case "uninstall": dry := flags.Bool("dry-run", false, "preview without mutation") + prepare := flags.Bool("prepare", false, "prepare exact unarchived title cleanup") + commit := flags.Bool("commit", false, "commit artifact removal after exact title cleanup") noninteractive := flags.Bool("noninteractive", false, "run without prompts") confirm := flags.Bool("confirm", false, "confirm removal") action = func() (any, error) { - return uninstall(ctx, uninstallOptions{DryRun: *dry, Confirmed: *noninteractive && *confirm}) + return uninstall(ctx, uninstallOptions{DryRun: *dry, Prepare: *prepare, Commit: *commit, Confirmed: *noninteractive && *confirm}) } case "version": action = func() (any, error) { return map[string]any{"version": version}, nil } diff --git a/cmd/threadbear/main_test.go b/cmd/threadbear/main_test.go index d2f07ac..540671e 100644 --- a/cmd/threadbear/main_test.go +++ b/cmd/threadbear/main_test.go @@ -28,12 +28,38 @@ func TestRunRejectsInvalidTitleStatusBeforeMutation(t *testing.T) { } } -func TestRunRequiresExplicitOnboardingMode(t *testing.T) { - for _, args := range [][]string{{"onboard", "--json"}, {"onboard", "--confirm", "--json"}} { - var stdout, stderr bytes.Buffer - code := run(context.Background(), args, strings.NewReader(""), &stdout, &stderr) - if code != 1 || !strings.Contains(stdout.String(), "onboarding requires --dry-run or --noninteractive --confirm") { - t.Fatalf("onboard mode %v = code %d, stdout %q, stderr %q", args, code, stdout.String(), stderr.String()) - } +func TestRunHasNoOnboardCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"onboard", "--json"}, strings.NewReader(""), &stdout, &stderr) + if code != 2 || !strings.Contains(stderr.String(), `unknown command "onboard"`) { + t.Fatalf("removed onboard command = code %d, stdout %q, stderr %q", code, stdout.String(), stderr.String()) + } +} + +func TestRunRequiresConfirmationForUninstallPreparation(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"uninstall", "--prepare", "--json"}, strings.NewReader(""), &stdout, &stderr) + if code != 1 || !strings.Contains(stdout.String(), "uninstall preparation requires --noninteractive --confirm") { + t.Fatalf("unconfirmed preparation = code %d, stdout %q, stderr %q", code, stdout.String(), stderr.String()) + } +} + +func TestRunRefusesConfirmedUninstallWithoutAnExplicitPhase(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"uninstall", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &stdout, &stderr) + if code != 1 || !strings.Contains(stdout.String(), "confirmed uninstall must use --prepare or --commit") { + t.Fatalf("unphased uninstall = code %d, stdout %q, stderr %q", code, stdout.String(), stderr.String()) + } +} + +func TestRunAcceptsV301AutomaticUpdaterInstallInvocation(t *testing.T) { + isolatedLifecycle(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"install", "--automatic", "--no-onboard", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("v3.0.1 updater invocation = code %d, stdout %q, stderr %q", code, stdout.String(), stderr.String()) } } diff --git a/cmd/threadbear/managed_javascript_test.go b/cmd/threadbear/managed_javascript_test.go index b2e8832..1f9c6d9 100644 --- a/cmd/threadbear/managed_javascript_test.go +++ b/cmd/threadbear/managed_javascript_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -func TestEmbeddedOnboardingJavaScriptResumesAndSerializesNativeWrites(t *testing.T) { +func TestEmbeddedUninstallJavaScriptBlocksTeardownOnDriftAndCommitsAfterExactCleanup(t *testing.T) { protocol := readRepoFile(t, "assets", "skill", "SKILL.md") source := extractJavaScriptCell(t, protocol) sourceJSON, err := json.Marshal(source) @@ -19,131 +19,150 @@ func TestEmbeddedOnboardingJavaScriptResumesAndSerializesNativeWrites(t *testing harness := fmt.Sprintf(` const source = %s; -const plan = { - ready:true, plan_complete:true, read_only:false, total:7, - items:[ - {outcome:"prepared",task_id:"drift",title:"old drift",desired_title:"🐻 old drift"}, - {outcome:"prepared",task_id:"unreadable",title:"old unreadable",desired_title:"🐻 old unreadable"}, - {outcome:"prepared",task_id:"wrongid",title:"old wrongid",desired_title:"🐻 old wrongid"}, - {outcome:"prepared",task_id:"exact",title:"old exact",desired_title:"🐻 old exact"}, - {outcome:"prepared",task_id:"object",title:"old object",desired_title:"🐻 old object"}, - {outcome:"prepared",task_id:"bad",title:"old bad",desired_title:"🐻 old bad"}, - {outcome:"unchanged",task_id:"same",title:"same",desired_title:"same"} - ] -}; -const encoded = JSON.stringify(plan); -const cut1 = Math.floor(encoded.length / 3); -const cut2 = Math.floor(encoded.length * 2 / 3); -const trace = [], outputs = [], notices = []; -let writeCalls = 0; -const tools = { - exec_command: async args => { - trace.push("exec"); - if (args.cmd !== "\"$HOME/.local/bin/threadbear\" onboard --noninteractive --confirm --json" || - args.yield_time_ms !== 30000 || args.max_output_tokens !== 200000) throw new Error("bad exec args"); - return {session_id:77,output:encoded.slice(0,cut1)}; - }, - write_stdin: async args => { - trace.push("write:" + args.session_id); - if (args.session_id !== 77 || args.yield_time_ms !== 30000 || - args.max_output_tokens !== 200000) throw new Error("bad resume args"); - writeCalls++; - if (writeCalls === 1) return {session_id:77,output:encoded.slice(cut1,cut2)}; - if (writeCalls === 2) return {exit_code:0,output:encoded.slice(cut2)}; - throw new Error("preparation process resumed more than needed"); - }, - codex_app__read_thread: async args => { - trace.push("read:" + args.threadId); - if (Object.keys(args).sort().join(",") !== - "includeOutputs,maxOutputCharsPerItem,threadId,turnLimit" || - args.includeOutputs !== false || args.turnLimit !== 1 || - args.maxOutputCharsPerItem !== 1) throw new Error("bad read args"); - if (args.threadId === "drift") return JSON.stringify({thread:{id:"drift",title:"changed"}}); - if (args.threadId === "unreadable") return "{malformed"; - if (args.threadId === "wrongid") return JSON.stringify({thread:{id:"other",title:"old wrongid"}}); - if (args.threadId === "exact") return JSON.stringify({thread:{id:"exact",title:"old exact"}}); - if (args.threadId === "object") return {thread:{id:"object",title:"old object"}}; - if (args.threadId === "bad") return JSON.stringify({thread:{id:"bad",title:"old bad"}}); - throw new Error("unexpected read target"); - }, - codex_app__set_thread_title: async args => { - trace.push("set:" + args.threadId); - if (Object.keys(args).sort().join(",") !== "threadId,title") throw new Error("bad setter args"); - if (args.threadId === "exact" && args.title === "🐻 old exact") - return JSON.stringify({threadId:"exact",title:"🐻 old exact"}); - if (args.threadId === "object" && args.title === "🐻 old object") - return {threadId:"object",title:"🐻 old object"}; - if (args.threadId === "bad" && args.title === "🐻 old bad") return "{malformed"; - throw new Error("unexpected setter target"); - } -}; -const text = value => outputs.push(value); -const notify = value => notices.push(value); class Exit extends Error {} -const exit = () => { throw new Exit(); }; const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; -try { - await new AsyncFunction("tools","text","exit","notify",source)(tools,text,exit,notify); -} catch (error) { - if (!(error instanceof Exit)) throw error; +async function run(plan,yieldPreparation) { + const trace = [], outputs = [], notices = []; + const encoded = JSON.stringify(plan); + let commandCalls = 0, writeCalls = 0; + const tools = { + exec_command: async args => { + commandCalls++; + if (args.yield_time_ms !== 30000 || args.max_output_tokens !== 200000 || + args.sandbox_permissions !== "require_escalated") throw new Error("bad exec args"); + if (commandCalls === 1) { + trace.push("exec:prepare"); + if (args.cmd !== "\"$HOME/.local/bin/threadbear\" uninstall --prepare --noninteractive --confirm --json") + throw new Error("bad preparation command"); + if (yieldPreparation) return {session_id:77,output:encoded.slice(0,Math.floor(encoded.length/2))}; + return {exit_code:0,output:encoded}; + } + trace.push("exec:commit"); + if (args.cmd !== "\"$HOME/.local/bin/threadbear\" uninstall --commit --noninteractive --confirm --json") + throw new Error("bad commit command"); + return {exit_code:0,output:JSON.stringify({ready:true,uninstalled:true,restart_required:true})}; + }, + write_stdin: async args => { + trace.push("write:" + args.session_id); + writeCalls++; + if (!yieldPreparation || writeCalls !== 1 || args.session_id !== 77 || + args.yield_time_ms !== 30000 || args.max_output_tokens !== 200000) + throw new Error("bad resume args"); + return {exit_code:0,output:encoded.slice(Math.floor(encoded.length/2))}; + }, + codex_app__read_thread: async args => { + trace.push("read:" + args.threadId); + if (args.includeOutputs !== false || args.turnLimit !== 1 || args.maxOutputCharsPerItem !== 1) + throw new Error("bad read args"); + const item = plan.items.find(value => value.task_id === args.threadId); + if (args.threadId === "drift") return JSON.stringify({thread:{id:"drift",title:"changed"}}); + return args.threadId === "active" ? {thread:{id:args.threadId,title:item.title}} : + JSON.stringify({thread:{id:args.threadId,title:item.title}}); + }, + codex_app__set_thread_title: async args => { + trace.push("set:" + args.threadId + ":" + args.title); + if (Object.keys(args).sort().join(",") !== "threadId,title" || args.title.startsWith("🐻 ")) + throw new Error("bad setter args"); + if (args.threadId === "failset") return "{malformed"; + return args.threadId === "active" ? {threadId:args.threadId,title:args.title} : + JSON.stringify({threadId:args.threadId,title:args.title}); + } + }; + const text = value => outputs.push(typeof value === "string" ? value : JSON.stringify(value)); + const notify = value => notices.push(value); + const exit = () => { throw new Exit(); }; + try { await new AsyncFunction("tools","text","exit","notify",source)(tools,text,exit,notify); } + catch (error) { if (!(error instanceof Exit)) throw error; } + return {trace,outputs,notices,writeCalls}; } -process.stdout.write(JSON.stringify({trace,outputs,notices,writeCalls})); +const failure = await run({ready:true,plan_complete:true,read_only:false,total:4, + needs_cleanup:3,prepared:3,unchanged:1,skipped:0,items:[ + {outcome:"prepared",task_id:"exact",title:"✅ exact",desired_title:"exact"}, + {outcome:"prepared",task_id:"drift",title:"🐻 drift",desired_title:"drift"}, + {outcome:"prepared",task_id:"failset",title:"🚨 fail",desired_title:"fail"}, + {outcome:"unchanged",task_id:"plain",title:"plain"} + ]},true); +const success = await run({ready:true,plan_complete:true,read_only:false,total:4, + needs_cleanup:2,prepared:2,unchanged:1,skipped:1,items:[ + {outcome:"prepared",task_id:"history",title:"🐻 history",desired_title:"history"}, + {outcome:"unchanged",task_id:"plain",title:"plain"}, + {outcome:"skipped",task_id:"unsafe"}, + {outcome:"prepared",task_id:"active",title:"✅ active",desired_title:"active"} + ]},false); +process.stdout.write(JSON.stringify({failure,success})); `, sourceJSON) output, err := exec.Command("node", "--input-type=module", "--eval", harness).CombinedOutput() if err != nil { - t.Fatalf("execute embedded onboarding JavaScript: %v\n%s", err, output) + t.Fatalf("execute embedded uninstall JavaScript: %v\n%s", err, output) } var run struct { - Trace []string `json:"trace"` - Outputs []string `json:"outputs"` - Notices []string `json:"notices"` - WriteCalls int `json:"writeCalls"` + Failure struct { + Trace []string `json:"trace"` + Outputs []string `json:"outputs"` + Notices []string `json:"notices"` + WriteCalls int `json:"writeCalls"` + } `json:"failure"` + Success struct { + Trace []string `json:"trace"` + Outputs []string `json:"outputs"` + Notices []string `json:"notices"` + WriteCalls int `json:"writeCalls"` + } `json:"success"` } if err := json.Unmarshal(output, &run); err != nil { t.Fatalf("decode JavaScript harness output: %v\n%s", err, output) } - wantTrace := []string{ - "exec", "write:77", "write:77", - "read:drift", - "read:unreadable", - "read:wrongid", - "read:exact", "set:exact", - "read:object", "set:object", - "read:bad", "set:bad", + wantFailureTrace := []string{ + "exec:prepare", "write:77", "read:exact", "set:exact:exact", + "read:drift", "read:failset", "set:failset:fail", + } + if !reflect.DeepEqual(run.Failure.Trace, wantFailureTrace) { + t.Fatalf("unexpected failed cleanup order\n got: %v\nwant: %v", run.Failure.Trace, wantFailureTrace) + } + if run.Failure.WriteCalls != 1 || len(run.Failure.Outputs) != 1 { + t.Fatalf("failed cleanup resumptions/outputs = %d/%d", run.Failure.WriteCalls, len(run.Failure.Outputs)) + } + var failedReceipt struct { + Ready bool `json:"ready"` + Uninstalled bool `json:"uninstalled"` + CleanupComplete bool `json:"cleanup_complete"` + Updated int `json:"updated"` + Drifted int `json:"drifted"` + Unconfirmed int `json:"unconfirmed"` } - if !reflect.DeepEqual(run.Trace, wantTrace) { - t.Fatalf("unexpected managed-loop order\n got: %v\nwant: %v", run.Trace, wantTrace) + if err := json.Unmarshal([]byte(run.Failure.Outputs[0]), &failedReceipt); err != nil { + t.Fatalf("decode failed cleanup receipt: %v\n%s", err, run.Failure.Outputs[0]) } - if run.WriteCalls != 2 { - t.Fatalf("write_stdin calls = %d; want two resumptions of the same process", run.WriteCalls) + if failedReceipt.Ready || failedReceipt.Uninstalled || failedReceipt.CleanupComplete || + failedReceipt.Updated != 1 || failedReceipt.Drifted != 1 || failedReceipt.Unconfirmed != 1 { + t.Fatalf("unexpected failed cleanup receipt: %+v", failedReceipt) } - if len(run.Outputs) != 1 { - t.Fatalf("terminal outputs = %d; want one receipt", len(run.Outputs)) + if containsString(run.Failure.Trace, "exec:commit") || + run.Failure.Notices[len(run.Failure.Notices)-1] != "ThreadBear uninstall: titles 3/3" { + t.Fatalf("failed cleanup crossed teardown boundary: trace=%v notices=%v", run.Failure.Trace, run.Failure.Notices) } - var receipt struct { - Ready bool `json:"ready"` - PlanComplete bool `json:"plan_complete"` - OnboardingComplete bool `json:"onboarding_complete"` - Total int `json:"total"` - Updated int `json:"updated"` - Skipped int `json:"skipped"` - Unchanged int `json:"unchanged"` - Unconfirmed int `json:"unconfirmed"` + wantSuccessTrace := []string{"exec:prepare", "read:history", "set:history:history", + "read:active", "set:active:active", "exec:commit"} + if !reflect.DeepEqual(run.Success.Trace, wantSuccessTrace) { + t.Fatalf("unexpected successful cleanup order\n got: %v\nwant: %v", run.Success.Trace, wantSuccessTrace) } - if err := json.Unmarshal([]byte(run.Outputs[0]), &receipt); err != nil { - t.Fatalf("decode managed-loop receipt: %v\n%s", err, run.Outputs[0]) + var successReceipt struct { + Uninstalled bool `json:"uninstalled"` + TitleCleanup struct { + Updated, Unchanged, Skipped int + } `json:"title_cleanup"` } - if receipt.Ready || !receipt.PlanComplete || receipt.OnboardingComplete || - receipt.Total != 7 || receipt.Updated != 2 || receipt.Skipped != 3 || - receipt.Unchanged != 4 || receipt.Unconfirmed != 1 { - t.Fatalf("unexpected managed-loop receipt: %+v", receipt) + if len(run.Success.Outputs) != 1 || json.Unmarshal([]byte(run.Success.Outputs[0]), &successReceipt) != nil || + !successReceipt.Uninstalled || successReceipt.TitleCleanup.Updated != 2 || + successReceipt.TitleCleanup.Unchanged != 1 || successReceipt.TitleCleanup.Skipped != 1 { + t.Fatalf("unexpected successful uninstall receipt: outputs=%v receipt=%+v", run.Success.Outputs, successReceipt) } - if len(run.Notices) < 3 || run.Notices[0] != "ThreadBear onboarding: preparing" || - run.Notices[len(run.Notices)-1] != "ThreadBear onboarding: 6/6" { - t.Fatalf("unexpected progress notifications: %v", run.Notices) + if run.Success.Notices[len(run.Success.Notices)-1] != "ThreadBear uninstall: removing managed artifacts" || + run.Success.Trace[len(run.Success.Trace)-1] != "exec:commit" { + t.Fatalf("successful uninstall made work after commit: trace=%v notices=%v", run.Success.Trace, run.Success.Notices) } } diff --git a/cmd/threadbear/scan.go b/cmd/threadbear/scan.go index 7f9fa93..bfb2db0 100644 --- a/cmd/threadbear/scan.go +++ b/cmd/threadbear/scan.go @@ -7,10 +7,10 @@ import ( ) const ( - onboardingNeedsUpdate = "needs_update" - onboardingPrepared = "prepared" - onboardingUnchanged = "unchanged" - onboardingSkipped = "skipped" + cleanupNeedsUpdate = "needs_cleanup" + cleanupPrepared = "prepared" + cleanupUnchanged = "unchanged" + cleanupSkipped = "skipped" ) type indexedTask struct { @@ -30,28 +30,24 @@ type currentTitleResult struct { MaxTitleUnits int `json:"max_title_units"` } -type onboardingItem struct { +type cleanupItem struct { TaskID string `json:"task_id"` Title string `json:"title,omitempty"` - Subject string `json:"subject,omitempty"` DesiredTitle string `json:"desired_title,omitempty"` - Safe bool `json:"safe"` Outcome string `json:"outcome"` Reason string `json:"reason,omitempty"` } -type onboardingResult struct { - Ready bool `json:"ready"` - PlanComplete bool `json:"plan_complete"` - ReadOnly bool `json:"read_only"` - OnboardingComplete bool `json:"onboarding_complete"` - Total int `json:"total"` - Safe int `json:"safe"` - NeedsUpdate int `json:"needs_update"` - Prepared int `json:"prepared"` - Unchanged int `json:"unchanged"` - Skipped int `json:"skipped"` - Items []onboardingItem `json:"items"` +type cleanupResult struct { + Ready bool `json:"ready"` + PlanComplete bool `json:"plan_complete"` + ReadOnly bool `json:"read_only"` + Total int `json:"total"` + NeedsCleanup int `json:"needs_cleanup"` + Prepared int `json:"prepared"` + Unchanged int `json:"unchanged"` + Skipped int `json:"skipped"` + Items []cleanupItem `json:"items"` } func runCurrentTitle(_ context.Context, taskID, status string) (currentTitleResult, error) { @@ -72,63 +68,66 @@ func runCurrentTitle(_ context.Context, taskID, status string) (currentTitleResu return result, nil } -func runOnboarding(ctx context.Context, apply bool, activeTaskID string) (onboardingResult, error) { - if apply && !taskIDPattern.MatchString(activeTaskID) { - return onboardingResult{}, errors.New("CODEX_THREAD_ID is unavailable or invalid") +func runTitleCleanup(ctx context.Context, prepare bool, activeTaskID string) (cleanupResult, error) { + if prepare && !taskIDPattern.MatchString(activeTaskID) { + return cleanupResult{}, errors.New("CODEX_THREAD_ID is unavailable or invalid") } budget := appServerListBudget - if apply { - budget = appServerOnboardingBudget + if prepare { + budget = appServerCleanupBudget } client, err := startAppServer(ctx, budget) if err != nil { - return onboardingResult{}, err + return cleanupResult{}, err } defer client.abort() nextRequestID := 2 tasks, err := client.inventory(&nextRequestID) if err != nil { - return onboardingResult{}, err + return cleanupResult{}, err } - items := prepareOnboardingItems(tasks) - excludeActiveOnboardingTask(items, activeTaskID) - result := summarizeOnboarding(items, !apply) + items := prepareTitleCleanupItems(tasks) + if prepare { + moveActiveTaskLast(items, activeTaskID) + } + result := summarizeTitleCleanup(items, !prepare) result.Ready, result.PlanComplete = true, true - if !apply { + if !prepare { client.close() return result, nil } for index := range items { item := &items[index] - if !item.Safe || item.TaskID == activeTaskID || item.Outcome != onboardingNeedsUpdate { + if item.Outcome != cleanupNeedsUpdate { continue } - item.Outcome = onboardingPrepared - item.Reason = "app-native title write required" + item.Outcome = cleanupPrepared + item.Reason = "app-native title removal required" } client.close() - result = summarizeOnboarding(items, false) + result = summarizeTitleCleanup(items, false) result.Ready, result.PlanComplete = true, true return result, nil } -func excludeActiveOnboardingTask(items []onboardingItem, activeTaskID string) { +func moveActiveTaskLast(items []cleanupItem, activeTaskID string) { if activeTaskID == "" { return } for index := range items { if items[index].TaskID == activeTaskID { - items[index].Outcome = onboardingUnchanged - items[index].Reason = "active task is handled by the terminal title writer" + active := items[index] + copy(items[index:], items[index+1:]) + items[len(items)-1] = active return } } } -func prepareOnboardingItems(tasks []indexedTask) []onboardingItem { - items := make([]onboardingItem, 0, len(tasks)) +func prepareTitleCleanupItems(tasks []indexedTask) []cleanupItem { + items := make([]cleanupItem, 0, len(tasks)) for _, task := range tasks { - item := onboardingItem{TaskID: task.ID, Outcome: onboardingSkipped} + item := cleanupItem{TaskID: task.ID, Outcome: cleanupSkipped} if task.RawFallback { item.Reason = "native task name is blank; task is raw or unowned" items = append(items, item) @@ -141,40 +140,31 @@ func prepareOnboardingItems(tasks []indexedTask) []onboardingItem { continue } if decorated { - item.Title, item.Subject, item.DesiredTitle = task.Title, subject, task.Title - item.Safe, item.Outcome, item.Reason = true, onboardingUnchanged, "already decorated" + item.Title, item.DesiredTitle = task.Title, subject + item.Outcome = cleanupNeedsUpdate items = append(items, item) continue } - item.Title, item.Subject, item.DesiredTitle = task.Title, subject, "🐻 "+subject - item.Safe, item.Outcome = true, onboardingNeedsUpdate + item.Title, item.Outcome, item.Reason = task.Title, cleanupUnchanged, "no ThreadBear prefix" items = append(items, item) } return items } -func summarizeOnboarding(items []onboardingItem, readOnly bool) onboardingResult { - result := onboardingResult{ReadOnly: readOnly, Total: len(items), Items: items} +func summarizeTitleCleanup(items []cleanupItem, readOnly bool) cleanupResult { + result := cleanupResult{ReadOnly: readOnly, Total: len(items), Items: items} for _, item := range items { - if item.Safe { - result.Safe++ - if item.Outcome == onboardingNeedsUpdate || item.Outcome == onboardingPrepared { - result.NeedsUpdate++ - } + if item.Outcome == cleanupNeedsUpdate || item.Outcome == cleanupPrepared { + result.NeedsCleanup++ } switch item.Outcome { - case onboardingPrepared: + case cleanupPrepared: result.Prepared++ - case onboardingUnchanged: + case cleanupUnchanged: result.Unchanged++ - case onboardingSkipped: + case cleanupSkipped: result.Skipped++ } } - if readOnly { - result.OnboardingComplete = result.NeedsUpdate == 0 - } else { - result.OnboardingComplete = result.Prepared == 0 - } return result } diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index e798624..c419dd2 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -46,7 +46,7 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { "For every lifecycle action, write the lasting summary after all tool calls.", "Nothing changes in this step.", "Existing task titles will not change in this step.", - "onboarding stays a separate previewed choice.", + "Existing task titles were not changed.", "Never leave that recap only in commentary, progress notices, notifications, or raw tool output", "do not copy raw fields or list internal files", "Group safe skips as “left unchanged” unless the user needs to act.", @@ -54,24 +54,19 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { "## Here's what will happen", "## ThreadBear recap 🐻", "Other Codex settings and files stay untouched.", - "Existing tasks have not been changed yet", - "Checked N existing tasks: updated X, left Y unchanged, and could not confirm Z.", + "Existing task titles and unrelated Codex settings stayed untouched.", "installs only verified official releases", "Updates never read tasks or change titles.", - "ThreadBear and its automatic updates were removed.", + "ThreadBear and its automatic updates were removed after cleaning X task titles.", "--dry-run --json", "--noninteractive --confirm --json", - "--no-onboard", - "ThreadBear onboard", - "entire unarchived App Server catalog before any preparation or title write", - "fresh complete catalog snapshot", - "returns one `prepared` action containing the snapshot title and desired title", - "tools.write_stdin", - "tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})", - "A missing, unreadable, wrong-ID, or changed-title response is skipped.", - "tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})", - "Every prepared item must reach exactly one outcome.", - "Checked N existing tasks: updated X, left Y unchanged, and could not confirm Z.", + "uninstall --prepare --noninteractive --confirm --json", + "uninstall --commit --noninteractive --confirm --json", + "A bare confirmed uninstall is refused.", + "one fresh complete plan", + "with the initiating task last", + "Any missing, drifted, malformed, wrong-target, wrong-title, or thrown result blocks teardown", + "There is no final catalog scan, marker, queue, controller, or resume state.", "tools.codex_app__set_thread_title({title:desired})", "one injection-safe terminal JavaScript cell", "wait only for that same cell", @@ -102,7 +97,9 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { "migration_failed", "background migration-controller", "Luna helper", - "uninstall --prepare", + "ThreadBear onboard", + "--no-onboard", + "Existing title icons may remain", "state_N.sqlite", "rereads every candidate", `"$codex_path" --version; break`, @@ -148,6 +145,7 @@ func TestInstalledGuidanceDefinesOneTerminalPlannerAndNativeWrite(t *testing.T) "Never start another cell, poll the title, retry, or reconcile.", "A returned failure is local to this turn.", "The status controls only the visible icon.", + "ThreadBear emits five exact status prefixes and recognizes the obsolete neutral bear prefix only so it can remove it.", ) if count := strings.Count(guidance, "```js"); count != 1 { t.Fatalf("managed guidance contains %d JavaScript cells; want one", count) @@ -168,6 +166,7 @@ func TestInstalledGuidanceDefinesOneTerminalPlannerAndNativeWrite(t *testing.T) "ThreadBear footer", "maintenance --cancel", "prepared uninstall", + "six exact current icon prefixes", ) } @@ -185,9 +184,9 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { "## Install or reset", "helper, instructions, skill, and daily updates", "leave tasks, settings, and titles alone", - "## Onboard existing tasks", - "onboard --dry-run --json", - `\"$HOME/.local/bin/threadbear\" onboard --noninteractive --confirm --json`, + "## Uninstall", + "uninstall --dry-run --json", + `\"$HOME/.local/bin/threadbear\" uninstall --prepare --noninteractive --confirm --json`, `sandbox_permissions:"require_escalated"`, "This preview changes nothing.", "If Codex says approval requests are disabled, stop.", @@ -196,7 +195,7 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { `typeof item.title !== "string"`, "for (const item of prepared)", "tools.write_stdin({", - "session_id:local.session_id", + "session_id:call.session_id", "tools.codex_app__read_thread({threadId:item.task_id", "includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1", "const parseNative = value =>", @@ -209,22 +208,18 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { "title:item.desired_title", "renamed.threadId === item.task_id", "renamed.title === item.desired_title", - "notify(`ThreadBear onboarding: ${done}/${prepared.length}`)", - "const accounted = updated + skipped + unconfirmed === prepared.length", - "ready:accounted && unconfirmed === 0", - "onboarding_complete:accounted && unconfirmed === 0", - "unchanged:plan.total - updated - unconfirmed", - "Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.", - "No retry, cap, or persistent task.", + "notify(`ThreadBear uninstall: titles ${done}/${prepared.length}`)", + "const accounted = updated + drifted + unconfirmed === prepared.length", + "if (!accounted || drifted !== 0 || unconfirmed !== 0)", + "cleanup_complete:false", + "safe_rerun:\"threadbear uninstall --dry-run --json\"", "## Update", "Preview download, checks, replacement, and restart.", - "## Uninstall", - "uninstall --dry-run --json", - "uninstall --noninteractive --confirm --json", + "uninstall --commit --noninteractive --confirm --json", "Only `uninstalled:true` means removed", - "keep tasks, settings, and files; icons may remain.", - "no title cell.", - "Recap exactly: “ThreadBear was removed.", + "Never retry a drifted or unconfirmed title in the same pass.", + "After artifact commit, make no title call.", + "ThreadBear was removed. X task titles were cleaned", ) rejectText(t, protocol, "this title stayed as-is") if count := strings.Count(protocol, "tools.codex_app__set_thread_title("); count != 1 { @@ -249,7 +244,8 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { "Migration controller", "migration --phase", "maintenance --cancel", - "uninstall --prepare", + "onboard --dry-run", + "icons may remain", ) } @@ -265,11 +261,11 @@ func TestLifecycleCopyLeavesADurableFriendlyRecap(t *testing.T) { } } - if count := strings.Count(guide, "## Here's what will happen"); count < 4 { - t.Fatalf("install guide has %d lifecycle previews; want install, onboarding, update, and uninstall guidance", count) + if count := strings.Count(guide, "## Here's what will happen"); count < 3 { + t.Fatalf("install guide has %d lifecycle previews; want install, update, and uninstall guidance", count) } - if count := strings.Count(guide, "## ThreadBear recap 🐻"); count < 4 { - t.Fatalf("install guide has %d durable recaps; want universal plus lifecycle results", count) + if count := strings.Count(guide, "## ThreadBear recap 🐻"); count < 3 { + t.Fatalf("install guide has %d durable recaps; want install, update, and uninstall results", count) } requireText(t, guide, "end the final response", @@ -324,10 +320,10 @@ func TestHomepageDescribesOnlyShippedCapabilities(t *testing.T) { "One terminal update", "Codex reads the title, then makes at most one native title write", "The mounted app owns titles", - "Onboarding finishes App Server pagination before serial app-native writes", - "no arbitrary first-50 cap", - "immediately rereads each prepared task through the mounted app", - "skips drift", + "A clean goodbye", + "Uninstall fully plans owned-prefix cleanup before serial app-native writes", + "immediately rereads each prepared task through the mounted app before one possible prefix removal", + "Drift or an unconfirmed result stops before artifact removal.", "null or blank name", "preview is never adopted", "daily update-only LaunchAgent", @@ -336,6 +332,8 @@ func TestHomepageDescribesOnlyShippedCapabilities(t *testing.T) { "title-core readiness", ) rejectText(t, page, + "Five outcomes, plus a welcome bear", + "aria-label=\"blocked, needs input, automation, next steps, complete, onboarded\"", "One direct writer", "writes at most once, and verifies exact readback", "only task read/write authority", diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index 6d4b5d2..96656c1 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -24,7 +24,8 @@ var statusIcons = map[string]string{ "automation": "🤖", } -// These exact visible prefixes are reserved for current ThreadBear titles. +// These exact visible prefixes are removable ThreadBear title decorations. +// The bear is legacy-only: current titles can strip it, but never render it. // Other leading emoji remain user text unless they match an ambiguous old // ThreadBear rendering below, which cannot be distinguished safely. var ownedTitlePrefixes = []string{"✅ ", "➡️ ", "🙋 ", "🚨 ", "🤖 ", "🐻 "} @@ -93,8 +94,10 @@ func validateSubject(subject string) error { return errors.New("subject is a raw internal envelope") } } - if len(utf16.Encode([]rune("🐻 "+subject))) > maxTitleUnits { - return errors.New("subject does not fit without truncation") + for _, icon := range statusIcons { + if len(utf16.Encode([]rune(icon+" "+subject))) > maxTitleUnits { + return errors.New("subject does not fit without truncation") + } } return nil } diff --git a/cmd/threadbear/state_test.go b/cmd/threadbear/state_test.go index d582f4a..f8b1ee1 100644 --- a/cmd/threadbear/state_test.go +++ b/cmd/threadbear/state_test.go @@ -31,7 +31,7 @@ func TestSubjectFromTitleUsesFiniteVisiblePrefixes(t *testing.T) { func TestSubjectFromTitleRejectsAmbiguousAndInternalText(t *testing.T) { for _, title := range []string{ - "🧵🐻 needs input (you): approve onboarding", + "🧵🐻 needs input (you): approve cleanup", "⏳ ThreadBear is working", "❔ old prompt", "private", @@ -64,7 +64,7 @@ func TestRenderTitlePreservesSubjectAndNeverTruncates(t *testing.T) { } } -func TestTitlePolicyCoversEveryStatusAndNeutralOnboarding(t *testing.T) { +func TestTitlePolicyCoversEveryStatusAndLegacyBearCleanup(t *testing.T) { for status, icon := range statusIcons { if got, err := renderTitle(status, "subject"); err != nil || got != icon+" subject" { t.Errorf("render %s = %q, %v", status, got, err) @@ -74,7 +74,12 @@ func TestTitlePolicyCoversEveryStatusAndNeutralOnboarding(t *testing.T) { } } if !containsString(ownedTitlePrefixes, "🐻 ") { - t.Fatal("owned prefixes omit onboarding bear") + t.Fatal("owned prefixes omit legacy bear cleanup") + } + for _, icon := range statusIcons { + if icon == "🐻" { + t.Fatal("neutral bear remains a writable status") + } } } diff --git a/cmd/threadbear/update.go b/cmd/threadbear/update.go index e6963aa..8f929a6 100644 --- a/cmd/threadbear/update.go +++ b/cmd/threadbear/update.go @@ -158,7 +158,7 @@ func update(ctx context.Context, automatic bool) (result any, returnErr error) { return nil, updateFailure("candidate_self_test", err) } receipt.RestartRequired = true - candidateInstall, err := candidateResult(ctx, candidate, updateInstallTimeout, "install", "", "install", "--automatic", "--no-onboard", "--noninteractive", "--confirm", "--json") + candidateInstall, err := candidateResult(ctx, candidate, updateInstallTimeout, "install", "", "install", "--automatic", "--noninteractive", "--confirm", "--json") if err != nil { if candidateInstall != nil { candidateInstall["install_stage"] = candidateInstall["stage"] diff --git a/cmd/threadbear/update_test.go b/cmd/threadbear/update_test.go index d2821d2..ded431e 100644 --- a/cmd/threadbear/update_test.go +++ b/cmd/threadbear/update_test.go @@ -386,12 +386,10 @@ func candidateScript(candidateVersion, selfTestMode string, installFailure, stru selfTest = "sleep 1" } install := `automatic=false -no_onboard=false for argument in "$@"; do [ "$argument" = "--automatic" ] && automatic=true - [ "$argument" = "--no-onboard" ] && no_onboard=true done -[ "$automatic" = true ] && [ "$no_onboard" = true ] || { echo missing-automatic-core-only-flags >&2; exit 10; } +[ "$automatic" = true ] || { echo missing-automatic-flag >&2; exit 10; } cp "$TB_UPDATE_TEST_SKILL_SOURCE" "$TB_UPDATE_TEST_SKILL_TARGET" printf '{"ready":true,"installed":true}\n'` if installFailure { diff --git a/docs/README.md b/docs/README.md index 7bbde14..6e4e81a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,9 @@ # ThreadBear documentation - `README.md` — product and public CLI -- `docs/architecture.md` — stateless title handling, app-native writing, onboarding, and updates +- `docs/architecture.md` — stateless title handling, app-native writing, uninstall cleanup, and updates - `docs/status-convention.md` — status enum and icon mapping -- `docs/compatibility.md` — supported Codex/macOS, default permissions, onboarding App Server, and app-native titles +- `docs/compatibility.md` — supported Codex/macOS, default permissions, uninstall App Server, and app-native titles - `docs/live-eval.md` — focused exact-candidate release proof - `docs/experiments/README.md` — title-mechanism registry and experiment preflight - `docs/release-checklist.md` — local, release, and hosted checks diff --git a/docs/architecture.md b/docs/architecture.md index 22a69a6..e4ddaa0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,28 +8,30 @@ ThreadBear is one Go executable, one managed instruction block, one installed sk 2. Immediately before the final response, managed guidance runs one terminal JavaScript cell containing `threadbear title --status --json`. 3. That stateless command validates the status and current task ID, then returns the fixed icon and safety policy. It starts no App Server and writes no state. 4. The mounted Codex app reads the exact calling task. The cell rejects a wrong ID, blank or unsafe title, raw internal text, an ambiguous old ThreadBear prefix, or a title that cannot fit intact. -5. The cell strips at most one of ThreadBear's six reserved leading icon prefixes, preserves every other subject byte, and renders the selected icon. If the title already matches, it stops. Otherwise it calls mounted `set_thread_title` once with no explicit task ID. +5. The cell strips at most one of ThreadBear's five current status prefixes or the obsolete neutral bear prefix, preserves every other subject byte, and renders the selected status icon. If the title already matches, it stops. Otherwise it calls mounted `set_thread_title` once with no explicit task ID. 6. Success requires the exact returned task ID and title. A throw, malformed response, or mismatch stays local. The task never starts another cell, polls the title, retries, or reconciles. -The enum controls only the icon. ThreadBear reserves `✅ `, `➡️ `, `🙋 `, `🚨 `, `🤖 `, and neutral onboarding `🐻 ` as its visible ownership boundary. A title beginning with one of those current prefixes is deliberately ambiguous. The obsolete `➡ `, `⏳ `, `❔ `, and `🧵🐻` forms are also ambiguous after a clean v2 reset, so ThreadBear leaves the complete title unchanged rather than guessing whether its leading emoji is user-authored. Every other leading emoji remains user text. +The enum controls only the icon and can emit exactly `✅ `, `➡️ `, `🙋 `, `🚨 `, or `🤖 `. ThreadBear also recognizes neutral `🐻 ` as a removable legacy prefix but never emits it. A title beginning with one of those exact prefixes is deliberately ambiguous. The obsolete `➡ `, `⏳ `, `❔ `, and `🧵🐻` forms are also ambiguous after a clean v2 reset, so ThreadBear leaves the complete title unchanged rather than guessing whether its leading emoji is user-authored. Every other leading emoji remains user text. -Codex has no compare-and-swap title primitive. The mounted read and possible write remain in one terminal cell, and onboarding rereads each historical target immediately before its one possible write. If live canaries show practical corruption or response blocking, rewriting is disabled instead of wrapped in reconciliation machinery. +Codex has no compare-and-swap title primitive. The mounted read and possible write remain in one terminal cell, and uninstall cleanup rereads each historical target immediately before its one possible write. If live canaries show practical corruption or response blocking, rewriting is disabled instead of wrapped in reconciliation machinery. ## Native boundaries -Mounted Codex tools are the only ordinary title reader and the sole title writer. Current-task writes omit `threadId`; onboarding writes carry one explicit target. Tool results normally arrive as raw JSON text, so managed cells decode one layer while retaining object compatibility. Exact returned ID/title is the acknowledgement; release acceptance still verifies the mounted header and sidebar. +Mounted Codex tools are the only ordinary title reader and the sole title writer. Current-task writes omit `threadId`; uninstall cleanup writes carry one explicit target. Tool results normally arrive as raw JSON text, so managed cells decode one layer while retaining object compatibility. Exact returned ID/title is the acknowledgement; release acceptance still verifies the mounted header and sidebar. -The official `codex app-server --stdio` process is used only for complete-catalog onboarding. ThreadBear launches it from a fixed Codex Desktop path, never ambient repository `PATH`, initializes one bounded client, follows every unarchived `thread/list` page, tolerates notifications, deduplicates IDs, and closes it. Native `name` is the user-facing title. Null or blank names stay raw; `preview` is never adopted. +The official `codex app-server --stdio` process is used only for complete-catalog uninstall cleanup. ThreadBear launches it from a fixed Codex Desktop path, never ambient repository `PATH`, initializes one bounded client, follows every unarchived `thread/list` page, tolerates notifications, deduplicates IDs, and closes it. Native `name` is the user-facing title. Null or blank names stay raw; `preview` is never adopted. -Ordinary turns therefore work under Codex's default workspace permissions. Onboarding asks for one explicit command permission because App Server maintains Codex's own local state outside the workspace. ThreadBear never opens Codex SQLite, edits Desktop caches, runs an App Server daemon, keeps a shared client, uses a model, or falls back to another title source. +Ordinary turns therefore work under Codex's default workspace permissions. Uninstall cleanup asks for one explicit command permission because App Server maintains Codex's own local state outside the workspace. ThreadBear never opens Codex SQLite, edits Desktop caches, runs an App Server daemon, keeps a shared client, uses a model, or falls back to another title source. -## Onboarding +## Uninstall title cleanup -`onboard --dry-run --json` enumerates the complete catalog without ThreadBear mutation and reports `total`, `safe`, `needs_update`, and per-item reasons. Enumeration or protocol failure means zero title calls. +`uninstall --dry-run --json` enumerates the complete catalog without mutation and reports `total`, `needs_cleanup`, `unchanged`, `skipped`, and per-item reasons alongside the artifact preview. Enumeration or protocol failure means zero title or filesystem writes. -After separate consent, `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, skips the active caller and unsafe rows, and returns one `prepared` action with snapshot `title` and `desired_title` for every eligible target. It stores no titles, performs no per-target App Server read, and writes no Codex title. +After separate consent, `uninstall --prepare --noninteractive --confirm --json` takes one fresh complete snapshot and returns one `prepared` action with snapshot `title` and undecorated `desired_title` for every safe owned prefix. The initiating task is last. Preparation stores no titles, performs no per-target App Server read, and writes no Codex title. -The installed skill resumes the same preparation process if it yields. It then serially reads each prepared target through the mounted app immediately before any write. A missing task, wrong ID, or drift is `skipped`. An exact match receives at most one setter call. Only exact returned target ID/title counts as `updated`; every other setter result is `unconfirmed` and is never retried. A rerun takes a fresh complete snapshot—there is no controller, wave, queue, or resume state. +The installed skill resumes the same preparation process if it yields. It then serially reads each prepared target through the mounted app immediately before any write. A missing task, wrong ID, or drift blocks teardown. An exact match receives at most one setter call. Only exact returned target ID/title counts as `updated`; every other setter result is `unconfirmed` and also blocks teardown. If every prepared row succeeds, the same cell runs `uninstall --commit --noninteractive --confirm --json`. A bare confirmed uninstall is refused, so the ordinary CLI path cannot skip preparation. A failure gets one fresh-rerun action—there is no retry, final inventory scan, controller, queue, marker, or resume state. + +The installed skill is the trusted local lifecycle orchestrator; `--commit` is its explicit internal phase attestation, not a security boundary against deliberate local CLI misuse. Cleanup guarantees exact handling of the prepared snapshot, not a freeze against later user or concurrent Codex title writes. Process handshakes, persisted proof, and cross-task writer coordination are outside this lifecycle. ## Installation, reset, updates, and uninstall @@ -39,4 +41,4 @@ Version 2.2.1 uses an explicit clean reset. The guide verifies and removes only The daily LaunchAgent runs only `threadbear update`. It validates origin, architecture, checksum, version, Codex compatibility, and candidate self-test before replacement. Network and verification failures leave the old install untouched; later local failures report a rerunnable partial, with the binary written last. The updater never reads tasks or changes titles. -Uninstall removes the executable, lifecycle/update state, managed guidance, skill, and LaunchAgent without waiting for titles to converge. It also removes any obsolete v3.0.0 subject records it owns while preserving neighbors. Historical icons may remain. Once removal commits, the task asks for a Codex restart and does not run the title cell. +Uninstall first removes one current owned prefix from every prepared safe unarchived title through the mounted writer. Plain, unsafe, ambiguous, user-owned, and archived titles remain unchanged. Only after every prepared write returns the exact target and title does it remove the executable, lifecycle/update state, managed guidance, skill, and LaunchAgent, preserving neighbors and removing the binary last. Once removal commits, the task asks for a Codex restart and makes no title call. diff --git a/docs/benchmark.md b/docs/benchmark.md index c2645c3..f11fb65 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -1,11 +1,11 @@ # Benchmark -Run the complete read-only local onboarding inventory with: +Run the complete read-only local uninstall-cleanup inventory with: ```sh -threadbear onboard --dry-run --json +threadbear uninstall --dry-run --json ``` -Report App Server page count, elapsed time, total deduplicated unarchived tasks, safe candidates, needed updates, and unchanged tasks by reason. Exercise more than 100 tasks so at least two `thread/list` pages are required. Assert that enumeration applies no arbitrary page or item cap, source-label filter, task mutation, model call, or SQLite access. Include null and blank `name` rows with plausible `preview` text and prove both remain raw and unowned. +Report App Server page count, elapsed time, total deduplicated unarchived tasks, titles needing cleanup, unchanged titles, and skipped titles by reason. Exercise more than 100 tasks so at least two `thread/list` pages are required. Assert that enumeration applies no arbitrary page or item cap, source-label filter, task mutation, model call, SQLite access, or artifact removal. Include null and blank `name` rows with plausible `preview` text and prove both remain raw and unowned. Separately benchmark confirmed preparation and the serial mounted app-native pass. Report prepared, updated, unchanged, skipped, and unconfirmed counts. Performance is informative; correctness, exact native responses, and complete accounting are acceptance gates. diff --git a/docs/compatibility.md b/docs/compatibility.md index 5ffea31..ee027a1 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -6,10 +6,10 @@ ThreadBear resolves Codex only from fixed Desktop locations: the system or user For an ordinary turn, the local `title` command returns only the validated task ID and fixed title policy. The mounted app reads the exact current task and, if needed, writes once with no explicit target ID. Raw JSON-text results are decoded once; already-decoded objects are also accepted. A wrong ID, unsafe title, throw, undecodable response, or non-exact setter result stays local with no alternate reader, writer, or retry. -Ordinary title handling starts no App Server and writes no ThreadBear state, so it works under Codex's default workspace permissions. The six exact current ThreadBear icon prefixes are reserved. Other safe leading emoji and subject bytes are preserved, while ambiguous old ThreadBear prefixes are deliberately left unchanged rather than guessed. Visible titles are at most 60 UTF-16 units and are never truncated. +Ordinary title handling starts no App Server and writes no ThreadBear state, so it works under Codex's default workspace permissions. ThreadBear emits five exact status prefixes and recognizes neutral `🐻 ` only as removable legacy decoration. Other safe leading emoji and subject bytes are preserved, while ambiguous old ThreadBear prefixes are deliberately left unchanged rather than guessed. Visible titles are at most 60 UTF-16 units and are never truncated. -Historical onboarding explicitly asks for command permission, then follows the complete unarchived App Server `thread/list` catalog, tolerates notifications, and deduplicates IDs. Null or blank `name` stays raw regardless of `preview`. A later-page failure returns no partial plan. Confirmed preparation stores no subjects and writes no titles. The installed skill serially rereads each prepared target through the mounted app immediately before its one possible explicit-target setter call; drift or wrong IDs are skipped without retry. +Uninstall cleanup explicitly asks for command permission, then follows the complete unarchived App Server `thread/list` catalog, tolerates notifications, and deduplicates IDs. Null or blank `name` stays raw regardless of `preview`. A later-page failure returns no partial plan. Confirmed preparation stores no titles and writes no titles. The installed skill serially rereads each prepared target through the mounted app immediately before its one possible explicit-target setter call; drift, wrong IDs, or non-exact setter results block artifact teardown and receive no retry. ThreadBear never opens Codex SQLite or edits Desktop storage. It runs no App Server daemon or proxy, keeps no task-title database or App Server cache, uses no model, and has no controller, queue, reconciliation, or alternate path. -The supported public commands are `install`, `title`, `onboard`, `status`, `self-test`, `update`, `uninstall`, and `version`. The daily update-only LaunchAgent needs no `sudo`, Full Disk Access, model call, or persistent Codex task. Release binaries are checksum-verified but are not Developer ID signed or notarized. +The supported public commands are `install`, `title`, `status`, `self-test`, `update`, `uninstall`, and `version`. The daily update-only LaunchAgent needs no `sudo`, Full Disk Access, model call, or persistent Codex task. Release binaries are checksum-verified but are not Developer ID signed or notarized. diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index 61259c6..7950181 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -76,7 +76,7 @@ "TB-EXP-0116-006" ], "contradicted_by": [], - "decision": "TB-EXP-0116-003 established the mounted-writer path, TB-EXP-0116-004 repeated it against corrected candidate b5cc0cbe, and TB-EXP-0116-006 closes the final BEAR-117 artifact gate. Exact candidate a1ecec67 repainted the current and controlled historical rows immediately through the mounted writer, preserved both exact titles across a clean Desktop restart, and kept the friendly preview and committed-removal recap visible after reopen and restart.", + "decision": "TB-EXP-0116-003 established the mounted-writer path, TB-EXP-0116-004 repeated it against corrected candidate b5cc0cbe, and TB-EXP-0116-006 closes the final BEAR-117 artifact gate. Exact candidate a1ecec67 repainted the current and controlled historical rows immediately through the mounted writer, preserved both exact titles across a clean Desktop restart, and kept the friendly preview and committed-removal recap visible after reopen and restart. TB-EXP-0119-001 and TB-EXP-0119-002 validate the cleanup and corrected commit-admission seams only; neither adds rendered repaint or restart-persistence evidence.", "next_preflight": null }, { @@ -1254,6 +1254,89 @@ "TB-EXP-0120-004" ], "conflicts": [] + }, + { + "id": "TB-EXP-0119-001", + "date": "2026-08-08", + "issue": "BEAR-119", + "preflight_id": "TB-PRE-0119-001", + "question": "Does exact BEAR-119 candidate a051dccc remove one obsolete bear or current status prefix from every prepared live task through the mounted native writer, process the initiating task last, and only then remove ThreadBear-owned artifacts?", + "invariant": "One fresh complete plan must account for the entire unarchived catalog; each prepared row may receive one immediate exact read and at most one setter; any drift or non-exact response must block teardown; an all-exact pass must use the existing artifact commit without a final task scan or post-commit title call.", + "environment": { + "threadbear_version": "3.0.2 exact BEAR-119 local candidate; arm64 CGO=0 binary SHA-256 a051dccc01a682b114cc93e84093af28b76f80031d2e8d665e37c779863ddcea", + "git_sha": "42b8ac3a5c5fb874f809931d15e9cf4acb4861c9", + "codex_version": "Desktop 26.803.41515 build 6321; embedded CLI 0.147.0-alpha.6.5", + "codex_source": "vscode", + "host": "local macOS arm64", + "task_state": "362 unarchived native rows: 307 exact owned-prefix cleanup targets, 30 plain unchanged rows, and 25 skipped unsafe or raw rows; one established historical control and the initiating task were exact ordering controls", + "restart_state": "no restart or refresh; the declared probe stopped after committed teardown and artifact-only verification", + "hook_fingerprint": "hooks.json SHA-256 43d2619e3510054cc165f9b1f7915e16c20a162fa4427d2ed0b20294141ddf04 before cleanup and byte-identical after teardown; no ThreadBear title hook participated", + "guidance_fingerprint": "managed guidance SHA-256 9b79ed5149d430ae57c617221d82297e94bedc47249924a62ada876cb775c940; installed skill SHA-256 f967e5762b4b0c2081d38b404dd584cbd881e44f03404de77c85a636e85d8aa5" + }, + "invocation": { + "outer_tool": "exact candidate install and complete App Server preview/preparation, followed by one functions.exec serial mounted-native cleanup cell, the existing confirmed artifact commit, and artifact-only shell verification", + "code": "preview the complete catalog without mutation; require exact 362/307/30/25 counts; take one fresh prepared plan; require the initiating task as the last prepared row; for every prepared row immediately reread exact ID/title and make at most one explicit-target setter call to the undecorated title; stop on any drift or unconfirmed result; after 307 exact acknowledgements run confirmed uninstall once; verify owned artifact absence and unrelated hashes without another task read", + "native_tool_identity": "codex_app__read_thread immediately before codex_app__set_thread_title as the sole title writer; App Server thread/list only inside the exact candidate planner", + "target_identity_mode": "authoritative explicit task IDs from one complete prepared plan; exact snapshot ID/title guard before every write; initiating task placed last by the candidate planner" + }, + "evidence": [ + "linear:BEAR-119", + "git:42b8ac3a5c5fb874f809931d15e9cf4acb4861c9", + "codex-rollout:019fe38c-0689-7892-8b56-b6ee3251e141" + ], + "result": { + "status": "observed", + "summary": "The read-only preview accounted for all 362 unarchived rows with 307 exact owned-prefix targets, 30 plain unchanged rows, and 25 skipped rows. One fresh plan prepared all 307 targets and placed the initiating task last. The serial mounted pass returned 307 exact setter acknowledgements with zero drift and zero unconfirmed results, including the historical bear control before the initiating status-icon control. Confirmed teardown then returned uninstalled true. The binary, skill, managed guidance, private state, plist, and loaded updater were absent; hooks.json and config.toml remained byte-identical to their recorded pre-teardown hashes.", + "timing_ms": "unknown: the serial mounted pass reported every 25 outcomes and completed in one uninterrupted cell, but the outer wall timer was not recorded separately", + "hook_participation": "none; every title mutation used the mounted native setter directly and hooks.json remained byte-identical", + "rendered_proof": "No visual inspection or refresh was used. Every one of the 307 mounted setter results returned the exact target ID and undecorated title; the exact historical control was processed before the exact initiating control, which was the final title call before teardown." + }, + "confidence": "high", + "applicability": "Closes TB-PRE-0119-001 and validates the focused BEAR-119 uninstall-cleanup seam for exact candidate a051dccc on Desktop 26.803.41515: complete preview, one fresh prepared plan, serial exact removal, initiating task last, zero retry or final scan, and existing artifact teardown only after every write was confirmed. It does not add rendered repaint or restart-persistence evidence to TB-CAP-MOUNTED-NATIVE-REPAINT.", + "supersedes": [], + "conflicts": [] + }, + { + "id": "TB-EXP-0119-002", + "date": "2026-08-08", + "issue": "BEAR-119", + "preflight_id": "TB-PRE-0119-002", + "question": "Does corrected exact candidate 5f05b9f refuse the old bare confirmed uninstall before mutation, then retain the one-plan serial cleanup path and admit artifact removal only through explicit commit?", + "invariant": "Bare confirmed uninstall must not remove any artifact; one fresh plan and exact mounted acknowledgements must precede the explicit artifact commit; no final title scan or post-commit title call is allowed.", + "environment": { + "threadbear_version": "3.0.2 exact BEAR-119 corrected local candidate; arm64 CGO=0 binary SHA-256 5f05b9f872ca101f545feaca8df399d8752342fe0c7f7365e3b25da16ad0f864", + "git_sha": "9720af45539b41e060fddc47db36f726055c15a8", + "codex_version": "Desktop 26.803.41515 build 6321; embedded CLI 0.147.0-alpha.6.5", + "codex_source": "vscode", + "host": "local macOS arm64", + "task_state": "362 unarchived native rows: two controlled exact owned-prefix targets, 332 plain unchanged rows, and 28 skipped unsafe or raw rows; the historical control preceded the initiating control", + "restart_state": "no restart or refresh; the declared probe stopped after committed teardown and artifact-only verification", + "hook_fingerprint": "hooks.json SHA-256 43d2619e3510054cc165f9b1f7915e16c20a162fa4427d2ed0b20294141ddf04 before cleanup and byte-identical after teardown; no ThreadBear title hook participated", + "guidance_fingerprint": "managed guidance SHA-256 9b79ed5149d430ae57c617221d82297e94bedc47249924a62ada876cb775c940; installed skill SHA-256 1f53625729efe34267c10bf043fe8047f6101f69d3b4945c84f510d09c545bfd" + }, + "invocation": { + "outer_tool": "exact candidate install, bare-confirm refusal, complete App Server preview/preparation, one functions.exec serial mounted-native cleanup cell, explicit artifact commit, and artifact-only shell verification", + "code": "install candidate 5f05b9f; require bare uninstall --noninteractive --confirm --json to fail while status remains ready; seed one historical bear and one initiating status control; preview 362/2/332/28; take one fresh prepared plan; require historical then initiating order; immediately reread and exactly clean both; run uninstall --commit --noninteractive --confirm --json; verify owned artifact absence and unchanged hooks/config hashes without another task read", + "native_tool_identity": "codex_app__read_thread immediately before codex_app__set_thread_title as the sole title writer; App Server thread/list only inside the exact candidate planner", + "target_identity_mode": "authoritative explicit task IDs from one complete prepared plan; exact snapshot ID/title guard before both writes; initiating task last" + }, + "evidence": [ + "linear:BEAR-119", + "github-pr:126", + "git:9720af45539b41e060fddc47db36f726055c15a8", + "codex-rollout:019fe38c-0689-7892-8b56-b6ee3251e141" + ], + "result": { + "status": "observed", + "summary": "Bare confirmed uninstall failed with the explicit phase error and the installed candidate remained ready with its updater loaded. The complete preview accounted for 362 rows as two exact cleanup targets, 332 unchanged rows, and 28 skipped rows. One fresh plan placed the initiating task last. The serial cell returned two exact setter acknowledgements with zero drift and zero unconfirmed results, then explicit commit returned uninstalled true and listed artifact changes only. The binary, skill, managed guidance, private state, plist, and loaded updater were absent; hooks.json and config.toml remained byte-identical.", + "timing_ms": "approximately 7600 for the one preparation, two serial mounted read/write pairs, and explicit commit cell", + "hook_participation": "none; both title mutations used the mounted native setter directly and hooks.json remained byte-identical", + "rendered_proof": "No visual inspection or refresh was used. Both mounted setter results returned the exact target ID and undecorated title; the initiating control was the final title call before explicit artifact commit. This is commit-admission seam evidence, not repaint or restart-persistence evidence." + }, + "confidence": "high", + "applicability": "Closes TB-PRE-0119-002 and validates the corrected BEAR-119 commit-admission seam for exact candidate 5f05b9f on Desktop 26.803.41515. It does not add rendered repaint or restart-persistence evidence to TB-CAP-MOUNTED-NATIVE-REPAINT.", + "supersedes": [], + "conflicts": [] } ], "preflights": [ @@ -1461,6 +1544,44 @@ ], "stop_condition": "Stop after one exact current-task mounted write, one exact controlled historical onboarding write, immediate mounted inspection, one previewed and confirmed lifecycle canary with its final recap, one clean Codex restart, post-restart mounted inspection, and read-only runtime/artifact checks. Do not retry a title write, add another target, add refresh machinery, or claim release readiness until one linked result closes this preflight.", "result_experiment_id": "TB-EXP-0116-006" + }, + { + "id": "TB-PRE-0119-001", + "issue": "BEAR-119", + "capability_id": "TB-CAP-MOUNTED-NATIVE-REPAINT", + "status": "closed", + "consulted": [ + "TB-EXP-0116-006", + "TB-EXP-0116-007" + ], + "remaining_unknown": "Whether the exact BEAR-119 candidate removes one obsolete bear or current status prefix from every prepared row in the complete live catalog through the mounted native writer, processes the initiating task last, then removes only ThreadBear-owned artifacts while preserving unrelated titles and files.", + "single_changed_variable": "Uninstall title direction and executable fingerprint: replace v3.0.1's no-cleanup teardown and neutral-bear onboarding surface with the BEAR-119 candidate's one fresh removal plan, serial exact mounted prefix removal, initiating-task-last order, and existing artifact commit.", + "held_constant": "Desktop 26.803.41515 build 6321 or the current installed successor on the same local macOS arm64 host; mounted codex_app__read_thread and codex_app__set_thread_title as the only title boundary; one exact current task and the existing controlled historical task as ordering controls inside the complete unarchived App Server inventory; exact ID and title acknowledgement; no hooks, SQLite, detached writer, retry, reconciliation, final scan, controller, queue, pacing, or resume state.", + "predicted_outcomes": [ + "If the focused cleanup works, the candidate will preview all 362 unarchived rows without mutation, prepare all 307 exact owned-prefix rows with the initiating control last, remove exactly one prefix through one immediate read and at most one setter per prepared row, then report uninstalled true with owned artifacts absent and the 30 plain plus 25 skipped rows unchanged.", + "If any prepared row drifts or returns a non-exact result, teardown must not start; if cleanup succeeds but an unrelated title or file changes, an owned artifact remains, the initiating target is not last, or a bear prefix is emitted, the candidate must not merge." + ], + "stop_condition": "Stop after one exact-candidate full-catalog lifecycle pass, including the controlled historical and initiating rows plus read-only artifact and unrelated-content verification. On any drift or unconfirmed write, do not retry or commit teardown. Do not run a second pass, restart for refresh, add coordination machinery, or claim release readiness until one linked result closes this preflight.", + "result_experiment_id": "TB-EXP-0119-001" + }, + { + "id": "TB-PRE-0119-002", + "issue": "BEAR-119", + "capability_id": "TB-CAP-MOUNTED-NATIVE-REPAINT", + "status": "closed", + "consulted": [ + "TB-EXP-0116-006", + "TB-EXP-0119-001" + ], + "remaining_unknown": "Whether corrected exact candidate 5f05b9f refuses the old bare confirmed uninstall before mutation, then preserves the established one-plan mounted cleanup path and admits artifact removal only through the explicit commit phase.", + "single_changed_variable": "Artifact teardown admission and executable fingerprint: candidate 5f05b9f replaces the ordinary bare confirmed teardown with an explicit commit phase after exact mounted cleanup; the planner, serial writer, initiating-task-last order, and existing artifact remover remain unchanged.", + "held_constant": "Desktop 26.803.41515 build 6321 on the same local macOS arm64 host; the same controlled historical and initiating tasks; mounted codex_app__read_thread and codex_app__set_thread_title as the only title boundary; exact ID and title acknowledgement; no hooks, SQLite, retry, final scan, controller, queue, pacing, coordination fence, or persistent marker.", + "predicted_outcomes": [ + "A bare uninstall --noninteractive --confirm --json will fail before artifact mutation; one fresh complete preparation will place the initiating task last; the two controlled prefixes will receive exact serial removals; uninstall --commit --noninteractive --confirm --json will then remove the owned artifacts.", + "If bare confirmation removes an artifact, either controlled title drifts or returns a non-exact result, the initiating task is not last, commit fails, unrelated content changes, or an owned artifact remains, the candidate must not merge." + ], + "stop_condition": "Stop after one bare-confirm refusal and one exact-candidate controlled cleanup plus commit. On any drift or unconfirmed write, do not retry or commit teardown. After commit, inspect artifacts and unrelated hashes only; do not read or write a task title, restart for refresh, or add coordination machinery.", + "result_experiment_id": "TB-EXP-0119-002" } ] } diff --git a/docs/live-eval.md b/docs/live-eval.md index 0cde3bf..533204f 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -24,19 +24,19 @@ Force missing or malformed current task ID, malformed helper JSON, mounted read Restart Codex after a successful write. Confirm the exact title remains in the sidebar and the next terminal turn still preserves the subject. -## Onboarding +## Uninstall title cleanup -For `onboard --dry-run --json`, prove the exact App Server handshake and cursor protocol through the fixed Desktop executable path. Include more than 100 tasks so the catalog is larger than 50 and necessarily multi-page; inject notifications and a duplicate ID. Prove complete deduplication, no arbitrary cap, no model or SQLite access, and zero ThreadBear mutation. Null and blank names remain raw even when `preview` looks safe. Fail a later page and prove zero native calls because no partial plan escaped. Prove the installed skill explains and requests the one explicit command permission needed for this catalog read. +For `uninstall --dry-run --json`, prove the exact App Server handshake and cursor protocol through the fixed Desktop executable path. Include more than 100 fixture tasks so the catalog is necessarily multi-page; inject notifications and a duplicate ID. Prove complete deduplication, no arbitrary cap, no model or SQLite access, and zero title or filesystem mutation. Null and blank names remain raw even when `preview` looks safe. Fail a later page and prove zero native calls because no partial plan escaped. Prove the installed skill explains and requests the one explicit command permission needed for this catalog read. -After explicit consent, run exact `onboard --noninteractive --confirm --json`. Prove it starts from a fresh complete snapshot, stores no titles, emits `prepared` actions containing snapshot `title` and `desired_title`, performs no per-target App Server read, and makes zero Codex title writes. Cover the active caller, null and blank names, ambiguous old status prefixes, overlong text, user emoji, and already-onboarded titles. Force the preparation command to yield and prove the exact embedded JavaScript resumes that same process through `write_stdin` without starting a second command. +After explicit consent, run exact `uninstall --prepare --noninteractive --confirm --json`. Prove it starts from one fresh complete snapshot, stores no titles, emits `prepared` actions containing snapshot `title` and undecorated `desired_title`, puts the active caller last, performs no per-target App Server read, and makes zero Codex title writes. Cover null and blank names, ambiguous old status prefixes, overlong text, user emoji, plain titles, every current status prefix, and legacy neutral `🐻 `. Force the preparation command to yield and prove the exact embedded JavaScript resumes that same process through `write_stdin` without starting a second command. -Run the installed skill's one serial native loop. Immediately before each possible write, require one mounted-app `read_thread` call with `includeOutputs:false`, `turnLimit:1`, and `maxOutputCharsPerItem:1`. Exercise raw JSON-text and already-decoded object results for both mounted tools. A missing or unreadable task, wrong returned task ID, or title that differs from the prepared snapshot is `skipped` and receives no setter call. Every exact ID/title match receives at most one explicit-target setter call for `🐻 `. Validate the exact returned ID/title and cover a throw, undecodable or non-object response, wrong target, and wrong title. Count every non-exact setter result as `unconfirmed` without retry. Interrupt a pass, rerun from another task, and prove completed titles are not doubled. Require serial read-before-write ordering, progress during preparation and every 25 outcomes, and a final receipt where every prepared row is exactly one of `updated`, `skipped`, or `unconfirmed`. Report `unchanged` honestly and report ready only when all prepared rows are accounted for and `unconfirmed` is zero. +Run the installed skill's one serial native loop. Immediately before each possible write, require one mounted-app `read_thread` call with `includeOutputs:false`, `turnLimit:1`, and `maxOutputCharsPerItem:1`. Exercise raw JSON-text and already-decoded object results. A missing or unreadable task, wrong returned task ID, or title that differs from the prepared snapshot receives no setter call and blocks teardown. Every exact ID/title match receives at most one explicit-target setter call for the exact undecorated subject. Validate the exact returned ID/title; any non-exact result blocks teardown without retry. Prove a fresh rerun does not repeat settled writes. When every prepared write is exact, prove exact `uninstall --commit --noninteractive --confirm --json` follows in the same cell. Prove a bare confirmed uninstall is refused. There is no final inventory scan or title call afterward. -Live-test the complete real local catalog with no artificial first-50 subset. Capture the untouched sidebar after the historical write. If that mounted row is cached, reopen its project once and verify the persisted title appears; do not issue another title write. Verify both controlled titles again after a clean restart. +For the live candidate, control one historical decorated task and the initiating task. Prove both exact prefix removals immediately, artifact removal afterward, unrelated content preservation, and no post-commit title write. Restart Codex and confirm the cleaned titles persist. ## Lifecycle -For install/reset, onboarding, manual update, and uninstall, verify the conversational layer as well as the command result. Before consent, the final response must use plain language to say what changes, what stays untouched, whether a restart follows, and ask one clear question. After all tools finish, the final response must end with a friendly recap containing the real result/counts, uncertainty, and next action. Summarize or reopen the task and confirm that recap remains visible and understandable; commentary, notifications, and raw tool output do not count. +For install/reset, manual update, and uninstall, verify the conversational layer as well as the command result. Before consent, the final response must use plain language to say what changes, what stays untouched, whether a restart follows, and ask one clear question. After all tools finish, the final response must end with a friendly recap containing the real result/counts, uncertainty, and next action. Summarize or reopen the task and confirm that recap remains visible and understandable; commentary, notifications, and raw tool output do not count. Prove fresh install, reinstall, and a consented exact 2.2.1 reset. The preview exposes the legacy main-task ID and complete automation fingerprint. Verify collision and missing-target dry runs mutate nothing. After consent, delete and verify only the exact automation, unpin and verify only the exact former persistent task, and do not rename it. Either native failure aborts before filesystem reset. The completed reset imports no old state, leaves ambiguous legacy titles untouched, installs one daily updater, and requires restart. @@ -44,4 +44,4 @@ Exercise dry-run preflight against modified managed guidance, skill, LaunchAgent Exercise manual and scheduled updates against an isolated official-release service. Origin, platform, checksum, version, and self-test failures must happen before writes and preserve the old install. Inject each local managed-surface failure and require `partial:true`, the failed stage, restart implication, and one safe rerun while the prior binary remains active. Successful update JSON includes `restart_required`; the LaunchAgent invokes only update; missing updater health does not change title-core `ready`. -Uninstall from an ordinary task. Prove preview and commit JSON are complete and exact. Preserve unrelated AGENTS content, skills, settings, files, and LaunchAgents, and remove the binary last. Historical title cleanup is not a gate and icons may remain. After committed removal, do not run the title command. Restart Codex and prove the managed protocol is gone. +Uninstall from an ordinary task. Prove preview and preparation JSON are complete and exact, the initiating task is processed last, and any drift or unconfirmed write stops before artifact removal. After all prepared writes succeed, prove commit performs no final catalog scan, preserves unrelated AGENTS content, skills, settings, files, titles, and LaunchAgents, and removes the binary last. After committed removal, do not run the title command. Restart Codex and prove the managed protocol is gone. diff --git a/docs/plans/2026-07-23-001-feat-threadbear-plan.md b/docs/plans/2026-07-23-001-feat-threadbear-plan.md index 09d4af9..a4773da 100644 --- a/docs/plans/2026-07-23-001-feat-threadbear-plan.md +++ b/docs/plans/2026-07-23-001-feat-threadbear-plan.md @@ -621,7 +621,7 @@ The zero-model notice proof and non-persisted classifier proof are hard release - **Decisions:** KTD8-KTD9, KTD12. - **Dependencies:** U6, U7. - **Files:** `install.sh`, `internal/install/install.go`, `internal/install/prompts.go`, `internal/install/agents.go`, `internal/install/migrate.go`, `internal/install/uninstall.go`, `internal/launchagent/plist.go`, `internal/launchagent/launchctl.go`, `assets/org.litman.threadbear.plist.tmpl`, `assets/AGENTS.threadbear.md`, `assets/skill/SKILL.md`, associated tests. -- **Approach:** Keep the shell bootstrap limited to platform/version resolution, verified temporary download, and delegation to `threadbear install`. Read prompts from `/dev/tty`, support complete noninteractive flags with an explicit confirmation assertion, preview exact effects once, and apply KTD9. Create or adopt one control task, install/update one managed AGENTS block and skill, write config/state, render the five-minute default LaunchAgent, then self-test before activation. Migrate only data the prototype actually stores: controller ID, snapshot, title-derived status, retry IDs, and detectable interval; collect absent preferences through onboarding. Stop and verify the legacy LaunchAgent/automation before new activation. Uninstall leaves existing task titles and archives untouched, defaults control-task archival and final confirmation to yes, and always deletes persistent state. *(Amended 2026-07-27 by BEAR-62: state retention and its separate prompt are removed.)* +- **Approach:** Keep the shell bootstrap limited to platform/version resolution, verified temporary download, and delegation to `threadbear install`. Read prompts from `/dev/tty`, support complete noninteractive flags with an explicit confirmation assertion, preview exact effects once, and apply KTD9. Create or adopt one control task, install/update one managed AGENTS block and skill, write config/state, render the five-minute default LaunchAgent, then self-test before activation. Migrate only data the prototype actually stores: controller ID, snapshot, title-derived status, retry IDs, and detectable interval; collect absent preferences through onboarding. Stop and verify the legacy LaunchAgent/automation before new activation. Uninstall removes one exact current owned prefix from each safe unarchived title before deleting managed artifacts; plain, ambiguous, unsafe, and archived titles remain untouched. *(Amended 2026-07-27 by BEAR-62: state retention and its separate prompt are removed. Amended 2026-08-08 by BEAR-119: owned title cleanup is restored before uninstall.)* - **Test Scenarios:** Default/custom/noninteractive/cancelled install; `/dev/tty` prompts under a pipe; checksum/self-test failure before mutation; idempotent reinstall/configure; byte-preserving AGENTS edits; all partial failure points; legacy state migration; active legacy writer; control task renamed/archived/deleted; plist lint; launchctl bootstrap/bootout/kickstart; uninstall choices and unrelated-file preservation. - **Verification:** `go test ./internal/install ./internal/launchagent`, `sh -n install.sh`, `plutil -lint` on every rendered plist fixture, and a disposable macOS user-job smoke test with a temporary home. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 111ce54..4ccfc53 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -2,16 +2,16 @@ Before tagging a stable release: -1. Run `python3 scripts/validate-experiments.py`. Cite current capability and seam records for mounted app-native title control, App Server onboarding pagination, default-sandbox behavior, and restart persistence. Validator success is not semantic approval; resolve contradictory evidence. +1. Run `python3 scripts/validate-experiments.py`. Cite current capability and seam records for mounted app-native title control, App Server pagination, default-sandbox behavior, and restart persistence. Validator success is not semantic approval; resolve contradictory evidence. 2. Rename `Unreleased` to `vN.N.N - YYYY-MM-DD` and add a fresh `Unreleased` section. 3. Run `gofmt`, `go test ./...`, `go test -race ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity. Review the diff for unnecessary machinery; do not substitute a physical line-count gate for that judgment. 4. In isolated homes, prove fresh install, reinstall, dry-run collisions, status, update, uninstall, and the consented exact 2.2.1 reset while preserving unrelated AGENTS content, skills, settings, files, automations, and LaunchAgents. Confirm reset verifies automation deletion and exact-task unpin before filesystem mutation, imports no old state, and never renames the former persistent task. -5. Prove one bounded terminal JavaScript cell is the last tool action before the final response. It must run exactly one stateless local `threadbear title --status ENUM --json` helper, parse only complete exit-zero JSON, start no App Server, create no title state, and read the exact current task through the mounted app. It may make at most one mounted setter call with `threadId` omitted. Exercise raw JSON-text and object returns; reject malformed, wrong-ID, blank, unsafe, ambiguous old-prefix, and non-exact results. Cover every status, safe user renames, the six deliberately reserved leading icon prefixes, non-reserved user emoji, internal envelopes, the read-to-write rename race, and a slow native call. Prove the exact cell succeeds under built-in `:workspace` without escalation or writes outside the workspace, and prove no SQLite, model, daemon, proxy, cache, retry, fallback, queue, controller, or repair state. -6. Prove `onboard --dry-run --json` requests one explicit command permission, launches App Server from a fixed supported Desktop path rather than ambient `PATH`, paginates past 100 tasks, tolerates notifications, deduplicates IDs, applies no source-label exclusion, treats null and blank names as raw, never adopts `preview`, and returns no partial plan after a later-page failure. After consent, prove exact `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores no titles, prepares every eligible target with snapshot `title` and `desired_title`, has no cap, performs no per-target App Server read, and writes zero titles. Prove the exact embedded JavaScript resumes a yielded preparation process through `write_stdin`, serially rereads each target through the mounted app immediately before any write, skips missing, unreadable, wrong-ID, or drifted responses, makes at most one setter call per exact match, validates exact returned ID/title, never retries, reports progress, and accounts every prepared item. -7. Install the exact candidate locally, restart Codex, and run `docs/live-eval.md`. Require immediate mounted repaint for the active header and one controlled historical row after their sole writes, then verify both titles after restart. Live-test the full local onboarding corpus, not an arbitrary subset. Disable rewriting if it practically corrupts titles or blocks responses. Open SWE is review/merge only for Desktop behavior, not the implementation or live-proof environment. +5. Prove one bounded terminal JavaScript cell is the last tool action before the final response. It must run exactly one stateless local `threadbear title --status ENUM --json` helper, parse only complete exit-zero JSON, start no App Server, create no title state, and read the exact current task through the mounted app. It may make at most one mounted setter call with `threadId` omitted. Exercise raw JSON-text and object returns; reject malformed, wrong-ID, blank, unsafe, ambiguous old-prefix, and non-exact results. Cover every status, safe user renames, the five current status prefixes, the cleanup-only neutral bear prefix, non-reserved user emoji, internal envelopes, the read-to-write rename race, and a slow native call. Prove the exact cell succeeds under built-in `:workspace` without escalation or writes outside the workspace, and prove no SQLite, model, daemon, proxy, cache, retry, fallback, queue, controller, or repair state. +6. Prove `uninstall --dry-run --json` requests one explicit command permission, launches App Server from a fixed supported Desktop path rather than ambient `PATH`, fully paginates, tolerates notifications, deduplicates IDs, treats null and blank names as raw, never adopts `preview`, and returns no partial plan after a later-page failure. After consent, prove exact `uninstall --prepare --noninteractive --confirm --json` takes one fresh complete snapshot, prepares every safe owned prefix for exact removal with the initiating task last, performs no per-target App Server read, and writes zero titles. Prove the embedded JavaScript resumes a yielded process, serially rereads each target, makes at most one setter call per exact match, blocks teardown on drift or any non-exact result, never retries, refuses a bare confirmed uninstall, and runs exact `uninstall --commit --noninteractive --confirm --json` only after every prepared write succeeds, with no final scan or post-commit title call. +7. Install the exact candidate locally, restart Codex, and run `docs/live-eval.md`. Require immediate mounted repaint for the active header and one controlled historical row after their sole cleanup writes, artifact removal afterward, unrelated-content preservation, then verify both titles after restart. Disable rewriting if it practically corrupts titles or blocks responses. Open SWE is review/merge only for Desktop behavior, not the implementation or live-proof environment. 8. Prove the daily LaunchAgent invokes only the verified updater. Network and verification failures preserve the old install; local write failures report `partial`, stage, restart implication, and one safe rerun with binary last; success reports `restart_required`. Prove updater health is separate from core `ready` and update/uninstall races are serialized. After uninstall commit, do not run the title command. -9. Confirm `assets/skill/SKILL.md` stays around 5 KB with readable safety code and headroom, `INSTALL.md` and `site/install` are byte-identical, current docs describe no historical controller protocol as product behavior, and the homepage matches the release. Live-test the friendly install/onboarding/update/uninstall previews and final-response recaps. Summarize or reopen the task and prove the final recap remains visible after commentary and tool output collapse. +9. Confirm `assets/skill/SKILL.md` stays compact with readable safety code, `INSTALL.md` and `site/install` are byte-identical, current docs describe no historical controller protocol as product behavior, and the homepage matches the release. Live-test the friendly install/update/uninstall previews and final-response recaps. Summarize or reopen the task and prove the final recap remains visible after commentary and tool output collapse. Before the local Desktop canary, run the exact binary through the isolated lifecycle smoke with `THREADBEAR_SMOKE_CANDIDATE=/absolute/path/to/threadbear scripts/release-smoke.sh vN.N.N`. This mode skips only the not-yet-published download; the tagged release workflow runs the same smoke through the hosted installer and release assets. -After tagging, verify the Git tag and GitHub Release, both Darwin binaries, checksums, manifest, and hosted bootstrap. Run the hosted smoke through `threadbear.sh`, including candidate self-test, planner-only proof, multi-page onboarding preparation and failure-before-writes, exact reset preflight, update isolation, and complete uninstall. In Codex Desktop, separately prove exact app-native acknowledgement and mounted rendering. Confirm hosted `/install` bytes match the reviewed guide before announcing publication. +After tagging, verify the Git tag and GitHub Release, both Darwin binaries, checksums, manifest, and hosted bootstrap. Run the hosted smoke through `threadbear.sh`, including candidate self-test, planner-only proof, multi-page uninstall preparation and failure-before-writes, exact reset preflight, update isolation, and complete uninstall. In Codex Desktop, separately prove exact app-native acknowledgement and mounted rendering. Confirm hosted `/install` bytes match the reviewed guide before announcing publication. diff --git a/docs/status-convention.md b/docs/status-convention.md index f1a570f..9132623 100644 --- a/docs/status-convention.md +++ b/docs/status-convention.md @@ -20,8 +20,8 @@ The status maps to one owned icon: | `blocked` | `🚨 ` | | `automation` | `🤖 ` | -The enum controls only the icon. The helper returns the task ID and fixed policy without reading Codex or writing state. The mounted app reads the exact current title; when a change is needed, the same cell makes one native title call and accepts only the exact returned task ID/title. Any owner or next action stays in the substantive response. There is no ThreadBear footer or running icon. Ordinary turns never emit the neutral onboarding mark `🐻`. +The enum controls only the icon. The helper returns the task ID and fixed policy without reading Codex or writing state. The mounted app reads the exact current title; when a change is needed, the same cell makes one native title call and accepts only the exact returned task ID/title. Any owner or next action stays in the substantive response. There is no ThreadBear footer, running icon, or neutral bear status. -ThreadBear strips at most one of its exact reserved prefixes: the five status icons above or neutral `🐻 `. Every other safe current byte is the subject, including user-authored emoji and arrows. A title beginning with a reserved prefix is the deliberate visible ambiguity; other old ThreadBear prefixes, blank, multiline, control-bearing, raw internal, or overlong titles stay unchanged. ThreadBear never normalizes or truncates a subject. +ThreadBear strips at most one of its exact removable prefixes: the five status icons above or legacy neutral `🐻 `. The bear is cleanup-only and is never emitted. Every other safe current byte is the subject, including user-authored emoji and arrows. A title beginning with a removable prefix is the deliberate visible ambiguity; other old ThreadBear prefixes, blank, multiline, control-bearing, raw internal, or overlong titles stay unchanged. ThreadBear never normalizes or truncates a subject. Use `complete` when work is finished with no warranted follow-up; `next_steps` only when the response establishes one concrete next action; `needs_input` for required user input; `blocked` for an external blocker; and `automation` for healthy automated work with nothing pending. Generic offers and speculative possibilities do not qualify as next steps. diff --git a/scripts/release-smoke.sh b/scripts/release-smoke.sh index 51040e4..c20d6e8 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -144,7 +144,7 @@ def initial_threads(): value = [ { "id": f"10000000-0000-4000-8000-{number:012d}", - "name": f"Existing task {number:03d}", + "name": f"🐻 Existing task {number:03d}", "preview": f"First message {number:03d}", "source": "cli", } @@ -165,7 +165,7 @@ def initial_threads(): }, { "id": delegated_id, - "name": "Visible delegated task", + "name": "🐻 Visible delegated task", "preview": "Delegated task with a safe visible name", "source": "subagent", }, @@ -358,19 +358,19 @@ if mode == "current": result = {"ready": False, "reason": "Codex title write was not confirmed exactly"} else: result = {"ready": True, "task_id": task_id, "title": response["title"], "updated": True} -elif mode == "onboard": +elif mode == "cleanup": if plan.get("ready") is not True or plan.get("plan_complete") is not True or plan.get("read_only") is not False or not isinstance(plan.get("items"), list): - raise SystemExit("invalid onboarding plan") + raise SystemExit("invalid cleanup plan") prepared = [item for item in plan["items"] if item.get("outcome") == "prepared"] - if any(not isinstance(item.get("task_id"), str) or not isinstance(item.get("title"), str) or not isinstance(item.get("desired_title"), str) for item in prepared): - raise SystemExit("invalid prepared onboarding item") + if len(prepared) != plan.get("prepared") or any(not isinstance(item.get("task_id"), str) or not isinstance(item.get("title"), str) or not isinstance(item.get("desired_title"), str) for item in prepared): + raise SystemExit("invalid prepared cleanup item") updated = 0 - skipped = 0 + drifted = 0 unconfirmed = 0 for item in prepared: current = decode_tool_result(read_title(item["task_id"])) if current is None or current.get("thread", {}).get("id") != item["task_id"] or current.get("thread", {}).get("title") != item["title"]: - skipped += 1 + drifted += 1 continue response = decode_tool_result(set_title(item["task_id"], item["desired_title"], True)) if response is not None and response.get("threadId") == item["task_id"] and response.get("title") == item["desired_title"]: @@ -378,15 +378,16 @@ elif mode == "onboard": else: unconfirmed += 1 total = plan["total"] if isinstance(plan.get("total"), int) else len(plan["items"]) - accounted = updated + skipped + unconfirmed == len(prepared) + accounted = updated + drifted + unconfirmed == len(prepared) result = { - "ready": accounted and unconfirmed == 0, + "ready": accounted and drifted == 0 and unconfirmed == 0, "plan_complete": True, - "onboarding_complete": accounted and unconfirmed == 0, + "cleanup_complete": accounted and drifted == 0 and unconfirmed == 0, "total": total, "updated": updated, - "skipped": skipped, - "unchanged": total - updated - unconfirmed, + "drifted": drifted, + "unchanged": plan.get("unchanged", 0), + "skipped": plan.get("skipped", 0), "unconfirmed": unconfirmed, } else: @@ -512,7 +513,10 @@ run_reset_installer() { run_reset_threadbear() { HOME="$reset_home" \ CODEX_HOME="$reset_codex_home" \ + CODEX_THREAD_ID="$reset_main_id" \ PATH="$reset_home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + THREADBEAR_SMOKE_APP_SERVER_LOG="$app_server_log" \ + THREADBEAR_SMOKE_APP_SERVER_STATE="$app_server_state" \ "$reset_binary" "$@" } @@ -559,14 +563,14 @@ assert [group["matcher"] for group in value["hooks"]["PostToolUse"]] == ["foreig PY test ! -e "$reset_state/native.json" || fail "completed reset retained legacy state" run_reset_threadbear uninstall --dry-run --json >"$root/reset-uninstall-preview.json" -run_reset_threadbear uninstall --noninteractive --confirm --json >"$root/reset-uninstall.json" +run_reset_threadbear uninstall --commit --noninteractive --confirm --json >"$root/reset-uninstall.json" python3 - "$root/reset-uninstall.json" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["uninstalled"] is True, value -assert value["restart_required"] is True and value["icons_may_remain"] is True, value +assert value["restart_required"] is True and "icons_may_remain" not in value, value PY # A foreign LaunchAgent collision must stop before every current-format surface. @@ -597,8 +601,7 @@ value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["dry_run"] is True, value assert value["version"] == sys.argv[2] and value["installed"] is False, value assert value["legacy_reset_required"] is False and value["partial"] is False, value -assert value["onboarding_requested"] is True, value -assert value["next_request"] == "threadbear onboard --dry-run --json", value +assert "onboarding_requested" not in value and "next_request" not in value, value assert not any("hook" in change.lower() for change in value["planned_changes"]), value PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || @@ -614,9 +617,8 @@ value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["installed"] is True, value assert value["version"] == sys.argv[2] and value["dry_run"] is False, value assert value["legacy_reset_required"] is False and value["partial"] is False, value -assert value["onboarding_requested"] is True, value assert value["automatic_updates_enabled"] is True and value["restart_required"] is True, value -assert value["next_request"] == "threadbear onboard --dry-run --json", value +assert "onboarding_requested" not in value and "next_request" not in value, value PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || fail "current install changed hooks.json" @@ -678,7 +680,7 @@ assert text.count("tools.codex_app__set_thread_title") == 1, text assert text.count("tools.codex_app__read_thread") == 1, text assert text.count("tools.write_stdin") == 1, text assert 'sandbox_permissions:"require_escalated"' in text, text -assert "Allow ThreadBear to read the full Codex task list" in text, text +assert "Allow ThreadBear to read the complete Codex task list" in text, text assert 'item.outcome === "prepared"' in text, text assert "const parseNative = value =>" in text, text assert 'if (typeof value !== "string") return value;' in text, text @@ -687,7 +689,8 @@ assert "current = parseNative(await tools.codex_app__read_thread" in text, text assert "renamed = parseNative(await tools.codex_app__set_thread_title" in text, text assert "current?.thread?.id !== item.task_id" in text, text assert "current.thread.title !== item.title" in text, text -assert "updated + skipped + unconfirmed === prepared.length" in text, text +assert "updated + drifted + unconfirmed === prepared.length" in text, text +assert "if (!accounted || drifted !== 0 || unconfirmed !== 0)" in text, text assert "thread/name/set" not in text, text PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || @@ -727,7 +730,7 @@ assert value["ready"] is True and value["installed"] is True, value assert value["automatic_updates_enabled"] is False, value assert value["updater"]["exact"] is False and value["updater"]["loaded"] is False, value PY -run_threadbear install --no-onboard --noninteractive --confirm --json >"$root/reinstall-updater.json" +run_threadbear install --noninteractive --confirm --json >"$root/reinstall-updater.json" python3 - "$root/reinstall-updater.json" <<'PY' import json import sys @@ -735,7 +738,7 @@ import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["installed"] is True, value assert value["automatic_updates_enabled"] is True and value["restart_required"] is True, value -assert value["onboarding_requested"] is False and "next_request" not in value, value +assert "onboarding_requested" not in value and "next_request" not in value, value PY /bin/launchctl print "$agent_target" >/dev/null 2>&1 || fail "reinstall did not restore the updater" cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || @@ -840,23 +843,23 @@ test ! -s "$app_server_log" || fail "ordinary automation title update started Ap cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || fail "title planning or mounted setter simulation changed hooks.json" -# Full enumeration must finish before any historical write. +# Full uninstall enumeration must finish before any title or filesystem write. test ! -e "$state_dir/subjects" || fail "title flow created subject state" app_state_before=$(shasum -a 256 "$app_server_state" | awk '{print $1}') : >"$app_server_log" if THREADBEAR_SMOKE_APP_SERVER_MODE=fail-page-2 \ - run_threadbear onboard --dry-run --json >"$root/onboard-failed-page.json"; then - fail "onboard accepted an App Server page failure" + run_threadbear uninstall --dry-run --json >"$root/cleanup-failed-page.json"; then + fail "uninstall preview accepted an App Server page failure" fi unset THREADBEAR_SMOKE_APP_SERVER_MODE -python3 - "$root/onboard-failed-page.json" <<'PY' +python3 - "$root/cleanup-failed-page.json" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is False and "thread/list page 2" in value["error"], value assert value["plan_complete"] is False and value["total"] == 0, value -assert value["items"] is None and value["prepared"] == 0 and value["needs_update"] == 0, value +assert value["items"] is None and value["prepared"] == 0 and value["needs_cleanup"] == 0, value PY test ! -e "$state_dir/subjects" || fail "failed catalog enumeration created subject state" test "$(shasum -a 256 "$app_server_state" | awk '{print $1}')" = "$app_state_before" || @@ -875,25 +878,24 @@ assert not any(message.get("method") == "thread/name/set" for message in message PY : >"$app_server_log" -run_threadbear onboard --dry-run --json >"$root/onboard-preview.json" -python3 - "$root/onboard-preview.json" "$current_id" "$raw_id" "$blank_id" <<'PY' +run_threadbear uninstall --dry-run --json >"$root/cleanup-preview.json" +python3 - "$root/cleanup-preview.json" "$current_id" "$raw_id" "$blank_id" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) current_id, raw_id, blank_id = sys.argv[2:] assert value["ready"] is True and value["plan_complete"] is True and value["read_only"] is True, value -assert value["onboarding_complete"] is False, value assert value["total"] == len(value["items"]) == 109, value -assert value["safe"] == 107 and value["needs_update"] == 106, value -assert value["prepared"] == 0 and value["unchanged"] == 1 and value["skipped"] == 2, value +assert value["needs_cleanup"] == 107, value +assert value["prepared"] == 0 and value["unchanged"] == 0 and value["skipped"] == 2, value assert [item["task_id"] for item in value["items"]] == sorted(item["task_id"] for item in value["items"]) by_id = {item["task_id"]: item for item in value["items"]} -assert by_id[current_id]["outcome"] == "unchanged", by_id[current_id] +assert by_id[current_id]["outcome"] == "needs_cleanup", by_id[current_id] for task_id in (raw_id, blank_id): item = by_id[task_id] - assert item["safe"] is False and item["outcome"] == "skipped", item - assert "title" not in item and "subject" not in item and "desired_title" not in item, item + assert item["outcome"] == "skipped", item + assert "title" not in item and "desired_title" not in item, item PY python3 - "$app_server_log" <<'PY' import json @@ -903,7 +905,7 @@ messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages PY -# One confirmed production pass prepares actions from the complete snapshot +# One confirmed uninstall pass prepares actions from the complete snapshot # without per-target RPCs. The mounted-native simulation then rereads every # prepared title immediately before one possible setter; drift and a same-title # response for the wrong task both skip the write, while one injected setter @@ -911,22 +913,22 @@ PY : >"$app_server_log" : >"$native_tool_log" app_state_before_preparation=$(shasum -a 256 "$app_server_state" | awk '{print $1}') -run_threadbear_with_caller "$delegated_id" onboard --noninteractive --confirm --json >"$root/onboard-prepared-edge.json" -python3 - "$root/onboard-prepared-edge.json" "$delegated_id" <<'PY' +run_threadbear_with_caller "$delegated_id" uninstall --prepare --noninteractive --confirm --json >"$root/cleanup-prepared-edge.json" +python3 - "$root/cleanup-prepared-edge.json" "$delegated_id" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) delegated_id = sys.argv[2] assert value["ready"] is True and value["plan_complete"] is True, value -assert value["read_only"] is False and value["onboarding_complete"] is False, value -assert value["total"] == len(value["items"]) == 109 and value["safe"] == 107, value -assert value["needs_update"] == 105 and value["prepared"] == 105, value -assert value["unchanged"] == 2 and value["skipped"] == 2, value +assert value["read_only"] is False, value +assert value["total"] == len(value["items"]) == 109, value +assert value["needs_cleanup"] == 107 and value["prepared"] == 107, value +assert value["unchanged"] == 0 and value["skipped"] == 2, value assert value["prepared"] + value["unchanged"] + value["skipped"] == value["total"], value by_id = {item["task_id"]: item for item in value["items"]} -assert by_id[delegated_id]["outcome"] == "unchanged", by_id[delegated_id] -assert by_id[delegated_id]["reason"] == "active task is handled by the terminal title writer", by_id[delegated_id] +assert value["items"][-1]["task_id"] == delegated_id, value["items"][-1] +assert by_id[delegated_id]["outcome"] == "prepared", by_id[delegated_id] assert all(item["outcome"] != "updated" and item["outcome"] != "unconfirmed" for item in value["items"]), value PY python3 - "$app_server_log" <<'PY' @@ -942,10 +944,10 @@ assert [message["params"] for message in lists] == [ assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages PY test "$(shasum -a 256 "$app_server_state" | awk '{print $1}')" = "$app_state_before_preparation" || - fail "onboarding preparation mutated native task state" -test ! -e "$state_dir/subjects" || fail "onboarding preparation created subject state" -"$simulate_mounted" onboard "$root/onboard-prepared-edge.json" "$app_server_state" "$native_tool_log" "$root/onboard-edge.json" "$unconfirmed_id" "$mounted_drift_id" "$mounted_wrong_id" -python3 - "$root/onboard-edge.json" "$native_tool_log" "$root/onboard-prepared-edge.json" "$unconfirmed_id" "$mounted_drift_id" "$mounted_wrong_id" <<'PY' + fail "cleanup preparation mutated native task state" +test ! -e "$state_dir/subjects" || fail "cleanup preparation created subject state" +"$simulate_mounted" cleanup "$root/cleanup-prepared-edge.json" "$app_server_state" "$native_tool_log" "$root/cleanup-edge.json" "$unconfirmed_id" "$mounted_drift_id" "$mounted_wrong_id" +python3 - "$root/cleanup-edge.json" "$native_tool_log" "$root/cleanup-prepared-edge.json" "$unconfirmed_id" "$mounted_drift_id" "$mounted_wrong_id" <<'PY' import json import sys @@ -956,16 +958,17 @@ failed_id, drift_id, wrong_id = sys.argv[4:] assert value == { "ready": False, "plan_complete": True, - "onboarding_complete": False, + "cleanup_complete": False, "total": 109, - "updated": 102, + "updated": 104, + "drifted": 2, "skipped": 2, - "unchanged": 6, + "unchanged": 0, "unconfirmed": 1, }, value reads = [call for call in calls if call["method"] == "codex_app__read_thread"] sets = [call for call in calls if call["method"] == "codex_app__set_thread_title"] -assert len(reads) == 105 and len(sets) == 103 and len(calls) == 208, len(calls) +assert len(reads) == 107 and len(sets) == 105 and len(calls) == 212, len(calls) read_ids = [call["params"]["threadId"] for call in reads] set_ids = [call["params"]["threadId"] for call in sets] prepared = {item["task_id"]: item for item in plan["items"] if item["outcome"] == "prepared"} @@ -996,38 +999,38 @@ assert all(isinstance(call.get("response"), str) for call in sets if "error" not assert sum("error" in call for call in sets) == 1, sets PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || - fail "confirmed onboarding changed hooks.json" + fail "confirmed cleanup changed hooks.json" -run_threadbear onboard --dry-run --json >"$root/onboard-after-edge.json" -python3 - "$root/onboard-after-edge.json" <<'PY' +run_threadbear uninstall --dry-run --json >"$root/cleanup-after-edge.json" +python3 - "$root/cleanup-after-edge.json" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["plan_complete"] is True, value -assert value["total"] == 109 and value["safe"] == 107, value -assert value["needs_update"] == 4 and value["prepared"] == 0, value -assert value["unchanged"] == 103 and value["skipped"] == 2, value +assert value["total"] == 109, value +assert value["needs_cleanup"] == 2 and value["prepared"] == 0, value +assert value["unchanged"] == 105 and value["skipped"] == 2, value PY : >"$app_server_log" : >"$native_tool_log" -run_threadbear onboard --noninteractive --confirm --json >"$root/onboard-final-plan.json" -python3 - "$root/onboard-final-plan.json" "$app_server_log" <<'PY' +run_threadbear uninstall --prepare --noninteractive --confirm --json >"$root/cleanup-final-plan.json" +python3 - "$root/cleanup-final-plan.json" "$app_server_log" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["plan_complete"] is True, value -assert value["read_only"] is False and value["onboarding_complete"] is False, value -assert value["total"] == 109 and value["safe"] == 107, value -assert value["needs_update"] == 4 and value["prepared"] == 4, value -assert value["unchanged"] == 103 and value["skipped"] == 2, value +assert value["read_only"] is False, value +assert value["total"] == 109, value +assert value["needs_cleanup"] == 2 and value["prepared"] == 2, value +assert value["unchanged"] == 105 and value["skipped"] == 2, value messages = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages PY -"$simulate_mounted" onboard "$root/onboard-final-plan.json" "$app_server_state" "$native_tool_log" "$root/onboard-converged.json" "" "" "" -python3 - "$root/onboard-converged.json" "$native_tool_log" "$root/onboard-final-plan.json" <<'PY' +"$simulate_mounted" cleanup "$root/cleanup-final-plan.json" "$app_server_state" "$native_tool_log" "$root/cleanup-converged.json" "" "" "" +python3 - "$root/cleanup-converged.json" "$native_tool_log" "$root/cleanup-final-plan.json" <<'PY' import json import sys @@ -1037,33 +1040,34 @@ plan = json.load(open(sys.argv[3], encoding="utf-8")) assert value == { "ready": True, "plan_complete": True, - "onboarding_complete": True, + "cleanup_complete": True, "total": 109, - "updated": 4, - "skipped": 0, + "updated": 2, + "drifted": 0, + "skipped": 2, "unchanged": 105, "unconfirmed": 0, }, value reads = [call for call in calls if call["method"] == "codex_app__read_thread"] sets = [call for call in calls if call["method"] == "codex_app__set_thread_title"] prepared = {item["task_id"]: item for item in plan["items"] if item["outcome"] == "prepared"} -assert len(reads) == 4 and len(sets) == 4 and len(calls) == 8, calls -assert len({call["params"]["threadId"] for call in reads}) == 4, reads -assert len({call["params"]["threadId"] for call in sets}) == 4, sets +assert len(reads) == 2 and len(sets) == 2 and len(calls) == 4, calls +assert len({call["params"]["threadId"] for call in reads}) == 2, reads +assert len({call["params"]["threadId"] for call in sets}) == 2, sets assert {call["params"]["threadId"] for call in reads} == set(prepared), (reads, prepared) assert all(call["params"]["title"] == prepared[call["params"]["threadId"]]["desired_title"] for call in sets), sets assert all(isinstance(call.get("response"), str) and "error" not in call for call in calls), calls PY : >"$app_server_log" -run_threadbear onboard --dry-run --json >"$root/onboard-final-preview.json" -python3 - "$root/onboard-final-preview.json" "$app_server_log" <<'PY' +run_threadbear uninstall --dry-run --json >"$root/cleanup-final-preview.json" +python3 - "$root/cleanup-final-preview.json" "$app_server_log" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) -assert value["ready"] is True and value["onboarding_complete"] is True, value -assert value["needs_update"] == 0 and value["prepared"] == 0, value +assert value["ready"] is True and value["plan_complete"] is True, value +assert value["needs_cleanup"] == 0 and value["prepared"] == 0, value assert value["unchanged"] == 107 and value["skipped"] == 2, value messages = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages @@ -1133,6 +1137,7 @@ import sys path, binary, state_dir, codex_home, agent_path = sys.argv[1:] changes = [ + "remove one ThreadBear prefix from each safe unarchived task title", f"boot out and remove sh.threadbear.update LaunchAgent {agent_path}", f"remove managed AGENTS block from {codex_home}/AGENTS.md", f"remove skill {codex_home}/skills/threadbear/SKILL.md", @@ -1141,16 +1146,14 @@ changes = [ f"remove binary last {binary}", ] value = json.load(open(path, encoding="utf-8")) -assert value == { - "ready": True, - "dry_run": True, - "uninstalled": False, - "icons_may_remain": True, - "restart_required": False, - "partial": False, - "warning": "Existing ThreadBear title icons may remain until renamed.", - "planned_changes": changes, -}, value +assert value["ready"] is True and value["dry_run"] is True and value["uninstalled"] is False, value +assert value["restart_required"] is False and value["partial"] is False, value +assert value["plan_complete"] is True and value["read_only"] is True, value +assert value["total"] == len(value["items"]) == 109, value +assert value["needs_cleanup"] == 0 and value["prepared"] == 0, value +assert value["unchanged"] == 107 and value["skipped"] == 2, value +assert value["planned_changes"] == changes, value +assert "icons_may_remain" not in value and "warning" not in value, value assert not any("hook" in change.lower() for change in changes), changes PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || @@ -1159,7 +1162,8 @@ test -x "$binary" || fail "uninstall preview removed the binary" test -e "$agent_path" || fail "uninstall preview removed the LaunchAgent" /bin/launchctl kickstart -k "$agent_target" -run_threadbear uninstall --noninteractive --confirm --json >"$root/uninstall.json" +: >"$app_server_log" +run_threadbear uninstall --commit --noninteractive --confirm --json >"$root/uninstall.json" python3 - "$root/uninstall.json" "$root/uninstall-preview.json" <<'PY' import json import sys @@ -1170,13 +1174,12 @@ assert value == { "ready": True, "dry_run": False, "uninstalled": True, - "icons_may_remain": True, "restart_required": True, "partial": False, - "warning": preview["warning"], - "planned_changes": preview["planned_changes"], + "planned_changes": preview["planned_changes"][1:], }, value PY +test ! -s "$app_server_log" || fail "uninstall commit performed a final catalog scan" test ! -e "$binary" || fail "uninstall left the binary" test -x "$fake_codex" || fail "uninstall removed the neighboring Codex fixture" diff --git a/site/index.html b/site/index.html index 689d574..9f57faa 100644 --- a/site/index.html +++ b/site/index.html @@ -47,20 +47,20 @@

ThreadBear

Small by design

One terminal updateEach turn runs one stateless cell: Codex reads the title, then makes at most one native title write.
-
Your subject stays yoursThreadBear reserves six exact current icon prefixes, preserves every other safe emoji and subject byte, and leaves ambiguous old prefixes unchanged.
+
Your subject stays yoursThreadBear writes five exact status prefixes, preserves every other safe emoji and subject byte, and recognizes the old neutral bear only to remove it.
The mounted app owns titlesOrdinary turns use Codex's mounted native reader and writer—no helper process reaches into Codex storage.
-
Every native page accounted forOnboarding finishes App Server pagination before serial app-native writes, with no arbitrary first-50 cap.
+
A clean goodbyeUninstall fully plans owned-prefix cleanup before serial app-native writes and removes no files if a prepared title drifts.
Uncertainty stays localUnsafe or ambiguous titles are left unchanged. A returned failure is not retried or promoted into global state.
A verified new coatOne daily update-only LaunchAgent installs checksummed, self-tested official releases and never reads tasks.
-

Five outcomes, plus a welcome bear

-

🚨 🙋 🤖 ➡️ ✅ 🐻

+

Five useful outcomes

+

🚨 🙋 🤖 ➡️ ✅

Each mark is followed by the exact user-owned subject. Owners and actions stay in response prose, and overlong subjects are left unchanged instead of truncated.

Small, private footprint

ThreadBear installs at ~/.local/bin/threadbear and adds one managed instruction block, one skill, small lifecycle/update state, and one daily update-only LaunchAgent. It keeps no per-task title database.

Release binaries are not Developer ID signed or notarized. The installer verifies the published SHA-256 checksum and candidate self-test before installation.

Honest boundaries

-

Ordinary turns work with Codex's default workspace permissions and start no App Server. Historical onboarding asks once for permission, then launches App Server from a fixed Codex Desktop path, fully paginates thread/list, immediately rereads each prepared task through the mounted app, skips drift, and applies at most once with no item cap. A null or blank name is raw; preview is never adopted. There is no SQLite access, title database, daemon, proxy, cache, model, retry, fallback, queue, or repair pass.

+

Ordinary turns work with Codex's default workspace permissions and start no App Server. Uninstall cleanup asks once for permission, then launches App Server from a fixed Codex Desktop path, fully paginates thread/list, and immediately rereads each prepared task through the mounted app before one possible prefix removal. Drift or an unconfirmed result stops before artifact removal. A null or blank name is raw; preview is never adopted. There is no SQLite access, title database, daemon, proxy, cache, model, retry, fallback, queue, or repair pass.

The updater never reads tasks. Network and candidate-verification failures leave the old install untouched; a later local surface failure is reported as a rerunnable partial with the binary written last. Updater health is reported separately from title-core readiness.

diff --git a/site/install b/site/install index 82058aa..8186420 100644 --- a/site/install +++ b/site/install @@ -16,7 +16,7 @@ Open with this orientation: > > ThreadBear adds one useful status icon while keeping the rest of each safe task title intact. Codex reads and applies the title itself. > -> I'll check this Mac, show you exactly what will change, and ask before installing anything. Afterward, Codex needs one restart. Then you can say **ThreadBear onboard** in any task to update every safe existing local task—there is no 50-task cap or persistent ThreadBear task. +> I'll check this Mac, show you exactly what will change, and ask before installing anything. Installation leaves existing task titles alone. Afterward, Codex needs one restart. Codex collapses commentary after a turn finishes, so the final answer that asks for consent must repeat the orientation, readiness result, complete recommendation, and question. If a check fails, report it plainly and do not ask for install consent. @@ -54,7 +54,7 @@ if [ -x "$HOME/.local/bin/threadbear" ]; then fi ``` -ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop 0.146.0 or newer, and HTTPS access to the official guide and GitHub Releases. The check prints every fixed Codex Desktop command it finds; ThreadBear uses the first one that actually reports a compatible version. It needs no `sudo` or Full Disk Access. Ordinary title updates work with Codex's default workspace permissions. Historical onboarding asks once for permission to read the complete local task catalog. ThreadBear never opens Codex SQLite. +ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop 0.146.0 or newer, and HTTPS access to the official guide and GitHub Releases. The check prints every fixed Codex Desktop command it finds; ThreadBear uses the first one that actually reports a compatible version. It needs no `sudo` or Full Disk Access. Ordinary title updates work with Codex's default workspace permissions. Uninstall cleanup asks once for permission to read the complete local task catalog. ThreadBear never opens Codex SQLite. For an official release, run the verified bootstrap preview: @@ -79,7 +79,7 @@ Only after the checks and dry run succeed, present this complete card in the sam > ## Here's what will happen > > - ThreadBear adds one helpful status icon without rewriting your task's subject or emoji. -> - Existing tasks stay unchanged until you preview onboarding and approve it separately. +> - Existing task titles stay unchanged during installation. > - A small local helper, Codex instructions, and a ThreadBear skill are added. > - Once a day, ThreadBear checks for and installs only verified official releases. Updates never read tasks or change titles. > - Unclear or unsafe titles are left alone, and there is no persistent ThreadBear task. @@ -90,7 +90,7 @@ Only after the checks and dry run succeed, present this complete card in the sam For a 2.2.1 reset, add: “I'll remove only the verified old ThreadBear automation, unpin its former task without renaming it, and install the simpler version fresh. Old title history will not be guessed or imported, so some existing icons may remain.” -A clear yes to the unchanged recommendation is consent. Ask again only if the effect changes or the answer is ambiguous. If the user does not want historical onboarding, accept that preference and add `--no-onboard` to the confirmed install. +A clear yes to the unchanged recommendation is consent. Ask again only if the effect changes or the answer is ambiguous. ## 3. Install after consent @@ -111,7 +111,7 @@ For a local candidate, run: /path/to/threadbear install --noninteractive --confirm --json ``` -Add `--no-onboard` only when the user opted out. Add `--reset` only after the exact legacy cleanup is verified. Then run: +Add `--reset` only after the exact legacy cleanup is verified. Then run: ```sh ~/.local/bin/threadbear version --json @@ -121,64 +121,23 @@ Add `--no-onboard` only when the user opted out. Add `--reset` only after the ex Core `ready` is healthy when the installed binary, private lifecycle state, compatible Codex Desktop, managed guidance, and skill match the candidate. Report the daily updater separately; missing automatic updates do not make title handling globally unready. Core readiness does not depend on historical title counts. -No controller, worker, migration phase, persistent task, or hidden onboarding job should exist after installation. If installation fails after mutation starts, report `partial:true`, the failed stage, whether restart is required, and the one safe rerun action. `planned_changes` is a plan, not a claim that every item ran. +No controller, worker, migration phase, persistent task, or hidden historical-title job should exist after installation. If installation fails after mutation starts, report `partial:true`, the failed stage, whether restart is required, and the one safe rerun action. `planned_changes` is a plan, not a claim that every item ran. After the checks finish, end the final response with this plain-language receipt, filled with the real result: > ## ThreadBear recap 🐻 > > - ThreadBear is installed and automatic updates are [ready / need attention]. -> - Existing tasks have not been changed yet, and unrelated Codex settings stayed untouched. -> - Next: restart Codex, then open any task and say **ThreadBear onboard**. +> - Existing task titles and unrelated Codex settings stayed untouched. +> - Next: restart Codex so open tasks load the new instructions. -## 4. Restart and onboard +## 4. Restart -Say: “Installation is finished. One restart loads the new instructions; onboarding stays a separate previewed choice.” +Say: “Installation is finished. One restart loads the new instructions. Existing task titles were not changed.” After a successful install say: > ThreadBear is installed. Restart Codex so open tasks load the new managed guidance. -> -> After restart, open any task and say: **ThreadBear onboard** - -When that request arrives, read the installed skill and follow this protocol: - -1. Run `~/.local/bin/threadbear status --json`. Explain that Codex will ask once so ThreadBear can read the complete task list and that the preview changes nothing. Then run `~/.local/bin/threadbear onboard --dry-run --json` with `sandbox_permissions:"require_escalated"` and that plain-language justification. - If the host says approval requests are disabled, stop without running around that policy. End with **ThreadBear recap 🐻**: “No tasks changed. This task cannot ask for onboarding permission. Next: use a task where Codex can ask, then say **ThreadBear onboard**.” Do not change the user's permission settings. -2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any preparation or title write. If enumeration fails, make zero changes. -3. Explain `total`, `safe`, and `needs_update` with this card: - -> ## Here's what will happen -> -> - I found N existing tasks. X have safe titles, and Y need a ThreadBear icon. -> - The rest stay untouched. -> - I'll check each task again immediately before its one possible title change. -> - If a title changed before its turn, I'll leave it alone. -> - If a change cannot be confirmed, I won't retry it and I'll tell you. -> -> Update these existing tasks now? - -The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. -4. Ask for explicit consent unless unchanged install consent covered this first pass. -5. After consent, follow the installed skill's single onboarding JavaScript cell. Its first action runs exactly: - -```sh -~/.local/bin/threadbear onboard --noninteractive --confirm --json -``` - -The confirmed command asks for the same one-time permission, takes a fresh complete catalog snapshot, and returns one `prepared` action containing the snapshot title and desired title. It stores no titles and makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, wrong-ID, or changed-title response is skipped. Only an exact task ID and snapshot title may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. - -Codex can keep an already-mounted historical row cached after an exact native write. Do not retry or add refresh machinery. The persisted title appears when its project is reopened or Codex restarts; say this plainly in the onboarding summary. - -Report `updated`, `skipped`, `unchanged`, and `unconfirmed`. Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. - -End with: - -> ## ThreadBear recap 🐻 -> -> - Checked N existing tasks: updated X, left Y unchanged, and could not confirm Z. -> - No uncertain task was retried. Older sidebar rows may refresh when their project reopens or Codex restarts. -> - Next: [ThreadBear is ready / rerun **ThreadBear onboard** after resolving the named problem]. ## Commands and updater @@ -186,7 +145,6 @@ End with: ~/.local/bin/threadbear help ~/.local/bin/threadbear status --json ~/.local/bin/threadbear title --status complete --json -~/.local/bin/threadbear onboard --dry-run --json ~/.local/bin/threadbear update --json ``` @@ -220,31 +178,29 @@ Preview first: ~/.local/bin/threadbear uninstall --dry-run --json ``` +Run the preview with `sandbox_permissions:"require_escalated"` and explain that Codex is asking once to read the complete unarchived task catalog. Require `ready:true`, `plan_complete:true`, and `read_only:true`. If permission is unavailable or catalog enumeration fails, stop without changing titles or files. + End the consent turn with: > ## Here's what will happen > -> - I'll remove ThreadBear's local helper, Codex instructions, skill, and automatic updates. -> - Your tasks, other Codex settings, and unrelated files stay untouched. -> - Existing title icons may remain until those tasks are renamed. +> - I'll remove one ThreadBear status prefix from each safe unarchived task title, then remove ThreadBear's local helper, Codex instructions, skill, and automatic updates. +> - Plain, user-authored, ambiguous, unsafe, and archived titles stay unchanged, as do other Codex settings and unrelated files. +> - I'll reread every prepared task immediately before its one possible title change. Any drift or unconfirmed result stops before ThreadBear files are removed. > - After removal, you'll restart Codex once. > > Uninstall ThreadBear now? -After consent: - -```sh -~/.local/bin/threadbear uninstall --noninteractive --confirm --json -``` +After consent, follow the installed skill's single uninstall JavaScript cell. It first runs exact `uninstall --prepare --noninteractive --confirm --json`, taking one fresh complete plan. It then serially rereads and removes one owned prefix from every prepared target, with the initiating task last. Any missing, drifted, malformed, wrong-target, wrong-title, or thrown result blocks teardown and gets one fresh-rerun action; never retry in the same pass. Only after every prepared write returns the exact target and title does the cell run exact `uninstall --commit --noninteractive --confirm --json`. A bare confirmed uninstall is refused. There is no final catalog scan, marker, queue, controller, or resume state. -Require committed removal and verify unrelated AGENTS content, skills, settings, files, and LaunchAgents remain byte-for-byte intact. After commit, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. +Require committed removal and verify unrelated AGENTS content, skills, settings, files, titles, and LaunchAgents remain byte-for-byte intact. After commit, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. The final response after committed removal is: > ## ThreadBear recap 🐻 > -> - ThreadBear and its automatic updates were removed. -> - Your tasks and unrelated Codex content stayed untouched; old title icons may remain. +> - ThreadBear and its automatic updates were removed after cleaning X task titles. +> - Y task titles were left unchanged. Your task content and unrelated Codex content stayed untouched. > - Next: restart Codex so open tasks drop the old instructions. ## Release proof @@ -256,8 +212,8 @@ Release acceptance additionally requires one reviewed candidate live-tested end - the stateless terminal helper works under Codex's default workspace permissions and starts no App Server or title-state write; - the mounted app-native reader supplies the exact current title, and the setter receives no explicit current-task ID and returns the exact task ID/title; - the rendered sidebar shows the expected title before and after a clean restart; -- a full onboarding preview enumerates every local task, confirmed preparation writes no title, and the consented serial app-native pass accounts for every prepared target while skipping title drift before any write; -- failures and unconfirmed results are reported locally without retries or global failure state; +- a full uninstall preview enumerates every unarchived task, confirmed preparation writes no title, and the consented serial app-native pass processes the initiating task last; +- drift and unconfirmed results block teardown without retries or global failure state, while all-exact cleanup proceeds directly to artifact removal with no final inventory scan or post-commit title call; - automatic update and uninstall preserve neighboring user content. If the mounted app-native writer causes practical title corruption or response blocking, disable rewriting instead of adding reconciliation machinery.