From 46400e24dacd604e65d2fab4019fb373495c3cce Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Mon, 3 Aug 2026 15:42:39 -0400 Subject: [PATCH 1/2] feat: restore native task archiving [BEAR-110] --- CHANGELOG.md | 4 + CLAUDE.md | 5 +- INSTALL.md | 24 ++-- README.md | 7 +- assets/help.txt | 7 + assets/skill/SKILL.md | 45 ++++-- cmd/threadbear/install.go | 5 +- cmd/threadbear/main.go | 6 + cmd/threadbear/maintenance.go | 199 +++++++++++++++++++++++++++ cmd/threadbear/maintenance_test.go | 177 ++++++++++++++++++++++++ cmd/threadbear/site_contract_test.go | 25 +++- cmd/threadbear/state.go | 19 ++- docs/architecture.md | 12 +- site/index.html | 6 +- site/install | 24 ++-- 15 files changed, 512 insertions(+), 53 deletions(-) create mode 100644 cmd/threadbear/maintenance.go create mode 100644 cmd/threadbear/maintenance_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0a753..f5206f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Restored conservative 14-day automatic archiving through one consented hourly Luna heartbeat, deterministic candidate selection, native archive controls, interruption-safe ownership, and ownership-only restore. + ## v2.1.7 - 2026-08-03 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 86223ad..3b5e3bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ 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. -- CI rejects more than 1,000 physical lines of production Go; stay materially below that without compressing code to game the count. -- Do not add a scheduler, retained control task, pending-title queue, detached title writer, or background classifier. +- Shipped-logic target is 1,000 physical lines and CI rejects more than the 1,500-line absolute ceiling; stay as small as the product permits without compressing code to game the count. +- The only scheduler is the consented `threadbear-maintenance` Codex heartbeat attached to the persistent task. Do not add a LaunchAgent, second schedule, 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 2b00edd..cae405c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,6 +1,6 @@ # Install ThreadBear with Codex -Open a new Codex task and paste: +Open a new Codex Desktop task with Luna at medium effort and paste: ```text Install ThreadBear — follow https://threadbear.sh/install @@ -14,7 +14,7 @@ Open with this complete orientation before running a command: > ## Hi. Let's install ThreadBear. > -> ThreadBear keeps Codex Desktop task titles useful with a running mark while work is underway and a clear outcome when each response finishes. It uses two native title calls per ordinary turn and two small deterministic hooks. +> ThreadBear keeps Codex Desktop task titles useful with a running mark while work is underway and a clear outcome when each response finishes. It uses two native title calls per ordinary turn, two small deterministic hooks, and one quiet Luna helper for housekeeping. > > I'll check this Mac, show you exactly what will change, and answer questions before installing anything. After you consent, I'll install it and update native-addressable local Codex task titles without taking you away from this ThreadBear task. Older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and will stay unchanged. @@ -70,6 +70,8 @@ Only after every check and the dry run succeeds, compose one terminal final answ > - Small local footprint: one binary in ~/.local/bin, a skill, and two hooks. > - One persistent thread, 🧵🐻 ThreadBear 🐻🧵, for changing config and uninstalling. > - Deterministic classification first; Luna medium only for ambiguity. +> - A small Luna helper checks in hourly, then stays quiet when there is nothing to do. +> - Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them. > > Install ThreadBear with this recommended setup? @@ -106,6 +108,10 @@ The install result must show `installed:true`, the exact `main_task_id`, and `ph Inventory and migration cover local Codex Desktop and CLI tasks that the native explicit-target title setter can address. They do not enumerate or rename older signed-in ChatGPT chat-history rows that may also appear in the Desktop sidebar. Disclose that boundary before migration and never describe zero local inventory rows as proof that every visible sidebar row changed. +Create or update one paused hourly heartbeat automation through the native automation control. Its exact ID is `threadbear-maintenance`, its name is “ThreadBear maintenance,” and its target is `MAIN_TASK_ID`. Its prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, and stay quiet when nothing changes.” Reuse only an existing automation whose ID, name, kind, and target all match; an ID collision with anything else stops installation without changing that automation. Do not create a cron job or a second maintenance schedule. A reinstall already at `migration_complete` may keep it active. + +If the native automation call fails, say that ThreadBear's local title helper is installed but its housekeeping helper is not, and stop before claiming completion. Do not ask the user to create or repair the automation manually. + ## 4. Migrate without leaving this task Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install** and **Migration controller** sections. The installed skill is the canonical operation guide. @@ -136,7 +142,7 @@ Only after that command succeeds may the controller archive itself. A stopped co Keep this ThreadBear task selected and supervise the controller with compact task waits. Report only each 25-title milestone or phase change. When the controller returns, run `status --json` and `inventory --json`. `migration_pending` always means no controller was recorded; status repairs an older running-without-controller state to pending. `migration_running` always names the active controller. Status reconciles a missing controller or a terminal lifecycle event from the current attempt from stale `migration_running` to `migration_failed`; it never infers failure from age, slow progress, or a prior attempt's terminal event. If the controller or this turn is interrupted, begin the next turn with status so the durable phase is truthful. -Do not send a final installation answer while status still says `migration_pending` or `migration_running`. At `migration_pending`, say that migration has not started and nothing is running, then give the exact start action. At `migration_failed`, say plainly that migration stopped and is not still working, give the applied and remaining counts, name the cause, and give one exact resume action. At `migration_complete`, require zero remaining native-addressable local rows before closing and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. +Do not send a final installation answer while status still says `migration_pending` or `migration_running`. At `migration_pending`, say that migration has not started and nothing is running, then give the exact start action. At `migration_failed`, say plainly that migration stopped and is not still working, give the applied and remaining counts, name the cause, and give one exact resume action. Keep maintenance paused in every non-complete phase. At `migration_complete`, require zero remaining native-addressable local rows, activate the exact owned heartbeat, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. ## 5. Close precisely @@ -144,7 +150,7 @@ On complete success, use this shape in natural prose: > ## ThreadBear is installed > -> Everything passed: ThreadBear VERSION is installed, its managed guidance and two hooks are healthy, this task is its persistent home, and the migration controller completed with zero remaining native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows were outside this migration and may remain unchanged in the sidebar. +> Everything passed: ThreadBear VERSION is installed, its managed guidance, two hooks, and hourly Luna helper are healthy, this task is its persistent home, and the migration controller completed with zero remaining native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows were outside this migration and may remain unchanged in the sidebar. > > From here, you can ask “how are you?”, “what tasks do you see?”, or “uninstall ThreadBear.” @@ -161,26 +167,26 @@ For later help, lead with a short capability card instead of a command dump. Ver ~/.local/bin/threadbear help ``` -The installed binary's help is the authoritative public command list. +The installed binary's help is the authoritative public command list. Cross-check the exact `threadbear-maintenance` heartbeat with the native automation control before describing hourly housekeeping as healthy. ## Uninstall Read the installed skill's **Title cleanup** and **Uninstall** sections. Run status and inventory, then ask: -> Want me to uninstall ThreadBear? I'll tidy the ThreadBear icons from native-addressable local Codex task titles, remove ThreadBear's local files and two hooks, and leave your tasks and other Codex settings alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. +> Want me to uninstall ThreadBear? I'll pause its Luna helper, tidy the ThreadBear icons from native-addressable local Codex task titles, and remove ThreadBear's local files, two hooks, and owned automation. Tasks already tucked into the archive will stay there, and your other tasks and Codex settings will be left alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. > > Should I go ahead? -Title cleanup must finish first through serial explicit native target calls with exact returned IDs and titles. Clean the persistent ThreadBear task last. Then show and run: +Pause the exact owned `threadbear-maintenance` heartbeat before title cleanup; refuse an ID, kind, name, or target mismatch. Title cleanup must finish through serial explicit native target calls with exact returned IDs and titles. Clean the persistent ThreadBear task last, delete the exact owned automation, then show and run: ```sh ~/.local/bin/threadbear uninstall --noninteractive --confirm --json ``` -Uninstall refuses while `phase:migration_running`; stop the controller first. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary. It preserves unrelated content and hook order. After removal, make no terminal title call and append no ThreadBear footer, because either would decorate the cleaned control-task title again. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance. +Uninstall refuses while `phase:migration_running` or a native archive operation is pending; reconcile it and stop the controller first. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary. It preserves unrelated content, hook order, unrelated automations, and the archived state of already archived tasks. After removal, make no terminal title call and append no ThreadBear footer, because either would decorate the cleaned control-task title again. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance. ## Maintainer verification A release is ready only after unit and integration tests, the 1,500-line absolute shipped-logic gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task controller fixtures, and controller resume, interruption, and failure cases. -Also execute every lifecycle command printed here against the release candidate. Confirm that `INSTALL.md` and `site/install` are byte-identical and that the hosted `threadbear.sh/install` serves the reviewed guide before announcing publication. +Tests and fixtures alone are not shipping proof. Also execute every lifecycle command printed here against the reviewed release candidate and exercise each changed native lifecycle against a real, recoverable Codex test task through the supported native control. Exercise changed download/update behavior against the official release service in an isolated installation. Record exact candidate SHA, task/release IDs, before/after state, results, and cleanup without visual inspection. Confirm that `INSTALL.md` and `site/install` are byte-identical and that the hosted `threadbear.sh/install` serves the reviewed guide before announcing publication. diff --git a/README.md b/README.md index f6e391e..969ad21 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The canonical shape is ` [ → ]`. ThreadBear owns only d Open [INSTALL.md](INSTALL.md) in a new Codex task and follow the guided preview, consent, persistent-home setup, and supervised controller migration. -ThreadBear installs a standalone Go binary, one small private state file, managed guidance, and two Codex hooks. The initiating task becomes the persistent `🧵🐻 ThreadBear 🐻🧵` home; one ephemeral controller owns installation migration so that home returns promptly. +ThreadBear installs a standalone Go binary, one small private state file, managed guidance, two Codex hooks, and one consented hourly Luna heartbeat. The initiating task becomes the persistent `🧵🐻 ThreadBear 🐻🧵` home; one ephemeral controller owns installation migration so that home returns promptly, while the heartbeat later handles quiet housekeeping from that task. ## Commands @@ -26,6 +26,7 @@ ThreadBear installs a standalone Go binary, one small private state file, manage threadbear install threadbear inventory threadbear migration +threadbear maintenance threadbear status threadbear self-test threadbear uninstall @@ -34,10 +35,10 @@ threadbear version Every command accepts `--json`. `inventory` is read-only and includes every native-addressable unarchived local Codex Desktop or CLI task, including projectless tasks, excluding the persisted main and controller tasks. Rollout-only internal records and older signed-in ChatGPT chat-history rows that Codex's native title setter cannot enumerate or rename are excluded. Those chat-history rows may remain unchanged in the Desktop sidebar even after local migration completes. `status` reports `ready:true` only after `migration_complete`; the installed binary's `help` output is authoritative. -From the persistent ThreadBear task, ask to “strip title icons” at any time. The control task serially removes all leading ThreadBear status marks through the same native setter and exact Pre/Post verification used by ordinary turns. Guided uninstall always completes that cleanup before removing ThreadBear's local state and hooks. +From the persistent ThreadBear task, ask to “strip title icons” at any time. The control task serially removes all leading ThreadBear status marks through the same native setter and exact Pre/Post verification used by ordinary turns. The same task's hourly Luna helper can archive only deterministically eligible, ThreadBear-owned complete user tasks after 14 quiet days, and restore only archives recorded in its private ownership ledger. Guided uninstall always pauses that helper and completes title cleanup before removing ThreadBear's local state, hooks, and owned automation. ## Boundaries -ThreadBear does not install a daemon, schedule heartbeats, archive ordinary tasks, add token counts, update itself in the background, or edit Codex private UI storage. It adds no model call or narration to ordinary turns. Installation uses one serial native-writing controller and adaptive waves of read-only Luna-medium workers only when genuinely ambiguous history cannot be classified deterministically; workers classify and never write titles. +ThreadBear installs no daemon or LaunchAgent. One explicitly consented hourly Codex heartbeat runs maintenance from the persistent Luna-medium task and stays quiet on no-op runs. The CLI alone selects archive candidates, stages one operation, and reconciles ownership; Luna calls Codex's supported native archive control and never edits private UI storage or interprets prose to add targets. ThreadBear does not add token counts or update itself in the background yet, and it adds no model call or narration to ordinary turns. Installation uses one serial native-writing controller and adaptive waves of read-only Luna-medium workers only when genuinely ambiguous history cannot be classified deterministically; workers classify and never write titles. See [architecture](docs/architecture.md), [compatibility](docs/compatibility.md), and the [status footer convention](docs/status-convention.md). diff --git a/assets/help.txt b/assets/help.txt index 51445ff..0119e38 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -6,6 +6,7 @@ Usage: Commands: install Preview or install ThreadBear inventory Classify native-addressable tasks for guided setup + maintenance Plan and reconcile safe native task archives migration Record the single installation migration controller phase status Check the installed helper, hooks, and migration phase self-test Validate a release candidate @@ -19,3 +20,9 @@ Install flags: Migration flags: --phase PHASE migration_running, migration_complete, or migration_failed --controller-task-id ID + +Maintenance flags: + --archive TASK_ID Stage or reconcile one eligible archive + --restore TASK_ID Stage or reconcile one ThreadBear-owned restore + --cancel TASK_ID Clear one known-unapplied pending operation + --archive-after-days N Quiet completed days required (default 14) diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index 1d35e79..e0f9f5f 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: threadbear -description: Install, inspect, migrate, verify, or uninstall the local ThreadBear title manager for Codex Desktop on macOS. +description: Install, inspect, migrate, maintain, verify, or uninstall the local ThreadBear title manager for Codex Desktop on macOS. --- # ThreadBear @@ -9,7 +9,7 @@ Be warm, brief, and lightly bear-themed. Explain visible outcomes before command ## Help -For a help-shaped request, start with a short capability card: ThreadBear keeps Codex Desktop titles useful through two native title calls per ordinary turn, while its hooks deterministically preserve each task's subject. ThreadBear adds no model call or narration to ordinary turns. +For a help-shaped request, start with a short capability card: ThreadBear keeps Codex Desktop titles useful through two native title calls per ordinary turn, while its hooks deterministically preserve each task's subject. One hourly Luna helper can quietly tuck away owned, completed tasks after 14 inactive days. ThreadBear adds no extra model call or narration to ordinary turns. Run `~/.local/bin/threadbear status --json` before saying ThreadBear is installed or healthy. Use `~/.local/bin/threadbear help` as the authoritative public command reference. @@ -19,6 +19,8 @@ Show a command before running it. Ask for explicit consent before any lifecycle | --- | --- | | "How are you?" | `~/.local/bin/threadbear status --json` | | "What tasks do you see?" | `~/.local/bin/threadbear inventory --json` | +| "Run maintenance now" | Follow **Maintenance** below. | +| "Bring back archived task TASK_ID" | Follow the restore path in **Maintenance**. | | "Strip title icons" | Follow **Title cleanup** below from the persisted ThreadBear task. | | "Install ThreadBear" | Follow **Install** below. | | "Uninstall ThreadBear" | Follow **Uninstall** below. | @@ -26,14 +28,15 @@ Show a command before running it. Ask for explicit consent before any lifecycle ## Install 1. Read the current install guide and the candidate's help output. Check macOS, architecture, Codex, HTTPS access, and candidate self-test without changing the machine. Resolve the exact current task ID with supported Codex task tooling. -2. Run the exact dry run with `--control-task-id CURRENT_TASK_ID`. Explain the complete effect: adopting that task as the persistent home, the local binary, one small private state file, one managed AGENTS block, this skill, and two hook entries. Explain that migration covers native-addressable local Codex tasks; older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and stay unchanged. -3. Show the recommended setup and ask once for consent. A clear yes to the unchanged complete recommendation is installation consent. Ask again only if the recommendation changed, the answer was ambiguous, or this is a reinstall with a different effect. +2. Run the exact dry run with `--control-task-id CURRENT_TASK_ID`. Explain the complete effect: adopting that task as the persistent home, the local binary, one small private state file, one managed AGENTS block, this skill, two hook entries, and one owned hourly Luna heartbeat. Explain that migration covers native-addressable local Codex tasks; older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and stay unchanged. +3. Show the recommended setup and include: “A small Luna helper checks in hourly, then stays quiet when there is nothing to do.” and “Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them.” Ask once for consent. A clear yes to the unchanged complete recommendation is installation consent. Ask again only if the recommendation changed, the answer was ambiguous, or this is a reinstall with a different effect. 4. Run the confirmed install with the same ID and verify `version`, `self-test`, and `inventory`. A fresh result is `migration_pending`: no controller has started and nothing is running yet. On reinstall, omit the flag only when `status --json` already reports the persisted main task; never replace it with the launching task. 5. Before any migration, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and keep that task selected. -6. For an ordinary guided installation, never use visual inspection, computer control, screenshots, or Codex `/hooks`, and never ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate. Visual hook verification belongs only to **Debug canaries** when the install result explicitly contains `debug_canaries:true`. -7. Create exactly one background migration controller without opening, selecting, or navigating to it, then immediately record it with `migration --phase migration_running`. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave `migration_pending` unchanged and report that nothing is running plus the exact retry action. -8. Give the controller the **Migration controller** protocol below. Supervise it from the persistent task with compact task waits, reporting only each 25-title milestone or phase change. Do not end the installation turn while durable status is `migration_pending` or `migration_running`. -9. After the controller returns, run `status --json` and `inventory --json`. Claim success only at `migration_complete` with zero remaining native-addressable local rows, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. At `migration_failed`, say migration stopped and is not still working, report applied and remaining counts, name the cause, and give one exact resume action using the same controller ID. +6. Create or update one paused hourly heartbeat through the native automation control. Its ID is `threadbear-maintenance`, name is “ThreadBear maintenance,” target is the persisted main task, and prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, and stay quiet when nothing changes.” Reuse only an exact ID/name/kind/target match. A collision stops installation without changing the other automation; never create a cron job or duplicate. If the call fails, report the partial install and stop instead of asking the user to repair it manually. On a reinstall that already reports `migration_complete`, it may be active immediately. +7. For an ordinary guided installation, never use visual inspection, computer control, screenshots, or Codex `/hooks`, and never ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate. Visual hook verification belongs only to **Debug canaries** when the install result explicitly contains `debug_canaries:true`. +8. Create exactly one background migration controller without opening, selecting, or navigating to it, then immediately record it with `migration --phase migration_running`. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave `migration_pending` unchanged and report that nothing is running plus the exact retry action. +9. Give the controller the **Migration controller** protocol below. Supervise it from the persistent task with compact task waits, reporting only each 25-title milestone or phase change. Do not end the installation turn while durable status is `migration_pending` or `migration_running`. +10. After the controller returns, run `status --json` and `inventory --json`, then verify and activate the exact owned heartbeat with native automation controls. Claim success only at `migration_complete` with zero remaining native-addressable local rows and one active healthy maintenance automation, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. Leave the heartbeat paused at `migration_pending`, `migration_running`, or `migration_failed`. At `migration_failed`, say migration stopped and is not still working, report applied and remaining counts, name the cause, and give one exact resume action using the same controller ID. For a large existing workspace, say this before migration: @@ -41,9 +44,22 @@ For a large existing workspace, say this before migration: Do not claim success from installed files, a native setter return, or partial counts. +## Maintenance + +The owned `threadbear-maintenance` heartbeat runs this section from the persistent ThreadBear task. Luna orchestrates; the CLI alone chooses archive eligibility and records ownership. Never inspect task prose to add a target, and never edit Codex's SQLite archive field. + +1. Run `~/.local/bin/threadbear maintenance --json`. If it reports a pending operation, reconcile that operation before any new archive. If it reports no pending operation and no candidates, stay silent. +2. For each candidate in stable order, run `~/.local/bin/threadbear maintenance --archive TASK_ID --json` immediately before mutation and require that exact ID with `action:"archive"` and `pending:true`. Call native `codex_app__set_thread_archived` once with that task ID and `archived:true`, without opening or selecting the task. Rerun the exact maintenance command and require `reconciled:true` before continuing. +3. If the native result is unknown or the reconciliation still says pending, do not repeat the mutation. Read the task with native task controls, rerun maintenance, and stop with the pending transaction intact unless the CLI authoritatively reconciles it. If the native call returned a definite failure and a native read confirms the original archive state, run `maintenance --cancel TASK_ID --json` to clear that known-unapplied operation. Never cancel an unknown or in-flight result. Title, footer, activity, kind, identity, or archive drift makes the CLI fail closed. +4. Process one native archive operation at a time. Finish with a no-target maintenance pass and require no pending operation. Report only archived task subjects/counts or an error; do not narrate healthy no-op runs. + +For a user-requested restore, verify the request from the persistent ThreadBear task, then run `maintenance --restore TASK_ID --json`. Continue only for `action:"restore"`, call the native archive control once with `archived:false`, and rerun the same command until `reconciled:true`. The CLI accepts only ThreadBear-owned archives and restarts that task's 14-day quiet clock. A user-archived task is never adopted. A task manually restored through Codex is detected on the next pass, removed from the ownership ledger, and receives the same fresh quiet clock. + +To change the quiet window, pass the requested positive `--archive-after-days N` consistently to planning and staging and update the owned automation prompt. To disable archival, update the owned heartbeat prompt to omit this archive protocol; do not pause shared maintenance if it also owns other enabled work. + ## Status and inventory -`status --json` checks the installed binary, managed files, hooks, and state readability. It reports `installed:true` while artifacts are present, but `ready:true` only for `phase:migration_complete`. It does not mutate titles. `migration_pending` means no controller was recorded and returns the exact start action; status also repairs an older running-without-controller state to pending. `migration_running` always names the active controller. If that controller is missing or has a terminal lifecycle event from the current attempt, status atomically records `migration_failed`, explains that the controller stopped, and returns the exact resume action. It never infers failure from age, slow progress, or a prior attempt's terminal event. +`status --json` checks the installed binary, managed files, hooks, and state readability. It reports the expected `maintenance_automation_id`, pending native archive state, and owned archive count, but the agent must cross-check the exact heartbeat through native automation controls before calling it healthy. It reports `installed:true` while artifacts are present, but `ready:true` only for `phase:migration_complete`. It does not mutate titles. `migration_pending` means no controller was recorded and returns the exact start action; status also repairs an older running-without-controller state to pending. `migration_running` always names the active controller. If that controller is missing or has a terminal lifecycle event from the current attempt, status atomically records `migration_failed`, explains that the controller stopped, and returns the exact resume action. It never infers failure from age, slow progress, or a prior attempt's terminal event. `inventory --json` reads every native-addressable, unarchived local Codex Desktop or CLI task, including projectless tasks, excluding the persisted main and controller IDs. It excludes rollout-only internal records and older signed-in ChatGPT chat-history rows that Codex's native title setter cannot enumerate or rename. Treat its deterministic classifications, `status`, `action`, and `applied` evidence as authoritative only for that local catalog. Never describe zero inventory rows as proof that every visible sidebar row changed, and do not infer ThreadBear ownership from an icon or arrow alone. @@ -98,10 +114,11 @@ For ordinary on-demand cleanup, do not target the active control task: its requi ## Uninstall -1. Run `help`, `status --json`, and `inventory --json`. If `phase:migration_running`, stop the controller and let it record `migration_failed` before uninstalling. -2. Ask: “Want me to uninstall ThreadBear? I'll tidy the ThreadBear icons from native-addressable local Codex task titles, remove ThreadBear's local files and two hooks, and leave your tasks and other Codex settings alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. Should I go ahead?” Continue only after a clear yes. -3. Follow **Title cleanup** for every regular and controller task. Re-read and clean this persisted ThreadBear task last. Any drift, unknown result, or remaining decorated title stops uninstall before artifacts or ownership state are removed. -4. Run the confirmed uninstall. It refuses while migration is running, then removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary while preserving unrelated content and hook order. -5. Use the managed uninstall-turn exception: make no terminal title call and append no ThreadBear footer. Ask the user to restart Codex so already-open sessions cannot keep using snapshotted guidance. +1. Run `help`, `status --json`, `inventory --json`, and inspect the exact owned `threadbear-maintenance` heartbeat. If migration or archive work is pending, reconcile it first. Refuse any automation ID/name/kind/target mismatch. +2. Ask: “Want me to uninstall ThreadBear? I'll pause its Luna helper, tidy the ThreadBear icons from native-addressable local Codex task titles, and remove ThreadBear's local files, two hooks, and owned automation. Tasks already tucked into the archive will stay there, and your other tasks and Codex settings will be left alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. Should I go ahead?” Continue only after a clear yes. +3. Pause the exact owned heartbeat so it cannot race cleanup. +4. Follow **Title cleanup** for every regular and controller task. Re-read and clean this persisted ThreadBear task last. Any drift, unknown result, or remaining decorated title stops uninstall before artifacts or ownership state are removed. +5. Delete the exact owned heartbeat, then run the confirmed uninstall. It refuses while migration or archive mutation is pending, preserves unrelated automation/config order, and leaves already archived tasks archived while removing ThreadBear's local ownership ledger. +6. Use the managed uninstall-turn exception: make no terminal title call and append no ThreadBear footer. Ask the user to restart Codex so already-open sessions cannot keep using snapshotted guidance. Thank the user and invite optional feedback at `eric@litman.org`. Never remove artifacts before title cleanup has completed. diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index cef120b..cb88e10 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -113,6 +113,9 @@ func uninstall(ctx context.Context, confirmed bool) (any, error) { if value.Phase == phaseMigrationRunning { return nil, errors.New("cannot uninstall while installation migration is running; stop the controller first") } + if value.ArchivePending != nil { + return nil, errors.New("cannot uninstall while a native archive operation is pending; reconcile it first") + } if value.MainTaskID != "" { tasks, scanErr := inventory(ctx) if scanErr != nil { @@ -159,7 +162,7 @@ func status(ctx context.Context) (any, error) { } value, stateErr := reconcileMigration(ctx) err = errors.Join(err, validateFile(p.skill, assets.SkillManagedContent), validateFile(p.agents, managedBlock()), stateErr) - result := map[string]any{"ready": err == nil && value.Phase == phaseMigrationComplete && value.MainTaskID != "", "installed": err == nil, "version": version, "phase": value.Phase, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID} + result := map[string]any{"ready": err == nil && value.Phase == phaseMigrationComplete && value.MainTaskID != "", "installed": err == nil, "version": version, "phase": value.Phase, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID, "maintenance_automation_id": maintenanceAutomationID, "archive_pending": value.ArchivePending != nil, "owned_archives": len(value.Archives)} if value.MigrationFailure != "" { result["migration_failure"] = value.MigrationFailure result["next_action"] = "resume migration from the ThreadBear task" diff --git a/cmd/threadbear/main.go b/cmd/threadbear/main.go index e58bb1f..5b291a0 100644 --- a/cmd/threadbear/main.go +++ b/cmd/threadbear/main.go @@ -61,6 +61,12 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. phase := flags.String("phase", "", "migration phase: migration_running, migration_complete, or migration_failed") controllerTaskID := flags.String("controller-task-id", "", "ephemeral migration controller task") action = func() (any, error) { return transitionMigration(ctx, *phase, *controllerTaskID) } + case "maintenance": + archive := flags.String("archive", "", "stage or reconcile one eligible task archive") + restore := flags.String("restore", "", "stage or reconcile one ThreadBear-owned restore") + cancel := flags.String("cancel", "", "cancel one known-unapplied native archive operation") + days := flags.Int("archive-after-days", 14, "quiet days before a completed task is eligible") + action = func() (any, error) { return maintenance(ctx, *archive, *restore, *cancel, *days) } case "status": action = func() (any, error) { return status(ctx) } case "self-test": diff --git a/cmd/threadbear/maintenance.go b/cmd/threadbear/maintenance.go new file mode 100644 index 0000000..c448385 --- /dev/null +++ b/cmd/threadbear/maintenance.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "sort" + "time" +) + +const maintenanceAutomationID = "threadbear-maintenance" + +var maintenanceNow = time.Now + +type archiveTask struct { + ID, Title, RolloutPath string + Archived, Visible bool +} + +func archiveTasks(ctx context.Context) ([]archiveTask, error) { + db, err := openIndex() + if err != nil { + return nil, err + } + defer db.Close() + rows, err := db.QueryContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), archived, preview<>'' + FROM threads WHERE source IN ('vscode','cli') AND COALESCE(thread_source,'') IN ('','user') ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var tasks []archiveTask + for rows.Next() { + var task archiveTask + if err := rows.Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Archived, &task.Visible); err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + +func archiveTaskByID(ctx context.Context, id string) (archiveTask, bool, error) { + tasks, err := archiveTasks(ctx) + if err != nil { + return archiveTask{}, false, err + } + for _, task := range tasks { + if task.ID == id { + return task, true, nil + } + } + return archiveTask{}, false, nil +} + +func archiveEligibility(task archiveTask, value *state, days int) (string, bool) { + if task.Archived || !task.Visible || task.ID == value.MainTaskID || task.ID == value.ControllerTaskID { + return "", false + } + record := value.Tasks[task.ID] + footer, exact := rolloutFooter(task.RolloutPath) + info, err := os.Stat(task.RolloutPath) + if err != nil || !exact || footer.Status != "complete" || record.Pending != nil || record.Status != "complete" || record.Last != task.Title { + return "", false + } + activity := info.ModTime().UTC() + if restored, err := time.Parse(time.RFC3339Nano, record.ArchiveActivity); err == nil && restored.After(activity) { + activity = restored + } + return activity.Format(time.RFC3339Nano), !activity.After(maintenanceNow().UTC().AddDate(0, 0, -days)) +} + +func maintenance(ctx context.Context, archiveID, restoreID, cancelID string, days int) (any, error) { + actions := 0 + for _, id := range []string{archiveID, restoreID, cancelID} { + if id != "" { + actions++ + } + } + if days < 1 || actions > 1 { + return nil, errors.New("maintenance requires positive archive days and at most one target action") + } + result := map[string]any{"ready": true, "automation_id": maintenanceAutomationID, "archive_after_days": days} + err := newStore(stateDir()).update(func(value *state) (bool, error) { + if value.MainTaskID == "" || value.Phase != phaseMigrationComplete { + return false, errors.New("maintenance requires a completed ThreadBear installation") + } + changed := false + if value.Archives == nil { + value.Archives, changed = map[string]bool{}, true + } + requestedAction, requestedID := "archive", archiveID + if restoreID != "" { + requestedAction, requestedID = "restore", restoreID + } + if pending := value.ArchivePending; pending != nil { + task, found, err := archiveTaskByID(ctx, pending.TaskID) + if err != nil { + return changed, err + } + applied := found && pending.Action == "archive" && task.Archived || found && pending.Action == "restore" && !task.Archived + if applied { + if pending.Action == "archive" { + value.Archives[pending.TaskID] = true + } else { + delete(value.Archives, pending.TaskID) + record := value.Tasks[pending.TaskID] + record.ArchiveActivity = maintenanceNow().UTC().Format(time.RFC3339Nano) + value.Tasks[pending.TaskID] = record + } + value.ArchivePending, changed = nil, true + if requestedID == pending.TaskID && requestedAction == pending.Action { + result["reconciled"], result["task_id"], result["action"] = true, pending.TaskID, pending.Action + return changed, nil + } + } else { + if cancelID != "" { + unapplied := found && pending.Action == "archive" && !task.Archived || found && pending.Action == "restore" && task.Archived + if cancelID != pending.TaskID || !unapplied { + return changed, errors.New("cancel requires the exact known-unapplied pending task") + } + value.ArchivePending = nil + result["cancelled"], result["task_id"], result["action"] = true, pending.TaskID, pending.Action + return true, nil + } + if requestedID != pending.TaskID || requestedAction != pending.Action { + result["pending"] = pending + return changed, nil + } + if pending.Action == "archive" { + activity, eligible := archiveEligibility(task, value, days) + if !eligible || task.Title != pending.Title || activity != pending.Activity { + return changed, errors.New("pending archive no longer matches an eligible task") + } + } else if !found || !task.Archived || !value.Archives[pending.TaskID] { + return changed, errors.New("pending restore no longer matches an owned archive") + } + result["pending"], result["task_id"], result["action"] = true, pending.TaskID, pending.Action + return changed, nil + } + } + manuallyRestored := false + for id := range value.Archives { + task, found, err := archiveTaskByID(ctx, id) + if err != nil { + return changed, err + } + if found && !task.Archived { + delete(value.Archives, id) + record := value.Tasks[id] + record.ArchiveActivity = maintenanceNow().UTC().Format(time.RFC3339Nano) + value.Tasks[id] = record + changed = true + manuallyRestored = manuallyRestored || restoreID == id + } + } + if manuallyRestored { + result["reconciled"], result["task_id"], result["action"] = true, restoreID, "restore" + return changed, nil + } + if cancelID != "" { + return changed, errors.New("no pending native archive operation to cancel") + } + if restoreID != "" { + task, found, err := archiveTaskByID(ctx, restoreID) + if err != nil || !found || !task.Archived || !value.Archives[restoreID] { + return changed, errors.Join(err, errors.New("restore requires a ThreadBear-owned archived task")) + } + value.ArchivePending = &archiveOperation{TaskID: restoreID, Action: "restore", Title: task.Title} + result["task_id"], result["action"], result["pending"] = restoreID, "restore", true + return true, nil + } + if archiveID != "" { + task, found, err := archiveTaskByID(ctx, archiveID) + activity, eligible := archiveEligibility(task, value, days) + if err != nil || !found || !eligible { + return changed, errors.Join(err, fmt.Errorf("task %q is not eligible for archive", archiveID)) + } + value.ArchivePending = &archiveOperation{TaskID: archiveID, Action: "archive", Title: task.Title, Activity: activity} + result["task_id"], result["action"], result["pending"] = archiveID, "archive", true + return true, nil + } + tasks, err := archiveTasks(ctx) + if err != nil { + return changed, err + } + candidates := []map[string]string{} + for _, task := range tasks { + if activity, eligible := archiveEligibility(task, value, days); eligible { + candidates = append(candidates, map[string]string{"task_id": task.ID, "inactive_since": activity}) + } + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i]["task_id"] < candidates[j]["task_id"] }) + result["candidates"], result["owned_archives"] = candidates, len(value.Archives) + return changed, nil + }) + return result, err +} diff --git a/cmd/threadbear/maintenance_test.go b/cmd/threadbear/maintenance_test.go new file mode 100644 index 0000000..b969c00 --- /dev/null +++ b/cmd/threadbear/maintenance_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "os" + "testing" + "time" +) + +func TestMaintenancePlansOnlyInactiveOwnedCompleteUserTasks(t *testing.T) { + root, db := testIndex(t) + now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -14) + maintenanceNow = func() time.Time { return now } + t.Cleanup(func() { maintenanceNow = time.Now }) + for _, id := range []string{"eligible", "fresh", "blocked", "drift", "main", "controller", "worker", "automation", "archived"} { + path := addTask(t, db, root, id, "✅ "+id, nil, "vscode", 0) + writeMigrationRollout(t, path, "🧵🐻 complete") + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='archived'`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET thread_source='subagent' WHERE id='worker'`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET thread_source='automation' WHERE id='automation'`); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filepathForTask(root, "fresh"), now, now); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationComplete + for _, id := range []string{"eligible", "fresh", "drift", "main", "controller", "worker", "automation", "archived"} { + value.Tasks[id] = taskState{Subject: id, Last: "✅ " + id, Status: "complete"} + } + value.Tasks["blocked"] = taskState{Subject: "blocked", Last: "✅ blocked", Status: "blocked"} + value.Tasks["drift"] = taskState{Subject: "drift", Last: "✅ another title", Status: "complete"} + return true, nil + }); err != nil { + t.Fatal(err) + } + result, err := maintenance(context.Background(), "", "", "", 14) + if err != nil { + t.Fatal(err) + } + candidates := result.(map[string]any)["candidates"].([]map[string]string) + if len(candidates) != 1 || candidates[0]["task_id"] != "eligible" || candidates[0]["inactive_since"] != old.Format(time.RFC3339Nano) { + t.Fatalf("candidates = %#v", candidates) + } +} + +func TestMaintenanceArchiveRestoreAndInterruptionReconcile(t *testing.T) { + root, db := testIndex(t) + now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -30) + maintenanceNow = func() time.Time { return now } + t.Cleanup(func() { maintenanceNow = time.Now }) + path := addTask(t, db, root, "target", "✅ target", nil, "vscode", 0) + writeMigrationRollout(t, path, "🧵🐻 complete") + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.MainTaskID, value.Phase = "main", phaseMigrationComplete + value.Tasks["target"] = taskState{Subject: "target", Last: "✅ target", Status: "complete"} + return true, nil + }); err != nil { + t.Fatal(err) + } + staged, err := maintenance(context.Background(), "target", "", "", 14) + if err != nil || staged.(map[string]any)["action"] != "archive" { + t.Fatalf("stage archive = %#v, %v", staged, err) + } + if _, err := maintenance(context.Background(), "", "other", "", 14); err != nil { + t.Fatalf("pending plan should report, not fail: %v", err) + } + if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='target'`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET preview='' WHERE id='target'`); err != nil { + t.Fatal(err) + } + reconciled, err := maintenance(context.Background(), "target", "", "", 14) + if err != nil || reconciled.(map[string]any)["reconciled"] != true { + t.Fatalf("reconcile archive = %#v, %v", reconciled, err) + } + if _, err := maintenance(context.Background(), "", "target", "", 14); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='target'`); err != nil { + t.Fatal(err) + } + restored, err := maintenance(context.Background(), "", "target", "", 14) + if err != nil || restored.(map[string]any)["reconciled"] != true { + t.Fatalf("reconcile restore = %#v, %v", restored, err) + } + plan, err := maintenance(context.Background(), "", "", "", 14) + if err != nil || len(plan.(map[string]any)["candidates"].([]map[string]string)) != 0 { + t.Fatalf("restored task was immediately eligible: %#v, %v", plan, err) + } + value, _ := newStore(stateDir()).read() + if value.Archives["target"] || value.ArchivePending != nil || value.Tasks["target"].ArchiveActivity != now.Format(time.RFC3339Nano) { + t.Fatalf("archive state = %#v", value) + } +} + +func TestMaintenanceRejectsDriftAndUninstallWithPendingArchive(t *testing.T) { + root, db := testIndex(t) + now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) + maintenanceNow = func() time.Time { return now } + t.Cleanup(func() { maintenanceNow = time.Now }) + path := addTask(t, db, root, "target", "✅ target", nil, "vscode", 0) + writeMigrationRollout(t, path, "🧵🐻 complete") + old := now.AddDate(0, 0, -15) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.MainTaskID, value.Phase = "main", phaseMigrationComplete + value.Tasks["target"] = taskState{Subject: "target", Last: "✅ target", Status: "complete"} + return true, nil + }); err != nil { + t.Fatal(err) + } + if _, err := maintenance(context.Background(), "target", "", "", 14); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET name='User rename' WHERE id='target'`); err != nil { + t.Fatal(err) + } + if _, err := maintenance(context.Background(), "target", "", "", 14); err == nil { + t.Fatal("pending archive accepted title drift") + } + if _, err := uninstall(context.Background(), true); err == nil { + t.Fatal("uninstall accepted pending archive") + } + cancelled, err := maintenance(context.Background(), "", "", "target", 14) + if err != nil || cancelled.(map[string]any)["cancelled"] != true { + t.Fatalf("cancel pending archive = %#v, %v", cancelled, err) + } + value, _ := newStore(stateDir()).read() + if value.ArchivePending != nil { + t.Fatal("cancel left a pending archive") + } +} + +func TestMaintenanceDetectsManualNativeRestore(t *testing.T) { + root, db := testIndex(t) + now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) + maintenanceNow = func() time.Time { return now } + t.Cleanup(func() { maintenanceNow = time.Now }) + addTask(t, db, root, "target", "✅ target", nil, "vscode", 1) + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.MainTaskID, value.Phase = "main", phaseMigrationComplete + value.Tasks["target"] = taskState{Subject: "target", Last: "✅ target", Status: "complete"} + value.Archives = map[string]bool{"target": true} + return true, nil + }); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='target'`); err != nil { + t.Fatal(err) + } + if _, err := maintenance(context.Background(), "", "", "", 14); err != nil { + t.Fatal(err) + } + value, _ := newStore(stateDir()).read() + if value.Archives["target"] || value.Tasks["target"].ArchiveActivity != now.Format(time.RFC3339Nano) { + t.Fatalf("manual restore state = %#v", value) + } +} + +func filepathForTask(root, id string) string { return root + "/" + id + ".jsonl" } diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index 4a79daf..1fcbcbb 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -50,6 +50,12 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { "Small local footprint: one binary in ~/.local/bin, a skill, and two hooks.", "One persistent thread, 🧵🐻 ThreadBear 🐻🧵, for changing config and uninstalling.", "Deterministic classification first; Luna medium only for ambiguity.", + "A small Luna helper checks in hourly, then stays quiet when there is nothing to do.", + "Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them.", + "threadbear-maintenance", + "native automation control", + "paused hourly heartbeat", + "activate the exact owned heartbeat", "--control-task-id", "--noninteractive --confirm --json", "~/.local/bin/threadbear inventory --json", @@ -185,15 +191,24 @@ func TestPublishedInstallGuideDoesNotRequestConsentAfterFailedChecks(t *testing. } } -func TestHomepageDoesNotPromiseRemovedCapabilities(t *testing.T) { +func TestHomepageMatchesNativeMaintenanceCapabilities(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "site", "index.html")) if err != nil { t.Fatal(err) } page := string(data) + for _, requiredClaim := range []string{ + "Create a new session in ChatGPT Desktop using Luna on medium effort, and paste this in:", + "14 quiet days", + "native task control", + "never archives active work", + "installs no LaunchAgent", + } { + if !strings.Contains(page, requiredClaim) { + t.Errorf("homepage is missing maintenance capability claim %q", requiredClaim) + } + } for _, removedClaim := range []string{ - "safely archived", - "Only completed inactive tasks can be auto-archived", "ThreadBear can update itself by default", "zero-token idle", "Unchanged heartbeats use zero model tokens", @@ -201,8 +216,6 @@ func TestHomepageDoesNotPromiseRemovedCapabilities(t *testing.T) { "exits silently", "update-check", "version-change", - "LaunchAgent", - "heartbeat", "control task", } { if strings.Contains(page, removedClaim) { @@ -235,7 +248,7 @@ func TestShippedLogicStaysBelowAbsoluteLineCeiling(t *testing.T) { if err != nil { t.Fatal(err) } - paths = append(paths, filepath.Join(root, "install.sh"), filepath.Join(root, "site", "install.sh")) + paths = append(paths, filepath.Join(root, "install.sh")) count := 0 for _, path := range paths { if strings.HasSuffix(path, "_test.go") { diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index 3ec46ea..ddef214 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -24,11 +24,18 @@ type pendingProposal struct { Action string `json:"action,omitempty"` } type taskState struct { - Subject string `json:"subject"` - Last string `json:"last,omitempty"` - Status string `json:"status,omitempty"` - Action string `json:"action,omitempty"` - Pending *pendingProposal `json:"pending,omitempty"` + Subject string `json:"subject"` + Last string `json:"last,omitempty"` + Status string `json:"status,omitempty"` + Action string `json:"action,omitempty"` + ArchiveActivity string `json:"archive_activity,omitempty"` + Pending *pendingProposal `json:"pending,omitempty"` +} +type archiveOperation struct { + TaskID string `json:"task_id"` + Action string `json:"action"` + Title string `json:"title"` + Activity string `json:"activity,omitempty"` } type state struct { Format int `json:"format"` @@ -38,6 +45,8 @@ type state struct { MigrationStarted string `json:"migration_started_at,omitempty"` MigrationFailure string `json:"migration_failure,omitempty"` Tasks map[string]taskState `json:"tasks"` + Archives map[string]bool `json:"archives,omitempty"` + ArchivePending *archiveOperation `json:"archive_pending,omitempty"` } type footer struct{ Status, Action string } type store struct{ dir string } diff --git a/docs/architecture.md b/docs/architecture.md index 92c837f..0ccc99c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -ThreadBear is one small Go executable, one private atomic JSON file, one managed instruction block, one installed skill, and two Codex hook entries. It has no LaunchAgent or background writer. +ThreadBear is one small Go executable, one private atomic JSON file, one managed instruction block, one installed skill, two Codex hook entries, and one consented hourly Codex heartbeat attached to the persistent Luna-medium task. It has no LaunchAgent or detached writer. ## Ordinary turn @@ -17,12 +17,20 @@ The canonical title is ` [ → State is keyed by task ID and contains the persistent main-task ID, the single migration-controller ID, one migration phase, the canonical subject, the last verified rendering, and at most one pending proposal. A pending proposal lets a later call recognize setter success when Post was lost. State is private, locked, and atomically replaced. Ordinary title proposals are never queued for later repair. +## Maintenance and archive ownership + +The owned `threadbear-maintenance` heartbeat asks the installed skill to run a deterministic maintenance pass. The CLI reads only user tasks from the local index, excludes the main task, controller, automations, and subagents, and accepts only an exact current ThreadBear-owned `complete` title whose latest terminal footer is still complete. Inactivity starts at the later of the target rollout's last write and its last ThreadBear restore; the default threshold is 14 days. + +Archive and restore use one prepare-native-reconcile transaction. The CLI revalidates identity, kind, title, footer, status, activity, and current archive state, then persists one pending operation. Luna calls Codex's supported native archive control once without opening the target. A second deterministic pass observes native archive state and commits or removes ThreadBear ownership. Interruption leaves the pending operation durable; no new mutation or update may begin until it reconciles. Restore is allowed only for the owned archive ledger, so ThreadBear never adopts a user-created archive. + The visible title is limited to 60 UTF-16 units. Rendering first computes the bounded standalone status-and-subject display, then truncates or omits only the appended action without changing canonical state. The persisted main task may request one reserved cleanup marker for an explicit target. The same Pre/Post transaction re-reads the target, removes every consecutive leading ThreadBear status mark, stages the subject-only title, validates the exact native result, and repairs ownership state. The marker is denied for every other caller. Guided uninstall uses this serial operation for all active titles before deleting the hooks or ownership state; the same operation is available on demand from the persistent task. ## Installation and migration -Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order. It records `migration_pending` until a real controller ID is persisted, so an interrupted pre-controller install is never described as running. Ordinary guided installation trusts deterministic self-test and inventory evidence; Desktop visual inspection and computer control are debug/release-canary tools, never an end-user gate. The initiating task is recorded as the persistent ThreadBear home and stays selected while it creates and supervises exactly one background migration controller to a terminal phase. The controller inventories native-addressable unarchived local Codex Desktop and CLI tasks, excludes rollout-only internal records plus the main/controller IDs, and immediately begins a bounded deterministic title batch before fixed-surface Luna worker creation. Older signed-in ChatGPT chat-history rows can also render in the Desktop sidebar, but they are outside the pageable native inventory and explicit-target title mutation contract and remain unchanged. The guide discloses that boundary and never equates local zero-remaining evidence with every visible sidebar row. The controller classifies exact footers deterministically and uses adaptive waves of read-only Luna-medium workers only for genuinely ambiguous history, with `❔ ThreadBear could not classify` as the exact hook-accepted unknown proposal. It retains and awaits every successfully spawned worker even when a later spawn reaches collaboration capacity; worker results may complete out of order, but explicit native title writes remain serial and deterministic work never waits on ambiguous classification. Immediately before each write, the hook re-reads the target and adopts any newer rename. When an ownerless migration title begins with prior ThreadBear status marks, the controller boundary removes those marks before rendering so reinstall cannot compound decoration. +The owned heartbeat remains paused until migration is complete, so it cannot race or noisily fail during a long installation. A reinstall already at `migration_complete` may keep the same exact automation active. + +Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order, then creates or updates only the exact owned hourly heartbeat through native automation controls. It records `migration_pending` until a real controller ID is persisted, so an interrupted pre-controller install is never described as running. Ordinary guided installation trusts deterministic self-test and inventory evidence; Desktop visual inspection and computer control are debug/release-canary tools, never an end-user gate. The initiating task is recorded as the persistent ThreadBear home and stays selected while it creates and supervises exactly one background migration controller to a terminal phase. The controller inventories native-addressable unarchived local Codex Desktop and CLI tasks, excludes rollout-only internal records plus the main/controller IDs, and immediately begins a bounded deterministic title batch before fixed-surface Luna worker creation. Older signed-in ChatGPT chat-history rows can also render in the Desktop sidebar, but they are outside the pageable native inventory and explicit-target title mutation contract and remain unchanged. The guide discloses that boundary and never equates local zero-remaining evidence with every visible sidebar row. The controller classifies exact footers deterministically and uses adaptive waves of read-only Luna-medium workers only for genuinely ambiguous history, with `❔ ThreadBear could not classify` as the exact hook-accepted unknown proposal. It retains and awaits every successfully spawned worker even when a later spawn reaches collaboration capacity; worker results may complete out of order, but explicit native title writes remain serial and deterministic work never waits on ambiguous classification. Immediately before each write, the hook re-reads the target and adopts any newer rename. When an ownerless migration title begins with prior ThreadBear status marks, the controller boundary removes those marks before rendering so reinstall cannot compound decoration. Migration is rerunnable from the same controller ID and skips only inventory rows proven `applied: true` from exact committed ownership state. Native writes are serial. A timeout or unknown result records `migration_failed` until authoritative reconciliation; only a final zero-remaining inventory may record `migration_complete`. The persistent task supervises progress without becoming a second writer. Status repairs an older running-without-controller state to pending. Each real running transition records its attempt start; status reconciles a missing controller or a later terminal lifecycle event from stale `migration_running` to `migration_failed` without using age or a prior attempt as a failure signal. Rendered active-header and sidebar verification belongs in opt-in release QA. diff --git a/site/index.html b/site/index.html index 51f7e9d..83e171f 100644 --- a/site/index.html +++ b/site/index.html @@ -33,8 +33,9 @@
A local task-state bear for macOS

ThreadBear

Keeps Codex Desktop tasks usefully titled with two native calls per ordinary turn.

+

Create a new session in ChatGPT Desktop using Luna on medium effort, and paste this in:

Install ThreadBear — follow https://threadbear.sh/install
-

Paste that prompt into a new Codex task for a guided preview, approval, install, and verification.

+

ThreadBear will guide the preview, approval, install, and verification from there.

macOS 12+ · Apple silicon and Intel · no sudo · standalone pure-Go binary