From 16fa5a0178d945a2d9e6960d4a4c47e7d52bac4d Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 31 Jul 2026 21:28:59 -0400 Subject: [PATCH 1/2] feat: add non-blocking installation migration controller [BEAR-104] --- CHANGELOG.md | 4 + INSTALL.md | 47 +++++--- README.md | 9 +- assets/help.txt | 10 +- assets/skill/SKILL.md | 44 +++++--- cmd/threadbear/install.go | 154 ++++++++++----------------- cmd/threadbear/install_test.go | 127 ++++------------------ cmd/threadbear/lifecycle_test.go | 98 +++++++++++++++++ cmd/threadbear/main.go | 13 ++- cmd/threadbear/migration.go | 46 ++++++++ cmd/threadbear/migration_test.go | 8 +- cmd/threadbear/scan.go | 37 +++---- cmd/threadbear/site_contract_test.go | 36 +++++++ cmd/threadbear/state.go | 18 ++-- docs/architecture.md | 8 +- docs/release-checklist.md | 4 +- site/install | 47 +++++--- 17 files changed, 414 insertions(+), 296 deletions(-) create mode 100644 cmd/threadbear/lifecycle_test.go create mode 100644 cmd/threadbear/migration.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c44281b..f6c15b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- Made the initiating task ThreadBear's persistent home and moved installation migration to one resumable, serial native-title controller with honest phase reporting. + ### Fixed - Kept the guided installer welcome, readiness result, complete recommendation, and consent question visible together after the first turn finishes. diff --git a/INSTALL.md b/INSTALL.md index 6bd17f8..f2c3792 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -41,19 +41,22 @@ fi ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop, and HTTPS access to the official guide and GitHub Releases. Do not use `sudo`, grant Full Disk Access, or edit Codex private UI storage. +Resolve the canonical ID of this calling task with supported Codex task tooling and keep it as `MAIN_TASK_ID`. This initiating task becomes ThreadBear's persistent home. On a reinstall, use the persisted ThreadBear task ID from `status --json`; never adopt whichever task happened to launch the reinstall. + For an official release, run the verified bootstrap preview: ```sh -curl -fsSL https://threadbear.sh/install.sh | sh -s -- --dry-run --json +curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ + --control-task-id "$MAIN_TASK_ID" --dry-run --json ``` For an already-built local candidate, run: ```sh -/path/to/threadbear install --dry-run --json +/path/to/threadbear install --control-task-id "$MAIN_TASK_ID" --dry-run --json ``` -Require a successful candidate self-test and a dry-run limited to the binary, one small private state file, one managed AGENTS block, one installed skill, and two hook entries. Preserve unrelated AGENTS content and hook definitions in their existing order. +Require a successful candidate self-test and a dry-run limited to adopting the initiating task, the binary, one small private state file, one managed AGENTS block, one installed skill, and two hook entries. Preserve unrelated AGENTS content and hook definitions in their existing order. ## 2. Show the recommendation @@ -64,8 +67,9 @@ Only after every check and the dry run succeeds, compose one terminal final answ > - **Useful per-turn titles.** Each ordinary turn starts with a native running title and ends with a native title that matches its exact ThreadBear footer. > - **Stable subjects.** ThreadBear preserves the user-owned task subject, adopts later user renames, and bounds visible titles to 60 UTF-16 units. > - **Small local footprint.** One standalone Go binary, one private state file, managed guidance, and two deterministic Codex hooks. -> - **One foreground migration.** After a fresh-task canary, existing local unarchived tasks—including projectless tasks—can be updated through explicit native Desktop calls. +> - **One persistent home and one controller.** The initiating task becomes `🧵🐻 ThreadBear 🐻🧵`; after the fresh-task canary it starts exactly one ephemeral migration controller and returns promptly. > - **Deterministic first.** Exact historical footers are classified locally. Luna medium is reserved for genuinely ambiguous legacy history, with at most eight read-only workers; workers never write titles. +> - **Honest migration status.** Installation reports `migration_running` until the controller's native writes and final inventory prove every task applied; failures remain visible and resumable. > - **Honest cleanup.** Uninstall can remove ThreadBear-owned decoration or intentionally keep current titles before removing its local artifacts. > > Install ThreadBear with this recommended setup? @@ -80,13 +84,14 @@ For the verified official release, run: ```sh curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ + --control-task-id "$MAIN_TASK_ID" \ --noninteractive --confirm --json ``` For the verified local candidate, run: ```sh -/path/to/threadbear install --noninteractive --confirm --json +/path/to/threadbear install --control-task-id "$MAIN_TASK_ID" --noninteractive --confirm --json ``` Then verify the installed surfaces: @@ -98,21 +103,35 @@ Then verify the installed surfaces: ~/.local/bin/threadbear inventory --json ``` -Do not claim the hooks work merely because files were written. Codex snapshots hooks and managed guidance when a task starts, so verification must continue in a new task. +The install result must show `installed:true`, the exact `main_task_id`, and `phase:migration_running` unless a prior completed migration is being preserved. `ready:true` means `migration_complete`, not merely that artifacts were written. Do not claim the hooks work merely because files were written. Codex snapshots hooks and managed guidance when a task starts, so verification must continue in a new task. ## 4. Prove a fresh task and migrate -Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install**, **Bulk migration**, and **Rendered Desktop verification** sections. The installed skill is the canonical operation guide. +Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install**, **Migration controller**, and **Rendered Desktop verification** sections. The installed skill is the canonical operation guide. Before migration, tell the user: > The deterministic scan is already done and highly token-efficient. A large workspace can spend about three to five minutes in the native Desktop handoff. I'll report only real progress. Luna medium runs only for genuinely ambiguous legacy history. -Use Codex `/hooks` to inspect and trust the two installed definitions, then create a genuinely fresh Codex Desktop task. Prove that its first action is the native running-title call, its terminal call immediately precedes the footer, both exact native results pass through the two hooks, and both titles render in the active header and sidebar. Also prove that one explicit-target canary repaints only the intended mounted sidebar row. +Before any bulk work, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and prove that exact title in the active header and mounted sidebar. Then use Codex `/hooks` to inspect and trust the two installed definitions, create a genuinely fresh Codex Desktop task, and prove that its first action is the native running-title call, its terminal call immediately precedes the footer, both exact native results pass through the two hooks, and both titles render in the active header and sidebar. Also prove that one explicit-target canary repaints only the intended mounted sidebar row. + +After that canary passes, create exactly one ephemeral migration-controller task with a prompt containing the controller protocol from the installed ThreadBear skill. Persist its exact ID before it starts: -After that canary passes, inventory every local unarchived task. Classify exact historical footers deterministically. Use at most eight fresh Luna-medium workers only for items that remain genuinely ambiguous; workers classify only. Call the native setter with the explicit task ID and the classification's compact footer marker (or the documented unknown marker). The Pre hook immediately re-reads the target and preserves any newer rename before rewriting the title; Post verifies the exact returned pair. +```sh +~/.local/bin/threadbear migration \ + --phase migration_running \ + --controller-task-id "$CONTROLLER_TASK_ID" --json +``` + +End this persistent task promptly after the transition. It must not inventory, write, await, or poll the migration. The controller is the only migration writer: it processes one explicit target at a time, skips only rows already reporting `applied:true`, uses at most eight read-only Luna-medium workers for genuine ambiguity, and treats a timeout or unknown native result as `migration_failed` until authoritative reconciliation. It performs a final inventory with zero remaining rows, then records: + +```sh +~/.local/bin/threadbear migration \ + --phase migration_complete \ + --controller-task-id "$CONTROLLER_TASK_ID" --json +``` -Migration is rerunnable from scratch. Skip only inventory rows proven `applied: true`; a similar-looking but unowned title still goes through the native Pre/Post boundary. After the batch, rerun inventory and require every row to be applied. If interrupted, rerun inventory. +Only after that command succeeds may the controller archive itself. A stopped controller resumes with the same ID; a failed controller remains visible and resumable. Never claim installation completion from a setter return, accepted call, or partial count. ## 5. Close precisely @@ -120,7 +139,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, a fresh task completed both native title moments, and Codex Desktop rendered the result in its header and sidebar. Existing tasks were handled through the foreground migration you approved. +> Everything passed: ThreadBear VERSION is installed, its managed guidance and two hooks are healthy, this task is its persistent home, a fresh task completed both native title moments, Codex Desktop rendered the results in its header and sidebar, and the single migration controller completed with zero remaining tasks. > > From here, you can ask “how are you?”, “what tasks do you see?”, or “uninstall ThreadBear.” @@ -130,7 +149,7 @@ If official-download verification fails before mutation, say that installation p ## Help and status -For later help, lead with a short capability card instead of a command dump. Verify health before saying ThreadBear is active: +For later help, lead with a short capability card instead of a command dump. Verify the artifact and migration phase before saying ThreadBear is ready: ```sh ~/.local/bin/threadbear status --json @@ -149,10 +168,10 @@ Requested title cleanup must finish first through explicit native target calls w ~/.local/bin/threadbear uninstall --noninteractive --confirm --json ``` -Uninstall removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary. It preserves unrelated content and hook order. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance. +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. 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,000-line production-Go gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task migration fixtures, and the fresh Desktop matrix pass. Rendered header and sidebar screenshots are required; command exit codes and stored titles are not visual proof. +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, controller-resume and failure cases, and the fresh Desktop matrix pass. Rendered header and sidebar screenshots are required; command exit codes and stored titles are not visual proof. 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. diff --git a/README.md b/README.md index 6172e11..3d8839d 100644 --- a/README.md +++ b/README.md @@ -16,25 +16,26 @@ The canonical shape is ` [ → ]`. ThreadBear owns only d ## Install -Open [INSTALL.md](INSTALL.md) in a new Codex task and follow the guided preview, consent, install, migration, and rendered Desktop verification. +Open [INSTALL.md](INSTALL.md) in a new Codex task and follow the guided preview, consent, persistent-home setup, controller migration, and rendered Desktop verification. -ThreadBear installs a standalone Go binary, one small private state file, managed guidance, and two Codex hooks. +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. ## Commands ```text threadbear install threadbear inventory +threadbear migration threadbear status threadbear self-test threadbear uninstall threadbear version ``` -Every command accepts `--json`. `inventory` is read-only and includes every local unarchived task across source shapes, including projectless tasks. The installed binary's `help` output is authoritative. +Every command accepts `--json`. `inventory` is read-only and includes every local unarchived task across source shapes, including projectless tasks, excluding the persisted main and controller tasks. `status` reports `ready:true` only after `migration_complete`; the installed binary's `help` output is authoritative. ## Boundaries -ThreadBear does not archive 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 may use at most eight ephemeral Luna-medium workers only when genuinely ambiguous v2 history cannot be classified deterministically; those workers classify and never write titles. +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 at most eight ephemeral Luna-medium workers only when genuinely ambiguous history cannot be classified deterministically; those 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 bbdfbb2..7b5f0b6 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -6,7 +6,15 @@ Usage: Commands: install Preview or install ThreadBear inventory Classify local unarchived tasks for guided setup - status Check the installed helper and hooks + migration Record the single installation migration controller phase + status Check the installed helper, hooks, and migration phase self-test Validate a release candidate uninstall Remove ThreadBear while keeping current titles version Show the installed version + +Install flags: + --control-task-id ID Persist the initiating task as ThreadBear's home + +Migration flags: + --phase PHASE migration_running, migration_complete, or migration_failed + --controller-task-id ID diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index c8360b4..e166265 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -24,12 +24,14 @@ 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. -2. Run the exact dry run. Explain the complete effect: the local binary, one small private state file, one managed AGENTS block, this skill, and two hook entries. +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. 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. -4. Run the confirmed install and verify `version`, `self-test`, and `status`. -5. Use Codex `/hooks` to inspect and trust the two installed definitions. Then open a genuinely fresh Codex Desktop task and prove the first native call, the terminal native call, and the exact hook results before changing existing titles. Existing sessions may retain the hook snapshot they started with. -6. Follow **Bulk migration** for the current inventory, then prove a rendered active header and sidebar row in Codex Desktop. +4. Run the confirmed install with the same ID and verify `version`, `self-test`, and `status`. 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 prove that exact title in the active header and mounted sidebar. +6. Use Codex `/hooks` to inspect and trust the two installed definitions. Then open a genuinely fresh Codex Desktop task and prove the first native call, the terminal native call, and the exact hook results before changing existing titles. Existing sessions may retain the hook snapshot they started with. +7. After the canary passes, create exactly one ephemeral migration controller, record it with `migration --phase migration_running`, and end the persistent task promptly. The persistent task never performs, awaits, or polls bulk migration. +8. Give the controller the **Migration controller** protocol below. Do not claim completion until it records `migration_complete` after final inventory convergence. For a large existing workspace, say this before migration: @@ -39,19 +41,27 @@ Do not claim success until the installed checks, fresh-task canary, exact native ## Status and inventory -`status --json` checks the installed binary, managed files, hooks, and state readability. It does not mutate titles. +`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. -`inventory --json` reads every local, unarchived Codex task across source shapes, including projectless tasks. Treat its deterministic classifications and ownership evidence as authoritative. Do not infer ThreadBear ownership from an icon or arrow alone. +`inventory --json` reads every local, unarchived Codex task across source shapes, including projectless tasks, excluding the persisted main and controller IDs. Treat its deterministic classifications, `status`, `action`, and `applied` evidence as authoritative. Do not infer ThreadBear ownership from an icon or arrow alone. -## Bulk migration +## Migration controller -Bulk migration is foreground and rerunnable: +The controller is the only installation-migration writer and is rerunnable under one persisted controller ID: -1. Run `inventory --json` and report only counts and changed phase progress. -2. Accept exact historical ThreadBear footers deterministically. Send only genuinely ambiguous legacy items to fresh, read-only Luna-medium workers, with at most eight workers active. Each worker must inspect its assigned task through Codex's read-only task reader; workers classify and never write titles. Use `❔` when ambiguity remains. -3. For each unapplied item, call the native title setter with its explicit task ID and a compact marker: the exact valid footer for the classified status/action, or `❔ ThreadBear could not classify` for unknown. The Pre hook immediately re-reads that target, preserves any newer user rename, records the proposal, and rewrites the marker to the full desired title. -4. Require the native result to contain the exact target ID and returned title. The Post hook accepts only that exact pair and commits it. After the batch, rerun inventory and require every row to report `applied: true` before claiming success. -5. If interrupted or if any item fails, rerun inventory from scratch. Skip only rows already proven `applied: true`; a syntactically similar but unowned title must still pass through the native Pre/Post boundary. +1. Run `~/.local/bin/threadbear inventory --json` and use its `status`, `action`, `task_id`, and `applied` fields. The main and controller tasks are already excluded. +2. Accept exact historical footers deterministically. Send only genuinely ambiguous items to fresh, read-only Luna-medium workers, with at most eight workers active. Workers classify and never write titles; use the unknown marker when ambiguity remains. +3. Process targets in stable order, one native title write at a time. Convert each row's `status` and `action` to the exact compact ThreadBear footer grammar, then call the native setter with the explicit `threadId`. The Pre hook immediately re-reads the target and preserves any newer user rename; Post accepts only the exact target/title result and commits the proposal. +4. Re-run inventory after each write or bounded batch. Skip only rows reporting `applied:true`. A similar-looking but unowned title still passes through the native Pre/Post boundary. +5. A timeout, unknown native result, hook rejection, or unreconcilable target is not blindly retried. Record `migration_failed` with the same controller ID and leave the controller visible. Resume only after authoritative inventory establishes whether the prior write applied. +6. Finish only when a final inventory reports zero remaining rows, then run: + + ```sh + ~/.local/bin/threadbear migration --phase migration_complete \ + --controller-task-id CONTROLLER_TASK_ID --json + ``` + + Archive the controller only after that command succeeds. A successful transition is the durable completion evidence. The native setter has no compare-and-set argument. Do not claim it can prevent a rename that races the setter itself. @@ -63,16 +73,18 @@ Command success, state, and `read_thread` are not visual proof. In Codex Desktop - the terminal title appears in both places before the footer; - one explicit-target migration repaints only the intended mounted sidebar row; - a stopped turn leaves the running title and creates no additional ThreadBear turn. +- the persistent task remains exactly `🧵🐻 ThreadBear 🐻🧵` and pinned before migration begins; +- a completed controller leaves `status --json` at `ready:true`, `phase:migration_complete`, with no remaining inventory rows. Capture privacy-safe evidence when preparing a release. ## Uninstall -1. Run `help`, `status --json`, and `inventory --json`. +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 whether to clean ThreadBear-owned decoration to subject-only titles or keep current titles. Preview the chosen effect and obtain a clear yes. Keeping titles makes those decorated strings user-owned; a later reinstall preserves them literally. 3. For cleanup, re-read and update each explicit task through the native setter before removing any artifacts. Require exact returned IDs and titles. A changed title that is not the last committed ThreadBear rendering is user-owned and must not be cleaned. -4. Run the confirmed uninstall. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary while preserving unrelated content and hook order. +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. 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 requested title cleanup has completed. diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index 3e65d56..cddd739 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -1,13 +1,11 @@ package main import ( - "context" "encoding/json" "errors" "fmt" "github.com/ericlitman/threadbear/assets" "os" - "os/exec" "path/filepath" "reflect" "runtime" @@ -16,12 +14,11 @@ import ( ) const ( - launchLabel = "org.litman.threadbear" - blockStart = "" - blockEnd = "" + blockStart = "" + blockEnd = "" ) -type lifecyclePaths struct{ binary, agents, skill, hooks, plist string } +type lifecyclePaths struct{ binary, agents, skill, hooks string } type rawObject map[string]json.RawMessage func codexHome() string { @@ -31,41 +28,48 @@ func codexHome() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".codex") } -func stateDir() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".local", "share", "threadbear") -} +func homeDir() string { home, _ := os.UserHomeDir(); return home } +func stateDir() string { return filepath.Join(homeDir(), ".local", "share", "threadbear") } func installPaths() lifecyclePaths { - home, _ := os.UserHomeDir() - return lifecyclePaths{filepath.Join(home, ".local/bin/threadbear"), filepath.Join(codexHome(), "AGENTS.md"), filepath.Join(codexHome(), "skills/threadbear/SKILL.md"), filepath.Join(codexHome(), "hooks.json"), filepath.Join(home, "Library/LaunchAgents", launchLabel+".plist")} + return lifecyclePaths{filepath.Join(homeDir(), ".local/bin/threadbear"), filepath.Join(codexHome(), "AGENTS.md"), filepath.Join(codexHome(), "skills/threadbear/SKILL.md"), filepath.Join(codexHome(), "hooks.json")} } -func install(ctx context.Context, dry, confirmed bool) (any, error) { +func install(controlTaskID string, dry, confirmed bool) (any, error) { + controlTaskID = strings.TrimSpace(controlTaskID) + value, err := currentStateOrEmpty() + if err != nil { + return nil, err + } + mainTaskID := value.MainTaskID + if mainTaskID != "" && controlTaskID != "" && controlTaskID != mainTaskID { + return nil, fmt.Errorf("install would replace persisted ThreadBear task %q with %q", mainTaskID, controlTaskID) + } + if mainTaskID == "" { + if controlTaskID == "" { + return nil, errors.New("first install requires --control-task-id for the active ThreadBear task") + } + mainTaskID = controlTaskID + } p := installPaths() hooks, write, err := editHooks(p.hooks, p.binary, true) if err != nil { return nil, err } if dry { - return map[string]any{"ready": true, "dry_run": true}, nil + return map[string]any{"ready": true, "dry_run": true, "main_task_id": mainTaskID, "phase": value.Phase, "controller_task_id": value.ControllerTaskID, "controller_required": value.Phase != phaseMigrationComplete && value.ControllerTaskID == ""}, nil } if !confirmed { return nil, errors.New("install requires --noninteractive --confirm after its preview") } - legacy, err := readLegacyState(filepath.Join(stateDir(), "core.json")) - if err != nil { - return nil, err - } - if err = stopLegacyService(ctx); err != nil { - return nil, err - } - err = newStore(stateDir()).update(func(value *state) (bool, error) { - changed := false - for id, task := range legacy { - if old, ok := value.Tasks[id]; !ok || old.Subject == "" { - value.Tasks[id] = task - changed = true - } + err = newStore(stateDir()).update(func(saved *state) (bool, error) { + changed := saved.MainTaskID != mainTaskID + if saved.MainTaskID != "" && saved.MainTaskID != mainTaskID { + return false, errors.New("persisted ThreadBear task changed during install") + } + saved.MainTaskID = mainTaskID + if saved.Phase == "" { + saved.Phase, changed = phaseMigrationRunning, true } + value = *saved return changed, nil }) if err != nil { @@ -88,33 +92,37 @@ func install(ctx context.Context, dry, confirmed bool) (any, error) { if err == nil && write { err = writeAtomic(p.hooks, hooks, 0o600) } - if err == nil { - err = removeFiles(p.plist, filepath.Join(stateDir(), "core.json"), filepath.Join(stateDir(), "core.lock")) - } - return map[string]any{"ready": err == nil, "installed": err == nil}, err + return map[string]any{"ready": err == nil && value.Phase == phaseMigrationComplete, "installed": err == nil, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID, "phase": value.Phase, "controller_required": err == nil && value.Phase == phaseMigrationRunning && value.ControllerTaskID == ""}, err } -func uninstall(ctx context.Context, confirmed bool) (any, error) { +func uninstall(confirmed bool) (any, error) { if !confirmed { return nil, errors.New("uninstall requires --noninteractive --confirm") } + value, err := currentStateOrEmpty() + if err != nil { + return nil, err + } + if value.Phase == phaseMigrationRunning { + return nil, errors.New("cannot uninstall while installation migration is running; stop the controller first") + } p := installPaths() hooks, write, err := editHooks(p.hooks, p.binary, false) if err != nil { return nil, err } - if err = validateFile(p.skill, assets.SkillManagedContent); err == nil { - err = stopLegacyService(ctx) - } + err = validateFile(p.skill, assets.SkillManagedContent) if err == nil { err = manageBlock(p.agents, "") } - if err == nil && write && len(hooks) == 0 { - err = os.Remove(p.hooks) - } else if err == nil && write { - err = writeAtomic(p.hooks, hooks, 0o600) + if err == nil && write { + if len(hooks) == 0 { + err = os.Remove(p.hooks) + } else { + err = writeAtomic(p.hooks, hooks, 0o600) + } } if err == nil { - err = removeFiles(p.plist, p.binary, p.skill) + err = removeFiles(p.binary, p.skill) } if err == nil { err = os.RemoveAll(stateDir()) @@ -132,16 +140,12 @@ func status() (any, error) { _, err = os.Stat(path) } } + value, stateErr := newStore(stateDir()).read() if err == nil { - err = validateFile(p.skill, assets.SkillManagedContent) + err = stateErr } - if err == nil { - err = validateManagedBlock(p.agents) - } - if err == nil { - _, err = newStore(stateDir()).read() - } - return map[string]any{"ready": err == nil, "version": version}, err + phase := value.Phase + return map[string]any{"ready": err == nil && phase == phaseMigrationComplete && value.MainTaskID != "", "installed": err == nil, "version": version, "phase": phase, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID}, err } func selfTest() (any, error) { if runtime.GOOS != "darwin" || assets.AgentsManagedContent == "" || assets.SkillManagedContent == "" || version == "" { @@ -204,65 +208,17 @@ func ownedHookJSON(binary string) json.RawMessage { } func sameJSON(a, b []byte) bool { var left, right any - if json.Unmarshal(a, &left) != nil || json.Unmarshal(b, &right) != nil { - return false - } - return reflect.DeepEqual(left, right) + return json.Unmarshal(a, &left) == nil && json.Unmarshal(b, &right) == nil && reflect.DeepEqual(left, right) } func quoteCommand(binary string) string { return "'" + strings.ReplaceAll(binary, "'", "'\"'\"'") + "' hook" } -func readLegacyState(path string) (map[string]taskState, error) { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, err - } - var legacy struct { - Tasks map[string]struct { - Subject string `json:"subject"` - Last string `json:"last_applied"` - } `json:"tasks"` - } - if err = json.Unmarshal(data, &legacy); err != nil { - return nil, fmt.Errorf("read legacy state: %w", err) - } - result := map[string]taskState{} - for id, task := range legacy.Tasks { - if strings.TrimSpace(task.Subject) != "" { - result[id] = taskState{Subject: task.Subject, Last: task.Last} - } - } - return result, nil -} - -var stopLegacyService = func(ctx context.Context) error { - output, err := exec.CommandContext(ctx, "launchctl", "bootout", fmt.Sprintf("gui/%d/%s", os.Getuid(), launchLabel)).CombinedOutput() - message := strings.ToLower(string(output)) - if err != nil && !strings.Contains(message, "no such process") && !strings.Contains(message, "could not find service") && !strings.Contains(message, "service not found") { - return fmt.Errorf("stop legacy LaunchAgent: %w: %s", err, strings.TrimSpace(string(output))) - } - return nil -} - func validateFile(path, content string) error { data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil } - valid := string(data) == content - if err == nil && !valid { - return errors.New("managed file was modified: " + path) - } - return err -} -func validateManagedBlock(path string) error { - data, err := os.ReadFile(path) - text := string(data) - valid := strings.Count(text, blockStart) == 1 && strings.Count(text, blockEnd) == 1 && strings.Contains(text, managedBlock()) - if err == nil && !valid { + if err == nil && string(data) != content { return errors.New("managed file was modified: " + path) } return err diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index cc514d7..f7e5af5 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -1,7 +1,6 @@ package main import ( - "context" "encoding/json" "os" "os/exec" @@ -14,32 +13,16 @@ import ( ) func TestInstallReinstallAndUninstallPreserveForeignHooks(t *testing.T) { - p, stopped := isolatedLifecycle(t) + p := isolatedLifecycle(t) foreignAgents := "# Mine\nkeep this exactly\n" mustWrite(t, p.agents, foreignAgents) preA := json.RawMessage(`{"matcher":"Bash","hooks":[{"type":"command","command":"a"}],"extension":{"n":1}}`) preB := json.RawMessage(`{"hooks":[{"command":"b","timeout":99,"type":"command"}]}`) postA := json.RawMessage(`{"matcher":"","hooks":[{"type":"command","command":"c"}]}`) writeHookFixture(t, p.hooks, preA, preB, postA) - mustWrite(t, p.plist, "old heartbeat") - mustWrite(t, filepath.Join(stateDir(), "core.json"), `{"tasks":{"kept":{"subject":"Stable subject","last_applied":"✅ Stable subject"},"ignored":{"subject":""}}}`) - - if _, err := install(context.Background(), false, true); err != nil { + if _, err := install("installer", false, true); err != nil { t.Fatal(err) } - if *stopped != 1 { - t.Fatalf("legacy service stops = %d, want 1", *stopped) - } - if _, err := os.Stat(p.plist); !os.IsNotExist(err) { - t.Fatalf("legacy plist survived: %v", err) - } - if _, err := os.Stat(filepath.Join(stateDir(), "core.json")); !os.IsNotExist(err) { - t.Fatalf("legacy state survived: %v", err) - } - stateData, err := os.ReadFile(filepath.Join(stateDir(), "native.json")) - if err != nil || !strings.Contains(string(stateData), `"Stable subject"`) || strings.Contains(string(stateData), `"ignored"`) { - t.Fatalf("legacy ownership migration = %q, err %v", stateData, err) - } assertHookOrder(t, p.hooks, "PreToolUse", []json.RawMessage{preA, preB}, p.binary) assertHookOrder(t, p.hooks, "PostToolUse", []json.RawMessage{postA}, p.binary) skill, _ := os.ReadFile(p.skill) @@ -53,24 +36,27 @@ func TestInstallReinstallAndUninstallPreserveForeignHooks(t *testing.T) { if err := manageBlock(p.agents, ""); err != nil { t.Fatal(err) } - if _, err := status(); err == nil { - t.Fatal("status accepted a missing AGENTS managed block") - } if err := manageBlock(p.agents, assets.AgentsManagedContent); err != nil { t.Fatal(err) } - if _, err := install(context.Background(), false, true); err != nil { + if _, err := install("installer", false, true); err != nil { t.Fatal(err) } secondHooks, _ := os.ReadFile(p.hooks) if !reflect.DeepEqual(firstHooks, secondHooks) { t.Fatal("reinstall rewrote an already-correct hooks.json") } - if _, err := uninstall(context.Background(), true); err != nil { + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + if _, err := uninstall(true); err != nil { t.Fatal(err) } - if _, err := uninstall(context.Background(), true); err != nil { + if _, err := uninstall(true); err != nil { t.Fatalf("repeated uninstall: %v", err) } assertHookOrder(t, p.hooks, "PreToolUse", []json.RawMessage{preA, preB}, "") @@ -87,16 +73,13 @@ func TestInstallReinstallAndUninstallPreserveForeignHooks(t *testing.T) { } func TestInstallDryRunAndConfirmationDoNotMutate(t *testing.T) { - p, stopped := isolatedLifecycle(t) - if _, err := install(context.Background(), true, false); err != nil { + p := isolatedLifecycle(t) + if _, err := install("installer", true, false); err != nil { t.Fatal(err) } - if _, err := install(context.Background(), false, false); err == nil { + if _, err := install("installer", false, false); err == nil { t.Fatal("unconfirmed install succeeded") } - if *stopped != 0 { - t.Fatal("preview or rejected install stopped the legacy service") - } for _, path := range []string{p.binary, p.agents, p.skill, p.hooks, stateDir()} { if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("non-mutating install created %s: %v", path, err) @@ -105,15 +88,12 @@ func TestInstallDryRunAndConfirmationDoNotMutate(t *testing.T) { } func TestMalformedHooksFailBeforeLifecycleMutation(t *testing.T) { - p, stopped := isolatedLifecycle(t) + p := isolatedLifecycle(t) malformed := []byte(`{"hooks":{"PreToolUse":{"not":"an array"}}}`) mustWrite(t, p.hooks, string(malformed)) - if _, err := install(context.Background(), false, true); err == nil { + if _, err := install("installer", false, true); err == nil { t.Fatal("install accepted wrong-shaped hooks") } - if *stopped != 0 { - t.Fatal("failed install stopped the legacy service") - } got, _ := os.ReadFile(p.hooks) if !reflect.DeepEqual(got, malformed) { t.Fatal("failed install changed hooks.json") @@ -122,7 +102,7 @@ func TestMalformedHooksFailBeforeLifecycleMutation(t *testing.T) { t.Fatal("failed install copied the binary") } mustWrite(t, p.binary, "sentinel") - if _, err := uninstall(context.Background(), true); err == nil { + if _, err := uninstall(true); err == nil { t.Fatal("uninstall accepted wrong-shaped hooks") } if got, _ := os.ReadFile(p.binary); string(got) != "sentinel" { @@ -130,67 +110,6 @@ func TestMalformedHooksFailBeforeLifecycleMutation(t *testing.T) { } } -func TestStatusRequiresExactManagedAgentsBlock(t *testing.T) { - p, _ := isolatedLifecycle(t) - if _, err := install(context.Background(), false, true); err != nil { - t.Fatal(err) - } - canonical, err := os.ReadFile(p.agents) - if err != nil { - t.Fatal(err) - } - if _, err := status(); err != nil { - t.Fatalf("canonical status: %v", err) - } - - variants := map[string]string{ - "edited": strings.Replace(string(canonical), "# ThreadBear", "# ThreadBear edited", 1), - "empty": blockStart + "\n" + blockEnd + "\n", - "duplicate": string(canonical) + string(canonical), - "reversed": blockEnd + "\n" + strings.TrimSpace(assets.AgentsManagedContent) + "\n" + blockStart + "\n", - } - for name, value := range variants { - t.Run(name, func(t *testing.T) { - mustWrite(t, p.agents, value) - if _, err := status(); err == nil { - t.Fatalf("status accepted %s managed AGENTS block", name) - } - mustWrite(t, p.agents, string(canonical)) - }) - } -} - -func TestMalformedLegacyStateFailsBeforeLifecycleMutation(t *testing.T) { - p, stopped := isolatedLifecycle(t) - legacyPath := filepath.Join(stateDir(), "core.json") - malformed := []byte(`{"tasks":`) - mustWrite(t, legacyPath, string(malformed)) - mustWrite(t, p.plist, "legacy service sentinel") - mustWrite(t, p.hooks, `{"owner":"unchanged"}`) - - if _, err := install(context.Background(), false, true); err == nil { - t.Fatal("install accepted malformed legacy state") - } - if *stopped != 0 { - t.Fatalf("failed install stopped the legacy service %d times", *stopped) - } - for path, want := range map[string][]byte{ - legacyPath: malformed, - p.plist: []byte("legacy service sentinel"), - p.hooks: []byte(`{"owner":"unchanged"}`), - } { - got, err := os.ReadFile(path) - if err != nil || !reflect.DeepEqual(got, want) { - t.Fatalf("failed install changed %s: got %q, err %v", path, got, err) - } - } - for _, path := range []string{p.binary, p.agents, p.skill, filepath.Join(stateDir(), "native.json")} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("failed install created %s: %v", path, err) - } - } -} - func TestOwnedHookQuotesBinaryPath(t *testing.T) { binary := filepath.Join(t.TempDir(), "Eric O'Brien Bear", "threadbear") mustWrite(t, binary, "#!/bin/sh\nprintf '%s:%s' \"$#\" \"$1\"\n") @@ -212,13 +131,13 @@ func TestOwnedHookQuotesBinaryPath(t *testing.T) { } func TestUninstallRemovesOwnedOnlyHooksFile(t *testing.T) { - p, _ := isolatedLifecycle(t) + p := isolatedLifecycle(t) data, _, err := editHooks(p.hooks, p.binary, true) if err != nil { t.Fatal(err) } mustWrite(t, p.hooks, string(data)) - if _, err := uninstall(context.Background(), true); err != nil { + if _, err := uninstall(true); err != nil { t.Fatal(err) } if _, err := os.Stat(p.hooks); !os.IsNotExist(err) { @@ -226,16 +145,12 @@ func TestUninstallRemovesOwnedOnlyHooksFile(t *testing.T) { } } -func isolatedLifecycle(t *testing.T) (lifecyclePaths, *int) { +func isolatedLifecycle(t *testing.T) lifecyclePaths { t.Helper() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("CODEX_HOME", filepath.Join(home, ".codex")) - oldStop := stopLegacyService - stopped := 0 - stopLegacyService = func(context.Context) error { stopped++; return nil } - t.Cleanup(func() { stopLegacyService = oldStop }) - return installPaths(), &stopped + return installPaths() } func writeHookFixture(t *testing.T, path string, preA, preB, postA json.RawMessage) { diff --git a/cmd/threadbear/lifecycle_test.go b/cmd/threadbear/lifecycle_test.go new file mode 100644 index 0000000..d510116 --- /dev/null +++ b/cmd/threadbear/lifecycle_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "os" + "testing" +) + +func TestMigrationInventoryExcludesMainAndController(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) + addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) + targetRollout := addTask(t, db, root, "target", "Target", nil, "exec", 0) + writeMigrationRollout(t, targetRollout, "🧵🐻 complete") + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning + return true, nil + }); err != nil { + t.Fatal(err) + } + items, _, _, err := migrationInventory(context.Background()) + if err != nil || len(items) != 1 || items[0].TaskID != "target" { + t.Fatalf("migration scope = %#v, %v", items, err) + } +} + +func TestMigrationControllerRequiresAppliedFinalConvergence(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) + addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) + targetRollout := addTask(t, db, root, "target", "Target", nil, "exec", 0) + writeMigrationRollout(t, targetRollout, "🧵🐻 complete") + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning + return true, nil + }); err != nil { + t.Fatal(err) + } + if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller"); err != nil { + t.Fatal(err) + } + if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "other"); err == nil { + t.Fatal("migration accepted a second controller") + } + if _, err := transitionMigration(context.Background(), phaseMigrationComplete, "controller"); err == nil { + t.Fatal("migration completed before applied inventory convergence") + } + if _, err := db.Exec(`UPDATE threads SET name=? WHERE id='target'`, "✅ Target"); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Tasks["target"] = taskState{Subject: "Target", Last: "✅ Target", Status: "complete"} + return true, nil + }); err != nil { + t.Fatal(err) + } + result, err := transitionMigration(context.Background(), phaseMigrationComplete, "controller") + if err != nil { + t.Fatal(err) + } + if result.(map[string]any)["remaining"] != 0 { + t.Fatalf("completion result = %#v", result) + } + value, err := newStore(stateDir()).read() + if err != nil || value.Phase != phaseMigrationComplete { + t.Fatalf("completed state = %#v, %v", value, err) + } +} + +func TestInstallMainTaskIdentityIsSticky(t *testing.T) { + isolatedLifecycle(t) + if _, err := install("main", false, true); err != nil { + t.Fatal(err) + } + value, err := newStore(stateDir()).read() + if err != nil || value.MainTaskID != "main" || value.Phase != phaseMigrationRunning { + t.Fatalf("initial identity = %#v, %v", value, err) + } + if _, err := install("", false, true); err != nil { + t.Fatal(err) + } + if _, err := install("other", false, true); err == nil { + t.Fatal("reinstall replaced the persisted main task") + } +} + +func TestUninstallRefusesActiveMigration(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install("main", false, true); err != nil { + t.Fatal(err) + } + if _, err := uninstall(true); err == nil { + t.Fatal("uninstall accepted an active migration") + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("blocked uninstall removed the binary: %v", err) + } +} diff --git a/cmd/threadbear/main.go b/cmd/threadbear/main.go index 938042a..4d8e11b 100644 --- a/cmd/threadbear/main.go +++ b/cmd/threadbear/main.go @@ -39,22 +39,27 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. var action func() (any, error) switch command { case "install": + controlTaskID := flags.String("control-task-id", "", "active task that becomes ThreadBear's persistent home") dry := flags.Bool("dry-run", false, "preview without mutation") noninteractive := flags.Bool("noninteractive", false, "run without prompts") confirm := flags.Bool("confirm", false, "confirm the previewed installation") flags.String("version", "", "installer-selected release version") - action = func() (any, error) { return install(ctx, *dry, *noninteractive && *confirm) } + action = func() (any, error) { return install(*controlTaskID, *dry, *noninteractive && *confirm) } case "inventory": action = func() (any, error) { - items, err := migrationInventory(ctx) + items, remaining, value, err := migrationInventory(ctx) deterministic := 0 for _, item := range items { if item.Deterministic { deterministic++ } } - return map[string]any{"ready": err == nil, "count": len(items), "deterministic": deterministic, "ambiguous": len(items) - deterministic, "tasks": items}, err + return map[string]any{"ready": err == nil && remaining == 0 && value.Phase == phaseMigrationComplete, "count": len(items), "deterministic": deterministic, "ambiguous": len(items) - deterministic, "applied": len(items) - remaining, "remaining": remaining, "phase": value.Phase, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID, "tasks": items}, err } + case "migration": + 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 "status": action = status case "self-test": @@ -63,7 +68,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. case "uninstall": noninteractive := flags.Bool("noninteractive", false, "run without prompts") confirm := flags.Bool("confirm", false, "confirm the previewed uninstall") - action = func() (any, error) { return uninstall(ctx, *noninteractive && *confirm) } + action = func() (any, error) { return uninstall(*noninteractive && *confirm) } case "version": action = func() (any, error) { return map[string]any{"version": version}, nil } default: diff --git a/cmd/threadbear/migration.go b/cmd/threadbear/migration.go new file mode 100644 index 0000000..88eb95e --- /dev/null +++ b/cmd/threadbear/migration.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "errors" + "os" +) + +func currentStateOrEmpty() (state, error) { + value, err := newStore(stateDir()).read() + if errors.Is(err, os.ErrNotExist) { + return state{Format: stateFormat, Tasks: map[string]taskState{}}, nil + } + return value, err +} +func transitionMigration(ctx context.Context, phase, controllerID string) (any, error) { + if phase != phaseMigrationRunning && phase != phaseMigrationComplete && phase != phaseMigrationFailed || controllerID == "" { + return nil, errors.New("migration requires a valid phase and controller task ID") + } + var err error + remaining := -1 + if phase == phaseMigrationComplete { + _, remaining, _, err = migrationInventory(ctx) + if err != nil { + return nil, err + } + if remaining != 0 { + return nil, errors.New("migration has unresolved tasks") + } + } + err = newStore(stateDir()).update(func(value *state) (bool, error) { + if value.MainTaskID == "" || controllerID == value.MainTaskID || value.ControllerTaskID != "" && value.ControllerTaskID != controllerID || phase != phaseMigrationRunning && value.ControllerTaskID != controllerID || value.Phase == phaseMigrationComplete && phase != phaseMigrationComplete { + return false, errors.New("migration controller or main task changed") + } + value.ControllerTaskID, value.Phase = controllerID, phase + return true, nil + }) + if err != nil { + return nil, err + } + result := map[string]any{"ready": true, "phase": phase} + if remaining >= 0 { + result["remaining"] = remaining + } + return result, nil +} diff --git a/cmd/threadbear/migration_test.go b/cmd/threadbear/migration_test.go index 30ffb70..1d61529 100644 --- a/cmd/threadbear/migration_test.go +++ b/cmd/threadbear/migration_test.go @@ -13,7 +13,7 @@ import ( func TestMigrationInventoryAtZeroOneAndTwoHundredTasks(t *testing.T) { t.Run("zero", func(t *testing.T) { _, _ = testIndex(t) - items, err := migrationInventory(context.Background()) + items, _, _, err := migrationInventory(context.Background()) if err != nil || len(items) != 0 { t.Fatalf("migrationInventory() = %#v, %v", items, err) } @@ -23,7 +23,7 @@ func TestMigrationInventoryAtZeroOneAndTwoHundredTasks(t *testing.T) { root, db := testIndex(t) path := addTask(t, db, root, "only", "Ship the release", nil, "vscode", 0) writeMigrationRollout(t, path, "🧵🐻 next steps (you): approve the release") - items, err := migrationInventory(context.Background()) + items, _, _, err := migrationInventory(context.Background()) if err != nil || len(items) != 1 { t.Fatalf("migrationInventory() = %#v, %v", items, err) } @@ -69,11 +69,11 @@ func TestMigrationInventoryAtZeroOneAndTwoHundredTasks(t *testing.T) { if err != nil { t.Fatal(err) } - first, err := migrationInventory(context.Background()) + first, _, _, err := migrationInventory(context.Background()) if err != nil || len(first) != 200 { t.Fatalf("migrationInventory() count = %d, %v", len(first), err) } - second, err := migrationInventory(context.Background()) + second, _, _, err := migrationInventory(context.Background()) if err != nil || !reflect.DeepEqual(second, first) { t.Fatalf("idempotent rerun differs: %v\nfirst: %#v\nsecond: %#v", err, first, second) } diff --git a/cmd/threadbear/scan.go b/cmd/threadbear/scan.go index 71e0b9d..0500818 100644 --- a/cmd/threadbear/scan.go +++ b/cmd/threadbear/scan.go @@ -83,7 +83,7 @@ func openIndex() (*sql.DB, error) { func sqliteHome() (string, error) { base := codexHome() var config struct { - SQLiteHome *string `toml:"sqlite_home"` + SQLiteHome string `toml:"sqlite_home"` } if _, err := toml.DecodeFile(filepath.Join(base, "config.toml"), &config); err != nil { if errors.Is(err, os.ErrNotExist) { @@ -91,12 +91,9 @@ func sqliteHome() (string, error) { } return "", fmt.Errorf("parse Codex config: %w", err) } - if config.SQLiteHome == nil { - return base, nil - } - value := strings.TrimSpace(*config.SQLiteHome) + value := strings.TrimSpace(config.SQLiteHome) if value == "" { - return "", errors.New("Codex sqlite_home is empty") + return base, nil } if !filepath.IsAbs(value) { value = filepath.Join(base, value) @@ -104,7 +101,6 @@ func sqliteHome() (string, error) { return value, nil } -// rolloutFooter reads only the settled tail; classification never sends history to a model. func rolloutFooter(path string) (footer, bool) { if path == "" { return footer{}, false @@ -131,7 +127,6 @@ func rolloutFooter(path string) (footer, bool) { data = data[bytes.IndexByte(data, '\n')+1:] } lines := bytes.Split(data, []byte{'\n'}) - // The final fragment is either empty or an in-flight JSONL write. for i := len(lines) - 2; i >= 0; i-- { var item struct { Type string `json:"type"` @@ -171,19 +166,21 @@ type inventoryItem struct { Applied bool `json:"applied"` } -func migrationInventory(ctx context.Context) ([]inventoryItem, error) { +func migrationInventory(ctx context.Context) ([]inventoryItem, int, state, error) { tasks, err := inventory(ctx) if err != nil { - return nil, err + return nil, 0, state{}, err } - known := state{Tasks: map[string]taskState{}} - if saved, readErr := newStore(stateDir()).read(); readErr == nil { - known = saved - } else if !errors.Is(readErr, os.ErrNotExist) { - return nil, readErr + known, readErr := currentStateOrEmpty() + if readErr != nil { + return nil, 0, state{}, readErr } items := make([]inventoryItem, 0, len(tasks)) + remaining := 0 for _, task := range tasks { + if task.ID == known.MainTaskID || task.ID == known.ControllerTaskID { + continue + } record := known.Tasks[task.ID] subject := canonicalSubject(task.Title, record) result, ok := rolloutFooter(task.RolloutPath) @@ -196,12 +193,12 @@ func migrationInventory(ctx context.Context) ([]inventoryItem, error) { } } desired := renderTitle(result.Status, subject, result.Action) - items = append(items, inventoryItem{ - TaskID: task.ID, Title: task.Title, Subject: subject, Status: result.Status, - Action: result.Action, Deterministic: ok, - }) + items = append(items, inventoryItem{TaskID: task.ID, Title: task.Title, Subject: subject, Status: result.Status, Action: result.Action, Deterministic: ok}) last := &items[len(items)-1] last.Applied = record.Pending == nil && record.Last == task.Title && record.Last == desired + if !last.Applied { + remaining++ + } } - return items, nil + return items, remaining, known, nil } diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index 727e1b7..afca6e4 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -44,8 +44,14 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { for _, required := range []string{ "## Hi. Let's install ThreadBear.", "## Recommended setup", + "--control-task-id", "--noninteractive --confirm --json", "~/.local/bin/threadbear inventory --json", + "migration_running", + "migration_complete", + "exactly one ephemeral migration-controller task", + "codex_app__set_thread_title", + "codex_app__set_thread_pinned", "genuinely fresh Codex Desktop task", "two native title calls per ordinary turn", "~/.local/bin/threadbear uninstall --noninteractive --confirm --json", @@ -54,6 +60,9 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { t.Errorf("published install guide is missing %q", required) } } + if strings.Contains(text, "foreground migration") { + t.Error("published install guide still assigns migration to the persistent task") + } } func TestPublishedInstallGuideKeepsFirstConsentTurnVisible(t *testing.T) { @@ -126,3 +135,30 @@ func TestHomepageDoesNotPromiseRemovedCapabilities(t *testing.T) { } } } + +func TestShippedLogicStaysBelowAbsoluteLineCeiling(t *testing.T) { + root := filepath.Join("..", "..") + paths, err := filepath.Glob(filepath.Join(root, "cmd", "threadbear", "*.go")) + if err != nil { + t.Fatal(err) + } + paths = append(paths, filepath.Join(root, "install.sh"), filepath.Join(root, "site", "install.sh")) + count := 0 + for _, path := range paths { + if strings.HasSuffix(path, "_test.go") { + continue + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + count += bytes.Count(data, []byte{'\n'}) + if len(data) > 0 && data[len(data)-1] != '\n' { + count++ + } + } + t.Logf("shipped executable logic: %d lines (target 1000, absolute ceiling 1500)", count) + if count > 1500 { + t.Fatalf("shipped executable logic is %d lines; absolute ceiling is 1500", count) + } +} diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index 25ae7b0..eae72df 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -11,7 +11,7 @@ import ( "unicode/utf16" ) -const stateFormat = 3 +const stateFormat, phaseMigrationRunning, phaseMigrationComplete, phaseMigrationFailed = 3, "migration_running", "migration_complete", "migration_failed" type pendingProposal struct { ToolUseID string `json:"tool_use_id"` @@ -29,14 +29,18 @@ type taskState struct { Pending *pendingProposal `json:"pending,omitempty"` } type state struct { - Format int `json:"format"` - Tasks map[string]taskState `json:"tasks"` + Format int `json:"format"` + MainTaskID string `json:"main_task_id,omitempty"` + ControllerTaskID string `json:"controller_task_id,omitempty"` + Phase string `json:"phase,omitempty"` + Tasks map[string]taskState `json:"tasks"` } type footer struct{ Status, Action string } type store struct{ dir string } func newStore(dir string) store { return store{dir: dir} } func (s store) path() string { return filepath.Join(s.dir, "native.json") } + func (s store) lock() (*os.File, error) { if err := os.MkdirAll(s.dir, 0o700); err != nil { return nil, err @@ -82,7 +86,7 @@ func (s store) read() (state, error) { return state{}, err } var value state - if err := json.Unmarshal(data, &value); err != nil || value.Format != stateFormat || value.Tasks == nil { + if err := json.Unmarshal(data, &value); err != nil || value.Format != stateFormat || value.Tasks == nil || value.Phase != "" && value.Phase != phaseMigrationRunning && value.Phase != phaseMigrationComplete && value.Phase != phaseMigrationFailed { return state{}, errors.New("unsupported or corrupt ThreadBear state format") } return value, nil @@ -137,13 +141,11 @@ func parseFooter(message string) (footer, bool) { remainder := strings.TrimPrefix(line, "🧵🐻 ") statusText, ownerAction, ok := strings.Cut(remainder, " (") owner, action, ownerOK := strings.Cut(ownerAction, "): ") - statuses := map[string]string{"next steps": "next_steps", "needs input": "needs_input", "blocked": "blocked"} - status, statusOK := statuses[statusText] + status, statusOK := map[string]string{"next steps": "next_steps", "needs input": "needs_input", "blocked": "blocked"}[statusText] if remainder == line || !ok || !ownerOK || !statusOK || strings.TrimSpace(action) != action || len(strings.Fields(action)) < 2 { return footer{}, false } - if status == "needs_input" && owner != "you" || status == "blocked" && owner != "external" || - status == "next_steps" && owner != "you" && owner != "agent" && owner != "external" { + if status == "needs_input" && owner != "you" || status == "blocked" && owner != "external" || status == "next_steps" && owner != "you" && owner != "agent" && owner != "external" { return footer{}, false } return footer{Status: status, Action: action}, true diff --git a/docs/architecture.md b/docs/architecture.md index 60dc9e4..3192447 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. +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. ## Ordinary turn @@ -15,12 +15,12 @@ Each title moment may be retried once. There is no Stop hook: an interrupted tur The canonical title is ` [ → ]`. ThreadBear owns only a leading status and action suffix from its last exact committed rendering. Any different current title is a user rename and becomes the complete subject, even if it contains an icon or arrow. -State is keyed by task ID and contains the canonical subject, 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. +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. The visible title is limited to 60 UTF-16 units. Rendering truncates displayed subject before displayed action without changing canonical state. ## Installation and migration -Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order. A foreground Codex task inventories all local unarchived tasks, classifies exact footers deterministically, and uses Luna medium only for genuinely ambiguous v2 history. Immediately before each explicit-target native write, the helper re-reads the target and adopts any newer rename. +Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order. The initiating task is recorded as the persistent ThreadBear home, then a fresh-task canary proves the native boundary. That task creates exactly one ephemeral migration controller and returns promptly; the controller inventories all local unarchived tasks, excludes the main/controller IDs, classifies exact footers deterministically, and uses Luna medium only for genuinely ambiguous history. Immediately before each explicit-target native write, the hook re-reads the target and adopts any newer rename. -Migration is rerunnable from scratch and skips only inventory rows proven `applied: true` from exact committed ownership state. It persists no migration workflow. Rendered active-header and sidebar verification belongs in release QA. +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 never performs, awaits, or polls the migration. Rendered active-header and sidebar verification belongs in release QA. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 1d8cc87..69080c0 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -3,9 +3,9 @@ Before tagging a stable release: 1. Rename `Unreleased` to `vN.N.N - YYYY-MM-DD` and add a fresh `Unreleased` section. -2. Run `gofmt`, `go test ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity checks. Count every tracked non-test `.go` file and require at most 1,000 physical production lines. +2. Run `gofmt`, `go test ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity checks. Count tracked non-test Go plus shipped bootstrap shell, report the 1,000-line target comparison, and fail the release above the 1,500-line absolute ceiling. 3. In isolated homes, prove install, reinstall, status, inventory, and both uninstall title choices while preserving unrelated AGENTS content and hook definitions in order. -4. Exercise 0-, 1-, and 200-task foreground migrations. Prove deterministic exact-footer classification, ambiguity-only Luna medium with at most eight workers, explicit native results, concurrent rename/archive handling, interruption, and clean rerun. +4. Exercise 0-, 1-, and 200-task controller migrations. Prove deterministic exact-footer classification, ambiguity-only Luna medium with at most eight workers, explicit native results, concurrent rename/archive handling, interruption, same-controller resume, and clean rerun. 5. In fresh Codex Desktop tasks, run the matrix in `docs/live-eval.md`. Verify the rendered active header and sidebar, capture privacy-safe screenshots, and restore controlled canary titles through the supported native path. After tagging, confirm the release workflow publishes both Darwin architectures, checksums, and the manifest. Then run the hosted smoke test through `threadbear.sh`, including checksum verification, candidate self-test, install, status, inventory, and uninstall. diff --git a/site/install b/site/install index 6bd17f8..f2c3792 100644 --- a/site/install +++ b/site/install @@ -41,19 +41,22 @@ fi ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop, and HTTPS access to the official guide and GitHub Releases. Do not use `sudo`, grant Full Disk Access, or edit Codex private UI storage. +Resolve the canonical ID of this calling task with supported Codex task tooling and keep it as `MAIN_TASK_ID`. This initiating task becomes ThreadBear's persistent home. On a reinstall, use the persisted ThreadBear task ID from `status --json`; never adopt whichever task happened to launch the reinstall. + For an official release, run the verified bootstrap preview: ```sh -curl -fsSL https://threadbear.sh/install.sh | sh -s -- --dry-run --json +curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ + --control-task-id "$MAIN_TASK_ID" --dry-run --json ``` For an already-built local candidate, run: ```sh -/path/to/threadbear install --dry-run --json +/path/to/threadbear install --control-task-id "$MAIN_TASK_ID" --dry-run --json ``` -Require a successful candidate self-test and a dry-run limited to the binary, one small private state file, one managed AGENTS block, one installed skill, and two hook entries. Preserve unrelated AGENTS content and hook definitions in their existing order. +Require a successful candidate self-test and a dry-run limited to adopting the initiating task, the binary, one small private state file, one managed AGENTS block, one installed skill, and two hook entries. Preserve unrelated AGENTS content and hook definitions in their existing order. ## 2. Show the recommendation @@ -64,8 +67,9 @@ Only after every check and the dry run succeeds, compose one terminal final answ > - **Useful per-turn titles.** Each ordinary turn starts with a native running title and ends with a native title that matches its exact ThreadBear footer. > - **Stable subjects.** ThreadBear preserves the user-owned task subject, adopts later user renames, and bounds visible titles to 60 UTF-16 units. > - **Small local footprint.** One standalone Go binary, one private state file, managed guidance, and two deterministic Codex hooks. -> - **One foreground migration.** After a fresh-task canary, existing local unarchived tasks—including projectless tasks—can be updated through explicit native Desktop calls. +> - **One persistent home and one controller.** The initiating task becomes `🧵🐻 ThreadBear 🐻🧵`; after the fresh-task canary it starts exactly one ephemeral migration controller and returns promptly. > - **Deterministic first.** Exact historical footers are classified locally. Luna medium is reserved for genuinely ambiguous legacy history, with at most eight read-only workers; workers never write titles. +> - **Honest migration status.** Installation reports `migration_running` until the controller's native writes and final inventory prove every task applied; failures remain visible and resumable. > - **Honest cleanup.** Uninstall can remove ThreadBear-owned decoration or intentionally keep current titles before removing its local artifacts. > > Install ThreadBear with this recommended setup? @@ -80,13 +84,14 @@ For the verified official release, run: ```sh curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ + --control-task-id "$MAIN_TASK_ID" \ --noninteractive --confirm --json ``` For the verified local candidate, run: ```sh -/path/to/threadbear install --noninteractive --confirm --json +/path/to/threadbear install --control-task-id "$MAIN_TASK_ID" --noninteractive --confirm --json ``` Then verify the installed surfaces: @@ -98,21 +103,35 @@ Then verify the installed surfaces: ~/.local/bin/threadbear inventory --json ``` -Do not claim the hooks work merely because files were written. Codex snapshots hooks and managed guidance when a task starts, so verification must continue in a new task. +The install result must show `installed:true`, the exact `main_task_id`, and `phase:migration_running` unless a prior completed migration is being preserved. `ready:true` means `migration_complete`, not merely that artifacts were written. Do not claim the hooks work merely because files were written. Codex snapshots hooks and managed guidance when a task starts, so verification must continue in a new task. ## 4. Prove a fresh task and migrate -Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install**, **Bulk migration**, and **Rendered Desktop verification** sections. The installed skill is the canonical operation guide. +Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install**, **Migration controller**, and **Rendered Desktop verification** sections. The installed skill is the canonical operation guide. Before migration, tell the user: > The deterministic scan is already done and highly token-efficient. A large workspace can spend about three to five minutes in the native Desktop handoff. I'll report only real progress. Luna medium runs only for genuinely ambiguous legacy history. -Use Codex `/hooks` to inspect and trust the two installed definitions, then create a genuinely fresh Codex Desktop task. Prove that its first action is the native running-title call, its terminal call immediately precedes the footer, both exact native results pass through the two hooks, and both titles render in the active header and sidebar. Also prove that one explicit-target canary repaints only the intended mounted sidebar row. +Before any bulk work, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and prove that exact title in the active header and mounted sidebar. Then use Codex `/hooks` to inspect and trust the two installed definitions, create a genuinely fresh Codex Desktop task, and prove that its first action is the native running-title call, its terminal call immediately precedes the footer, both exact native results pass through the two hooks, and both titles render in the active header and sidebar. Also prove that one explicit-target canary repaints only the intended mounted sidebar row. + +After that canary passes, create exactly one ephemeral migration-controller task with a prompt containing the controller protocol from the installed ThreadBear skill. Persist its exact ID before it starts: -After that canary passes, inventory every local unarchived task. Classify exact historical footers deterministically. Use at most eight fresh Luna-medium workers only for items that remain genuinely ambiguous; workers classify only. Call the native setter with the explicit task ID and the classification's compact footer marker (or the documented unknown marker). The Pre hook immediately re-reads the target and preserves any newer rename before rewriting the title; Post verifies the exact returned pair. +```sh +~/.local/bin/threadbear migration \ + --phase migration_running \ + --controller-task-id "$CONTROLLER_TASK_ID" --json +``` + +End this persistent task promptly after the transition. It must not inventory, write, await, or poll the migration. The controller is the only migration writer: it processes one explicit target at a time, skips only rows already reporting `applied:true`, uses at most eight read-only Luna-medium workers for genuine ambiguity, and treats a timeout or unknown native result as `migration_failed` until authoritative reconciliation. It performs a final inventory with zero remaining rows, then records: + +```sh +~/.local/bin/threadbear migration \ + --phase migration_complete \ + --controller-task-id "$CONTROLLER_TASK_ID" --json +``` -Migration is rerunnable from scratch. Skip only inventory rows proven `applied: true`; a similar-looking but unowned title still goes through the native Pre/Post boundary. After the batch, rerun inventory and require every row to be applied. If interrupted, rerun inventory. +Only after that command succeeds may the controller archive itself. A stopped controller resumes with the same ID; a failed controller remains visible and resumable. Never claim installation completion from a setter return, accepted call, or partial count. ## 5. Close precisely @@ -120,7 +139,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, a fresh task completed both native title moments, and Codex Desktop rendered the result in its header and sidebar. Existing tasks were handled through the foreground migration you approved. +> Everything passed: ThreadBear VERSION is installed, its managed guidance and two hooks are healthy, this task is its persistent home, a fresh task completed both native title moments, Codex Desktop rendered the results in its header and sidebar, and the single migration controller completed with zero remaining tasks. > > From here, you can ask “how are you?”, “what tasks do you see?”, or “uninstall ThreadBear.” @@ -130,7 +149,7 @@ If official-download verification fails before mutation, say that installation p ## Help and status -For later help, lead with a short capability card instead of a command dump. Verify health before saying ThreadBear is active: +For later help, lead with a short capability card instead of a command dump. Verify the artifact and migration phase before saying ThreadBear is ready: ```sh ~/.local/bin/threadbear status --json @@ -149,10 +168,10 @@ Requested title cleanup must finish first through explicit native target calls w ~/.local/bin/threadbear uninstall --noninteractive --confirm --json ``` -Uninstall removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary. It preserves unrelated content and hook order. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance. +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. 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,000-line production-Go gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task migration fixtures, and the fresh Desktop matrix pass. Rendered header and sidebar screenshots are required; command exit codes and stored titles are not visual proof. +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, controller-resume and failure cases, and the fresh Desktop matrix pass. Rendered header and sidebar screenshots are required; command exit codes and stored titles are not visual proof. 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. From 4ec6a325dc87a70b773396f684346d6096587a8f Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 31 Jul 2026 22:42:31 -0400 Subject: [PATCH 2/2] fix: validate installed ThreadBear guidance [BEAR-104] --- cmd/threadbear/install.go | 19 ++++++++++--------- cmd/threadbear/install_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index cddd739..2f86abf 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -25,8 +25,7 @@ func codexHome() string { if value := os.Getenv("CODEX_HOME"); value != "" { return value } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".codex") + return filepath.Join(homeDir(), ".codex") } func homeDir() string { home, _ := os.UserHomeDir(); return home } func stateDir() string { return filepath.Join(homeDir(), ".local", "share", "threadbear") } @@ -141,11 +140,8 @@ func status() (any, error) { } } value, stateErr := newStore(stateDir()).read() - if err == nil { - err = stateErr - } - phase := value.Phase - return map[string]any{"ready": err == nil && phase == phaseMigrationComplete && value.MainTaskID != "", "installed": err == nil, "version": version, "phase": phase, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID}, err + err = errors.Join(err, validateFile(p.skill, assets.SkillManagedContent), validateFile(p.agents, managedBlock()), stateErr) + return 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}, err } func selfTest() (any, error) { if runtime.GOOS != "darwin" || assets.AgentsManagedContent == "" || assets.SkillManagedContent == "" || version == "" { @@ -218,13 +214,18 @@ func validateFile(path, content string) error { if errors.Is(err, os.ErrNotExist) { return nil } - if err == nil && string(data) != content { + text := string(data) + valid := text == content + if strings.HasPrefix(content, blockStart) { + valid = strings.Count(text, blockStart) == 1 && strings.Count(text, blockEnd) == 1 && strings.Contains(text, content) + } + if err == nil && !valid { return errors.New("managed file was modified: " + path) } return err } func managedBlock() string { - return blockStart + "\n" + strings.TrimSpace(assets.AgentsManagedContent) + "\n" + blockEnd + return strings.Join([]string{blockStart, strings.TrimSpace(assets.AgentsManagedContent), blockEnd}, "\n") } func manageBlock(path, content string) error { data, err := os.ReadFile(path) diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index f7e5af5..c70e227 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -87,6 +87,30 @@ func TestInstallDryRunAndConfirmationDoNotMutate(t *testing.T) { } } +func TestStatusRejectsModifiedManagedGuidance(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install("main", false, true); err != nil { + t.Fatal(err) + } + agents, _ := os.ReadFile(p.agents) + skill, _ := os.ReadFile(p.skill) + for name, change := range map[string]func(){ + "agents": func() { + mustWrite(t, p.agents, strings.Replace(string(agents), "# ThreadBear", "# ThreadBear edited", 1)) + }, + "skill": func() { mustWrite(t, p.skill, string(skill)+"edited\n") }, + } { + t.Run(name, func(t *testing.T) { + change() + if _, err := status(); err == nil { + t.Fatalf("status accepted modified %s", name) + } + mustWrite(t, p.agents, string(agents)) + mustWrite(t, p.skill, string(skill)) + }) + } +} + func TestMalformedHooksFailBeforeLifecycleMutation(t *testing.T) { p := isolatedLifecycle(t) malformed := []byte(`{"hooks":{"PreToolUse":{"not":"an array"}}}`)