threadkeeper 0.11.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/PKG-INFO +44 -9
  2. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/README.md +43 -8
  3. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/pyproject.toml +1 -1
  4. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_evolve_applier.py +289 -10
  5. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_menubar_app.py +34 -0
  6. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_config.py +4 -4
  7. threadkeeper-0.13.0/tests/test_verify_ingest.py +162 -0
  8. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/README.md +9 -3
  9. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +241 -57
  10. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/config.py +5 -0
  11. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/evolve_applier.py +256 -13
  12. threadkeeper-0.13.0/threadkeeper/verify_ingest.py +313 -0
  13. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/PKG-INFO +44 -9
  14. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/SOURCES.txt +2 -0
  15. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/LICENSE +0 -0
  16. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/setup.cfg +0 -0
  17. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_adapters.py +0 -0
  18. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_agent_status.py +0 -0
  19. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_auto_update.py +0 -0
  20. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_brief_footprint.py +0 -0
  21. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_brief_sections.py +0 -0
  22. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_candidate_reviewer.py +0 -0
  23. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_config_settings.py +0 -0
  24. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_core_memory.py +0 -0
  25. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_curator.py +0 -0
  26. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dashboard.py +0 -0
  27. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_delegated_search.py +0 -0
  28. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic.py +0 -0
  29. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_feed_tools.py +0 -0
  30. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_miner.py +0 -0
  31. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_observation_resolve.py +0 -0
  32. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_recompute.py +0 -0
  33. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_tier.py +0 -0
  34. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_validator.py +0 -0
  35. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_error_paths.py +0 -0
  36. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_evolve_apply_2.py +0 -0
  37. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_evolve_apply_3.py +0 -0
  38. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_evolve_daemon.py +0 -0
  39. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_extract_daemon.py +0 -0
  40. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_extract_dedup.py +0 -0
  41. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_i18n_multilang.py +0 -0
  42. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_identity.py +0 -0
  43. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_ingest_status.py +0 -0
  44. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_lessons.py +0 -0
  45. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_memory_guard.py +0 -0
  46. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_missed_spawns.py +0 -0
  47. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_nudges.py +0 -0
  48. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_onnx_embeddings.py +0 -0
  49. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_panel.py +0 -0
  50. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_probe_daemon.py +0 -0
  51. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_process_health.py +0 -0
  52. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_search_fts_punctuation.py +0 -0
  53. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_shadow_review.py +0 -0
  54. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_hint.py +0 -0
  55. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_passive_tier.py +0 -0
  56. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_tier.py +0 -0
  57. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_use_parser.py +0 -0
  58. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_watcher.py +0 -0
  59. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skills.py +0 -0
  60. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_budget.py +0 -0
  61. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_codex_stdin.py +0 -0
  62. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_hint.py +0 -0
  63. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_reap.py +0 -0
  64. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_slim.py +0 -0
  65. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_wrap.py +0 -0
  66. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_thread_janitor.py +0 -0
  67. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_threads.py +0 -0
  68. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_tools_smoke.py +0 -0
  69. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_validate_threads.py +0 -0
  70. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_vec_search.py +0 -0
  71. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/__init__.py +0 -0
  72. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/_mcp.py +0 -0
  73. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/_setup.py +0 -0
  74. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/_spawn_wrap.py +0 -0
  75. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/__init__.py +0 -0
  76. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
  77. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/antigravity.py +0 -0
  78. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/base.py +0 -0
  79. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/claude_code.py +0 -0
  80. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/claude_desktop.py +0 -0
  81. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/codex.py +0 -0
  82. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/copilot.py +0 -0
  83. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/gemini.py +0 -0
  84. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/vscode.py +0 -0
  85. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/agent_status.py +0 -0
  86. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
  87. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
  88. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
  89. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/auto_update.py +0 -0
  90. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/brief.py +0 -0
  91. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/candidate_reviewer.py +0 -0
  92. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/curator.py +0 -0
  93. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/db.py +0 -0
  94. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/dialectic_miner.py +0 -0
  95. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/dialectic_validator.py +0 -0
  96. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/embeddings.py +0 -0
  97. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/evolve_daemon.py +0 -0
  98. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/extract_daemon.py +0 -0
  99. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/helpers.py +0 -0
  100. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/i18n.py +0 -0
  101. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/identity.py +0 -0
  102. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/ingest.py +0 -0
  103. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/lessons.py +0 -0
  104. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/memory_guard.py +0 -0
  105. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/menubar_app.py +0 -0
  106. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/migrate_embeddings.py +0 -0
  107. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/nudges.py +0 -0
  108. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/probe_daemon.py +0 -0
  109. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/process_health.py +0 -0
  110. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/review_prompts.py +0 -0
  111. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/search_proxy.py +0 -0
  112. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/server.py +0 -0
  113. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/shadow_review.py +0 -0
  114. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/skill_watcher.py +0 -0
  115. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/spawn_budget.py +0 -0
  116. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/spawn_config.py +0 -0
  117. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/thread_janitor.py +0 -0
  118. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/__init__.py +0 -0
  119. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/agent_status.py +0 -0
  120. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
  121. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/concepts.py +0 -0
  122. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/consolidate.py +0 -0
  123. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/core_memory.py +0 -0
  124. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/correlation.py +0 -0
  125. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/curator.py +0 -0
  126. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/dashboard.py +0 -0
  127. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/dialectic.py +0 -0
  128. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/dialectic_feed.py +0 -0
  129. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/dialog.py +0 -0
  130. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/distill.py +0 -0
  131. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/evolve_applier.py +0 -0
  132. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/extract.py +0 -0
  133. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/graph.py +0 -0
  134. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/invariants.py +0 -0
  135. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/lessons.py +0 -0
  136. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/memory_guard.py +0 -0
  137. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/missed_spawns.py +0 -0
  138. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/panel.py +0 -0
  139. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/peers.py +0 -0
  140. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/pickup.py +0 -0
  141. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/probes.py +0 -0
  142. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/process_health.py +0 -0
  143. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/session.py +0 -0
  144. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/shadow_review.py +0 -0
  145. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/skills.py +0 -0
  146. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/spawn.py +0 -0
  147. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/style.py +0 -0
  148. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/threads.py +0 -0
  149. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/validate.py +0 -0
  150. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
  151. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/entry_points.txt +0 -0
  152. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/requires.txt +0 -0
  153. {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadkeeper
3
- Version: 0.11.0
3
+ Version: 0.13.0
4
4
  Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server.
5
5
  Author: thread-keeper contributors
6
6
  License: MIT
@@ -221,7 +221,7 @@ tk-agent-status --cleanup-memory
221
221
  ```
222
222
 
223
223
  `apps/macos-agent-status/` contains a small macOS menu-bar app that polls this
224
- command every 5 seconds and shows every autonomous learning loop: enabled/off,
224
+ command every 15 seconds and shows every autonomous learning loop: enabled/off,
225
225
  running/idle/ready, last pass, backlog, and active child RSS when that loop has
226
226
  spawned a worker. PyPI wheels and sdists also bundle the same Swift source under
227
227
  `threadkeeper/assets/macos-agent-status/`, so a normal `pipx`/`uv tool` install
@@ -239,12 +239,16 @@ memory button, self-restarts when its own RSS crosses
239
239
  notification permission, and sends a notification when a newly completed
240
240
  autonomous child task produces a useful result in `recent_results`; the first
241
241
  poll only marks existing results as seen, so old completions do not spam
242
- notifications. The header gear opens a separate Settings window for
242
+ notifications. Status polling and cleanup commands run off the main actor, so
243
+ opening the popover does not wait for `tk-agent-status --json`. The header gear
244
+ opens a separate Settings window for
243
245
  `~/.threadkeeper/.env`: common knobs are grouped into guided controls, the raw
244
246
  `.env` remains editable for advanced values, three local presets can be saved
245
247
  and loaded, and Save & Restart writes the file then asks existing
246
248
  `threadkeeper.server` processes to exit so MCP hosts reconnect with the new
247
- configuration. Probe backlog is due objective
249
+ configuration. Spawn CLI selectors collapse `agy` into canonical `antigravity`
250
+ while keeping `gemini` as legacy, and model selectors use dropdowns with exact
251
+ CLI model ids/labels instead of free-text fields. Probe backlog is due objective
248
252
  probes only, not every registered probe, so a healthy cooldown shows `0 due
249
253
  probes` instead of looking stuck. On macOS, `python -m threadkeeper.server`
250
254
  automatically installs and launches it on MCP startup, and restarts the app when
@@ -633,11 +637,15 @@ keys are lowercased:
633
637
  # default agent for roles with no explicit pin ("" / unset = use the active CLI)
634
638
  THREADKEEPER_SPAWN__DEFAULT=claude
635
639
  # per-role CLI: THREADKEEPER_SPAWN__LOOP__<ROLE>=<cli>
640
+ # supported CLI keys: claude, codex, antigravity (agy executable), gemini (legacy), copilot
636
641
  THREADKEEPER_SPAWN__LOOP__SHADOW_OBSERVER=claude # heaviest reasoning → keep on Claude
637
642
  THREADKEEPER_SPAWN__LOOP__CURATOR=codex # weekly audit → Codex is fine
638
643
  THREADKEEPER_SPAWN__LOOP__CANDIDATE_REVIEWER=auto # "auto" = follow active CLI
639
644
  # model pin per CLI or per role: THREADKEEPER_SPAWN__MODEL__<KEY>=<model>
640
645
  THREADKEEPER_SPAWN__MODEL__CLAUDE=opus
646
+ THREADKEEPER_SPAWN__MODEL__CODEX=gpt-5.5
647
+ THREADKEEPER_SPAWN__MODEL__AGY="Gemini 3.1 Pro (High)"
648
+ THREADKEEPER_SPAWN__MODEL__GEMINI=gemini-3.1-pro-preview
641
649
  THREADKEEPER_SPAWN__MODEL__DIALECTIC_VALIDATOR=opus
642
650
  ```
643
651
 
@@ -645,7 +653,9 @@ Resolution per role: `SPAWN__LOOP__<role>` → `SPAWN__DEFAULT` → active CLI
645
653
  `claude`; `"auto"` (or unset) defers to the active CLI. Real environment
646
654
  variables override the `.env`. Force host detection with
647
655
  `THREADKEEPER_ACTIVE_CLI=claude` (or `codex`, `antigravity`/`agy`,
648
- `gemini`, `copilot`). See `.env.example` for the full knob list.
656
+ `gemini`, `copilot`). `agy` is normalized to `antigravity`; `gemini` remains a
657
+ legacy Gemini CLI adapter for old installs/enterprise paths. See `.env.example`
658
+ for the full knob list.
649
659
 
650
660
  Adapters without headless support (Claude Desktop, VS Code) can't be
651
661
  spawn targets — `spawn_status()` reports them as "no adapter" and any
@@ -745,12 +755,34 @@ unchanged.
745
755
  ## Verifying ingest across CLIs
746
756
 
747
757
  ```bash
748
- python scripts/tk_verify_ingest.py
758
+ python scripts/tk_verify_ingest.py # both checks below
759
+ python scripts/tk_verify_ingest.py --contract # parse/ingest contract only
760
+ python scripts/tk_verify_ingest.py --live # production verdict only
761
+ python scripts/tk_verify_ingest.py --live --json # machine-readable
749
762
  ```
750
763
 
751
- Walks every installed CLI adapter, parses recent transcripts in an
752
- isolated tempdir DB, reports per-source message counts and any silent
753
- parse failures. Read-only with respect to live state.
764
+ Two read-only checks:
765
+
766
+ - **Contract test** (`--contract`) walks every installed CLI adapter,
767
+ parses recent transcripts into an isolated tempdir DB, reports
768
+ per-source message counts and flags any adapter that parsed messages
769
+ but silently failed to persist them. Answers *"does the pipeline
770
+ work?"*
771
+ - **Production verification** (`--live`) — reads the **live**
772
+ `dialog_messages` table read-only and scores the three acceptance
773
+ criteria from [roadmap issue #1](https://github.com/po4erk91/thread-keeper/issues/1):
774
+ (1) every targeted CLI *slot* has production rows, (2) shadow-review
775
+ sees more than one adapter in the same recent window, (3) the learning
776
+ loop has fired on non-Claude sessions. Emits a `PASS` / `PARTIAL` /
777
+ `FAIL` verdict. The four slots are `claude-code`, `codex`, `copilot`,
778
+ and `google` — where the Google slot is satisfied by *either* the
779
+ legacy `gemini` adapter or its successor Antigravity (`agy`), since
780
+ both live under `~/.gemini`.
781
+
782
+ `--strict` makes the process exit non-zero unless the live verdict is
783
+ `PASS`, so it can gate CI; `PARTIAL` (e.g. a box that doesn't run all
784
+ four CLIs) is a valid real-world state and exits 0 by default. The
785
+ reusable verdict logic lives in `threadkeeper/verify_ingest.py`.
754
786
 
755
787
  ---
756
788
 
@@ -776,6 +808,7 @@ threadkeeper/
776
808
  ├── db.py # SQLite schema + sqlite-vec loader
777
809
  ├── identity.py # session, self-cid, daemon launchers
778
810
  ├── ingest.py # adapter-driven transcript ingest
811
+ ├── verify_ingest.py # cross-CLI production verification verdict
779
812
  ├── brief.py # render_brief / render_context
780
813
  ├── shadow_review.py # autonomous learning observer
781
814
  ├── i18n.py # 10 locales of regex + prompt bundles
@@ -814,3 +847,5 @@ locale. Look for the `good-first-issue` label.
814
847
  ## License
815
848
 
816
849
  MIT — see [LICENSE](LICENSE).
850
+
851
+ <!-- mcp-name: io.github.po4erk91/thread-keeper -->
@@ -180,7 +180,7 @@ tk-agent-status --cleanup-memory
180
180
  ```
181
181
 
182
182
  `apps/macos-agent-status/` contains a small macOS menu-bar app that polls this
183
- command every 5 seconds and shows every autonomous learning loop: enabled/off,
183
+ command every 15 seconds and shows every autonomous learning loop: enabled/off,
184
184
  running/idle/ready, last pass, backlog, and active child RSS when that loop has
185
185
  spawned a worker. PyPI wheels and sdists also bundle the same Swift source under
186
186
  `threadkeeper/assets/macos-agent-status/`, so a normal `pipx`/`uv tool` install
@@ -198,12 +198,16 @@ memory button, self-restarts when its own RSS crosses
198
198
  notification permission, and sends a notification when a newly completed
199
199
  autonomous child task produces a useful result in `recent_results`; the first
200
200
  poll only marks existing results as seen, so old completions do not spam
201
- notifications. The header gear opens a separate Settings window for
201
+ notifications. Status polling and cleanup commands run off the main actor, so
202
+ opening the popover does not wait for `tk-agent-status --json`. The header gear
203
+ opens a separate Settings window for
202
204
  `~/.threadkeeper/.env`: common knobs are grouped into guided controls, the raw
203
205
  `.env` remains editable for advanced values, three local presets can be saved
204
206
  and loaded, and Save & Restart writes the file then asks existing
205
207
  `threadkeeper.server` processes to exit so MCP hosts reconnect with the new
206
- configuration. Probe backlog is due objective
208
+ configuration. Spawn CLI selectors collapse `agy` into canonical `antigravity`
209
+ while keeping `gemini` as legacy, and model selectors use dropdowns with exact
210
+ CLI model ids/labels instead of free-text fields. Probe backlog is due objective
207
211
  probes only, not every registered probe, so a healthy cooldown shows `0 due
208
212
  probes` instead of looking stuck. On macOS, `python -m threadkeeper.server`
209
213
  automatically installs and launches it on MCP startup, and restarts the app when
@@ -592,11 +596,15 @@ keys are lowercased:
592
596
  # default agent for roles with no explicit pin ("" / unset = use the active CLI)
593
597
  THREADKEEPER_SPAWN__DEFAULT=claude
594
598
  # per-role CLI: THREADKEEPER_SPAWN__LOOP__<ROLE>=<cli>
599
+ # supported CLI keys: claude, codex, antigravity (agy executable), gemini (legacy), copilot
595
600
  THREADKEEPER_SPAWN__LOOP__SHADOW_OBSERVER=claude # heaviest reasoning → keep on Claude
596
601
  THREADKEEPER_SPAWN__LOOP__CURATOR=codex # weekly audit → Codex is fine
597
602
  THREADKEEPER_SPAWN__LOOP__CANDIDATE_REVIEWER=auto # "auto" = follow active CLI
598
603
  # model pin per CLI or per role: THREADKEEPER_SPAWN__MODEL__<KEY>=<model>
599
604
  THREADKEEPER_SPAWN__MODEL__CLAUDE=opus
605
+ THREADKEEPER_SPAWN__MODEL__CODEX=gpt-5.5
606
+ THREADKEEPER_SPAWN__MODEL__AGY="Gemini 3.1 Pro (High)"
607
+ THREADKEEPER_SPAWN__MODEL__GEMINI=gemini-3.1-pro-preview
600
608
  THREADKEEPER_SPAWN__MODEL__DIALECTIC_VALIDATOR=opus
601
609
  ```
602
610
 
@@ -604,7 +612,9 @@ Resolution per role: `SPAWN__LOOP__<role>` → `SPAWN__DEFAULT` → active CLI
604
612
  `claude`; `"auto"` (or unset) defers to the active CLI. Real environment
605
613
  variables override the `.env`. Force host detection with
606
614
  `THREADKEEPER_ACTIVE_CLI=claude` (or `codex`, `antigravity`/`agy`,
607
- `gemini`, `copilot`). See `.env.example` for the full knob list.
615
+ `gemini`, `copilot`). `agy` is normalized to `antigravity`; `gemini` remains a
616
+ legacy Gemini CLI adapter for old installs/enterprise paths. See `.env.example`
617
+ for the full knob list.
608
618
 
609
619
  Adapters without headless support (Claude Desktop, VS Code) can't be
610
620
  spawn targets — `spawn_status()` reports them as "no adapter" and any
@@ -704,12 +714,34 @@ unchanged.
704
714
  ## Verifying ingest across CLIs
705
715
 
706
716
  ```bash
707
- python scripts/tk_verify_ingest.py
717
+ python scripts/tk_verify_ingest.py # both checks below
718
+ python scripts/tk_verify_ingest.py --contract # parse/ingest contract only
719
+ python scripts/tk_verify_ingest.py --live # production verdict only
720
+ python scripts/tk_verify_ingest.py --live --json # machine-readable
708
721
  ```
709
722
 
710
- Walks every installed CLI adapter, parses recent transcripts in an
711
- isolated tempdir DB, reports per-source message counts and any silent
712
- parse failures. Read-only with respect to live state.
723
+ Two read-only checks:
724
+
725
+ - **Contract test** (`--contract`) walks every installed CLI adapter,
726
+ parses recent transcripts into an isolated tempdir DB, reports
727
+ per-source message counts and flags any adapter that parsed messages
728
+ but silently failed to persist them. Answers *"does the pipeline
729
+ work?"*
730
+ - **Production verification** (`--live`) — reads the **live**
731
+ `dialog_messages` table read-only and scores the three acceptance
732
+ criteria from [roadmap issue #1](https://github.com/po4erk91/thread-keeper/issues/1):
733
+ (1) every targeted CLI *slot* has production rows, (2) shadow-review
734
+ sees more than one adapter in the same recent window, (3) the learning
735
+ loop has fired on non-Claude sessions. Emits a `PASS` / `PARTIAL` /
736
+ `FAIL` verdict. The four slots are `claude-code`, `codex`, `copilot`,
737
+ and `google` — where the Google slot is satisfied by *either* the
738
+ legacy `gemini` adapter or its successor Antigravity (`agy`), since
739
+ both live under `~/.gemini`.
740
+
741
+ `--strict` makes the process exit non-zero unless the live verdict is
742
+ `PASS`, so it can gate CI; `PARTIAL` (e.g. a box that doesn't run all
743
+ four CLIs) is a valid real-world state and exits 0 by default. The
744
+ reusable verdict logic lives in `threadkeeper/verify_ingest.py`.
713
745
 
714
746
  ---
715
747
 
@@ -735,6 +767,7 @@ threadkeeper/
735
767
  ├── db.py # SQLite schema + sqlite-vec loader
736
768
  ├── identity.py # session, self-cid, daemon launchers
737
769
  ├── ingest.py # adapter-driven transcript ingest
770
+ ├── verify_ingest.py # cross-CLI production verification verdict
738
771
  ├── brief.py # render_brief / render_context
739
772
  ├── shadow_review.py # autonomous learning observer
740
773
  ├── i18n.py # 10 locales of regex + prompt bundles
@@ -773,3 +806,5 @@ locale. Look for the `good-first-issue` label.
773
806
  ## License
774
807
 
775
808
  MIT — see [LICENSE](LICENSE).
809
+
810
+ <!-- mcp-name: io.github.po4erk91/thread-keeper -->
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadkeeper"
7
- version = "0.11.0"
7
+ version = "0.13.0"
8
8
  description = "Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server."
9
9
  requires-python = ">=3.11"
10
10
  authors = [{ name = "thread-keeper contributors" }]
@@ -57,8 +57,26 @@ def _bootstrap(tmp_path, monkeypatch, interval="0"):
57
57
  )
58
58
  monkeypatch.setattr(
59
59
  evolve_applier, "_comment_issue_claim",
60
- lambda issue, repo_root=None: "",
60
+ lambda issue, repo_root=None: ("https://x/issues/0#issuecomment-1", ""),
61
61
  )
62
+ monkeypatch.setattr(
63
+ evolve_applier, "_open_prs_for_issue",
64
+ lambda issue_number, repo_root=None: ([], ""),
65
+ )
66
+ # Note: _resolve_claim_race is NOT monkeypatched here so the new
67
+ # multi-host tests can exercise the real implementation. With the default
68
+ # _fetch_issue_comments returning [], the race resolver sees ≤1 active
69
+ # claim and returns (True, "") — existing tests behave the same.
70
+ monkeypatch.setattr(
71
+ evolve_applier, "_delete_issue_comment",
72
+ lambda comment_url, repo_root=None: "",
73
+ )
74
+ # Skip the real-time race-detection sleep in unit tests so the suite stays
75
+ # snappy. The bootstrap defaults already make the race resolver return True
76
+ # in the "no competing claim" common path.
77
+ import threadkeeper.config as _cfg
78
+ monkeypatch.setattr(_cfg, "ROADMAP_CLAIM_RACE_WINDOW_S", 0.0)
79
+ monkeypatch.setattr(evolve_applier, "ROADMAP_CLAIM_RACE_WINDOW_S", 0.0)
62
80
  return {"mcp": _mcp.mcp, "db": db, "ea": evolve_applier, "identity": identity}
63
81
 
64
82
 
@@ -415,7 +433,10 @@ def test_apply_roadmap_issue_comments_before_spawn(
415
433
 
416
434
  def _claim(issue, repo_root=None):
417
435
  order.append(f"claim#{int(issue['number'])}")
418
- return ""
436
+ return (
437
+ f"https://x/issues/{int(issue['number'])}#issuecomment-99",
438
+ "",
439
+ )
419
440
 
420
441
  def _spawn(**kw):
421
442
  order.append("spawn")
@@ -441,7 +462,7 @@ def test_apply_roadmap_issue_queue_reports_no_startable_when_claim_fails(
441
462
  )
442
463
  monkeypatch.setattr(
443
464
  pkg["ea"], "_comment_issue_claim",
444
- lambda issue, repo_root=None: "gh_issue_comment_failed: denied",
465
+ lambda issue, repo_root=None: ("", "gh_issue_comment_failed: denied"),
445
466
  )
446
467
 
447
468
  def _boom(**kw):
@@ -473,8 +494,8 @@ def test_apply_roadmap_issue_queue_tries_next_when_claim_fails(
473
494
  num = int(issue["number"])
474
495
  claimed.append(num)
475
496
  if num == 1:
476
- return "gh_issue_comment_failed: locked"
477
- return ""
497
+ return "", "gh_issue_comment_failed: locked"
498
+ return f"https://x/issues/{num}#issuecomment-{num}", ""
478
499
 
479
500
  monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
480
501
  calls = {}
@@ -501,7 +522,7 @@ def test_apply_roadmap_issue_exact_issue_does_not_switch_tasks(
501
522
  )
502
523
  monkeypatch.setattr(
503
524
  pkg["ea"], "_comment_issue_claim",
504
- lambda issue, repo_root=None: "gh_issue_comment_failed: locked",
525
+ lambda issue, repo_root=None: ("", "gh_issue_comment_failed: locked"),
505
526
  )
506
527
 
507
528
  def _boom(**kw):
@@ -557,6 +578,263 @@ def test_mark_roadmap_issue_applied_tool_requires_pr_url(
557
578
  assert row["summary"] == "https://github.com/o/r/pull/6"
558
579
 
559
580
 
581
+ # ── multi-host: cross-machine conflict guards ──────────────────────────────
582
+
583
+ def test_apply_roadmap_issue_skips_when_open_pr_already_closes_it(
584
+ tmp_path, monkeypatch,
585
+ ):
586
+ """If another host (or a prior crashed applier) already opened a PR for
587
+ this issue, do NOT spawn or claim — fall through to the next candidate."""
588
+ pkg = _bootstrap(tmp_path, monkeypatch)
589
+ monkeypatch.setattr(
590
+ pkg["ea"], "_fetch_open_issues",
591
+ lambda repo_root=None: (
592
+ [_issue(6, "Telemetry dashboard"), _issue(7, "Free issue")],
593
+ "",
594
+ ),
595
+ )
596
+ monkeypatch.setattr(
597
+ pkg["ea"], "_open_prs_for_issue",
598
+ lambda issue_number, repo_root=None: (
599
+ [{"url": "https://github.com/o/r/pull/42",
600
+ "number": 42}] if int(issue_number) == 6 else [],
601
+ "",
602
+ ),
603
+ )
604
+ claimed = []
605
+
606
+ def _claim(issue, repo_root=None):
607
+ num = int(issue["number"])
608
+ claimed.append(num)
609
+ return f"https://x/issues/{num}#issuecomment-{num}", ""
610
+
611
+ monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
612
+ calls = {}
613
+ _mock_spawn(monkeypatch, calls)
614
+
615
+ out = pkg["ea"].apply_roadmap_issue()
616
+
617
+ # advanced past #6 (open PR) to #7
618
+ assert out.startswith("spawned roadmap_issue=#7"), out
619
+ # claim was NOT posted for #6 — the open-PR check ran before claim
620
+ assert claimed == [7]
621
+
622
+
623
+ def test_apply_roadmap_issue_exact_mode_returns_open_pr_error(
624
+ tmp_path, monkeypatch,
625
+ ):
626
+ pkg = _bootstrap(tmp_path, monkeypatch)
627
+ monkeypatch.setattr(
628
+ pkg["ea"], "_fetch_open_issues",
629
+ lambda repo_root=None: ([_issue(6, "Telemetry dashboard")], ""),
630
+ )
631
+ monkeypatch.setattr(
632
+ pkg["ea"], "_open_prs_for_issue",
633
+ lambda issue_number, repo_root=None: (
634
+ [{"url": "https://github.com/o/r/pull/42"}], "",
635
+ ),
636
+ )
637
+
638
+ def _claim(issue, repo_root=None):
639
+ raise AssertionError("must not claim when an open PR already exists")
640
+
641
+ monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
642
+
643
+ def _boom(**kw):
644
+ raise AssertionError("must not spawn when an open PR already exists")
645
+
646
+ import threadkeeper.tools.spawn as spawn_mod
647
+ monkeypatch.setattr(spawn_mod, "spawn", _boom)
648
+
649
+ out = pkg["ea"].apply_roadmap_issue(issue_number=6)
650
+
651
+ assert out.startswith("ERR roadmap_issue_open_pr=#6"), out
652
+ assert "pull/42" in out
653
+
654
+
655
+ def test_apply_roadmap_issue_retracts_claim_on_lost_race(
656
+ tmp_path, monkeypatch,
657
+ ):
658
+ """TOCTOU: after we post our claim, a competing host's earlier claim is
659
+ visible. We retract our own claim and let the queue advance."""
660
+ pkg = _bootstrap(tmp_path, monkeypatch)
661
+ monkeypatch.setattr(
662
+ pkg["ea"], "_fetch_open_issues",
663
+ lambda repo_root=None: (
664
+ [_issue(6, "Telemetry dashboard"), _issue(7, "Other issue")],
665
+ "",
666
+ ),
667
+ )
668
+
669
+ def _claim(issue, repo_root=None):
670
+ return (
671
+ f"https://x/issues/{int(issue['number'])}#issuecomment-mine",
672
+ "",
673
+ )
674
+
675
+ monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
676
+
677
+ def _race(issue_number, my_comment_url, repo_root=None):
678
+ if int(issue_number) == 6:
679
+ return False, "" # lost
680
+ return True, ""
681
+
682
+ monkeypatch.setattr(pkg["ea"], "_resolve_claim_race", _race)
683
+ calls = {}
684
+ _mock_spawn(monkeypatch, calls)
685
+
686
+ out = pkg["ea"].apply_roadmap_issue()
687
+
688
+ assert out.startswith("spawned roadmap_issue=#7"), out
689
+ assert "ISSUE #7: Other issue" in calls["prompt"]
690
+
691
+
692
+ def test_apply_roadmap_issue_retracts_claim_on_spawn_failure(
693
+ tmp_path, monkeypatch,
694
+ ):
695
+ """If spawn() raises after we posted our claim, retract the claim so the
696
+ next pass can retry the issue immediately instead of waiting 24h TTL."""
697
+ pkg = _bootstrap(tmp_path, monkeypatch)
698
+ monkeypatch.setattr(
699
+ pkg["ea"], "_fetch_open_issues",
700
+ lambda repo_root=None: ([_issue(6, "Telemetry dashboard")], ""),
701
+ )
702
+ monkeypatch.setattr(
703
+ pkg["ea"], "_comment_issue_claim",
704
+ lambda issue, repo_root=None: (
705
+ "https://x/issues/6#issuecomment-mine", "",
706
+ ),
707
+ )
708
+
709
+ deleted = []
710
+ monkeypatch.setattr(
711
+ pkg["ea"], "_delete_issue_comment",
712
+ lambda comment_url, repo_root=None: (
713
+ deleted.append(comment_url) or ""
714
+ ),
715
+ )
716
+
717
+ import threadkeeper.tools.spawn as spawn_mod
718
+ monkeypatch.setattr(
719
+ spawn_mod, "spawn",
720
+ lambda **kw: (_ for _ in ()).throw(RuntimeError("spawn rejected")),
721
+ )
722
+
723
+ out = pkg["ea"].apply_roadmap_issue(issue_number=6)
724
+
725
+ assert out.startswith("spawn_error issue=#6"), out
726
+ assert "spawn rejected" in out
727
+ assert deleted == ["https://x/issues/6#issuecomment-mine"]
728
+
729
+
730
+ def test_resolve_claim_race_wins_when_oldest_active_claim_is_ours(
731
+ tmp_path, monkeypatch,
732
+ ):
733
+ pkg = _bootstrap(tmp_path, monkeypatch)
734
+ monkeypatch.setattr(
735
+ pkg["ea"], "_fetch_issue_comments",
736
+ lambda issue_number, repo_root=None: (
737
+ [
738
+ {
739
+ "body": "<!-- thread-keeper:evolve-applier-claim -->\nmine",
740
+ "url": "https://x/issues/6#issuecomment-100",
741
+ "createdAt": "2026-06-14T12:00:00Z",
742
+ },
743
+ {
744
+ "body": "<!-- thread-keeper:evolve-applier-claim -->\nthem",
745
+ "url": "https://x/issues/6#issuecomment-200",
746
+ "createdAt": "2026-06-14T12:00:03Z",
747
+ },
748
+ ],
749
+ "",
750
+ ),
751
+ )
752
+ monkeypatch.setattr(pkg["ea"].time, "time", lambda: 1781438400.0)
753
+ monkeypatch.setattr(pkg["ea"].time, "sleep", lambda _s: None)
754
+
755
+ won, err = pkg["ea"]._resolve_claim_race(
756
+ 6, "https://x/issues/6#issuecomment-100",
757
+ )
758
+ assert err == ""
759
+ assert won is True
760
+
761
+
762
+ def test_resolve_claim_race_loses_and_deletes_own_claim(
763
+ tmp_path, monkeypatch,
764
+ ):
765
+ pkg = _bootstrap(tmp_path, monkeypatch)
766
+ monkeypatch.setattr(
767
+ pkg["ea"], "_fetch_issue_comments",
768
+ lambda issue_number, repo_root=None: (
769
+ [
770
+ {
771
+ "body": "<!-- thread-keeper:evolve-applier-claim -->\nthem",
772
+ "url": "https://x/issues/6#issuecomment-100",
773
+ "createdAt": "2026-06-14T12:00:00Z",
774
+ },
775
+ {
776
+ "body": "<!-- thread-keeper:evolve-applier-claim -->\nmine",
777
+ "url": "https://x/issues/6#issuecomment-200",
778
+ "createdAt": "2026-06-14T12:00:03Z",
779
+ },
780
+ ],
781
+ "",
782
+ ),
783
+ )
784
+ monkeypatch.setattr(pkg["ea"].time, "time", lambda: 1781438400.0)
785
+ monkeypatch.setattr(pkg["ea"].time, "sleep", lambda _s: None)
786
+
787
+ deleted = []
788
+ monkeypatch.setattr(
789
+ pkg["ea"], "_delete_issue_comment",
790
+ lambda url, repo_root=None: (deleted.append(url) or ""),
791
+ )
792
+
793
+ won, err = pkg["ea"]._resolve_claim_race(
794
+ 6, "https://x/issues/6#issuecomment-200",
795
+ )
796
+ assert err == ""
797
+ assert won is False
798
+ assert deleted == ["https://x/issues/6#issuecomment-200"]
799
+
800
+
801
+ def test_claim_body_includes_host_pid_git_rev(tmp_path, monkeypatch):
802
+ pkg = _bootstrap(tmp_path, monkeypatch)
803
+ issue = _issue(42, "Cross-host check")
804
+ body = pkg["ea"]._roadmap_issue_claim_body(issue, now_t=1781438400.0)
805
+ assert pkg["ea"].ROADMAP_ISSUE_CLAIM_MARKER in body
806
+ # The new identity block fields must be present so multi-host triage works.
807
+ assert "- Host:" in body
808
+ assert "- PID:" in body
809
+ assert "- Git rev:" in body
810
+ assert "- Started:" in body
811
+ assert "Claim TTL:" in body
812
+
813
+
814
+ def test_roadmap_branch_name_carries_host_suffix(tmp_path, monkeypatch):
815
+ pkg = _bootstrap(tmp_path, monkeypatch)
816
+ branch = pkg["ea"].roadmap_issue_branch_name(7, "Hot config reload")
817
+ assert branch.startswith("roadmap/issue-7-hot-config-reload-")
818
+ suffix = branch.rsplit("-", 1)[-1]
819
+ # 6 hex chars from the hostname sha1
820
+ assert len(suffix) == 6
821
+ assert all(c in "0123456789abcdef" for c in suffix)
822
+
823
+
824
+ def test_comment_url_to_id_parses_github_url_shape():
825
+ """The race resolver relies on this to match our own posted claim back to
826
+ the comments list."""
827
+ from threadkeeper.evolve_applier import _comment_url_to_id
828
+ assert _comment_url_to_id(
829
+ "https://github.com/o/r/issues/6#issuecomment-12345"
830
+ ) == "12345"
831
+ assert _comment_url_to_id(
832
+ "https://github.com/o/r/issues/6#issuecomment_67890"
833
+ ) == "67890"
834
+ assert _comment_url_to_id("https://github.com/o/r/issues/6") == ""
835
+ assert _comment_url_to_id("") == ""
836
+
837
+
560
838
  # ── single-flight: refuse while an applier child runs ──────────────────────
561
839
 
562
840
  def test_apply_evolve_single_flight(tmp_path, monkeypatch):
@@ -777,9 +1055,10 @@ def test_run_apply_pass_skips_unstartable_issue_and_spawns_next(
777
1055
  )
778
1056
 
779
1057
  def _claim(issue, repo_root=None):
780
- if int(issue["number"]) == 1:
781
- return "gh_issue_comment_failed: locked"
782
- return ""
1058
+ num = int(issue["number"])
1059
+ if num == 1:
1060
+ return "", "gh_issue_comment_failed: locked"
1061
+ return f"https://x/issues/{num}#issuecomment-{num}", ""
783
1062
 
784
1063
  monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
785
1064
  calls = {}
@@ -803,7 +1082,7 @@ def test_run_apply_pass_falls_back_to_curator_when_no_issue_startable(
803
1082
  )
804
1083
  monkeypatch.setattr(
805
1084
  pkg["ea"], "_comment_issue_claim",
806
- lambda issue, repo_root=None: "gh_issue_comment_failed: locked",
1085
+ lambda issue, repo_root=None: ("", "gh_issue_comment_failed: locked"),
807
1086
  )
808
1087
  calls = {}
809
1088
  _mock_spawn(monkeypatch, calls)
@@ -46,6 +46,8 @@ def test_menubar_status_item_uses_idle_chip_and_running_gears():
46
46
  assert 'button.title = ""' in swift
47
47
  assert 'button.title = " TK' not in swift
48
48
  assert 'return "TK ' not in swift
49
+ assert "statusPollInterval: TimeInterval = 15.0" in swift
50
+ assert "Timer.scheduledTimer(withTimeInterval: statusPollInterval" in swift
49
51
  assert "Timer(timeInterval: gearSpinInterval" in swift
50
52
  assert "gearFrameStepDegrees = 17.0" in swift
51
53
  assert "largeGearDiameter: CGFloat = 12.0" in swift
@@ -59,6 +61,9 @@ def test_menubar_status_item_uses_idle_chip_and_running_gears():
59
61
  assert "store.snapshot.runningCount > 0" not in swift
60
62
  assert "button.image = gearFrames" in swift
61
63
  assert "TimelineView" not in swift
64
+ assert "refreshInFlight" in swift
65
+ assert "Task.detached(priority: .utility)" in swift
66
+ assert "nonisolated private static func runStatusCommand" in swift
62
67
  assert "store.openEnvSettings()" in swift
63
68
  assert '.help("Settings")' in swift
64
69
  assert '.help("Refresh")' not in swift
@@ -67,6 +72,19 @@ def test_menubar_status_item_uses_idle_chip_and_running_gears():
67
72
  assert '.help("Clean memory")' in swift
68
73
 
69
74
 
75
+ def test_menubar_popover_shows_before_status_refresh():
76
+ repo = Path(__file__).resolve().parents[1]
77
+ swift = (
78
+ repo / "apps" / "macos-agent-status" / "ThreadKeeperAgentStatus.swift"
79
+ ).read_text(encoding="utf-8")
80
+
81
+ start = swift.index("@objc private func togglePopover")
82
+ end = swift.index(" private func updateStatusButton", start)
83
+ body = swift[start:end]
84
+
85
+ assert body.index("popover.show(") < body.index("store.refresh()")
86
+
87
+
70
88
  def test_menubar_env_settings_window_edits_env_and_presets():
71
89
  repo = Path(__file__).resolve().parents[1]
72
90
  swift = (
@@ -81,6 +99,22 @@ def test_menubar_env_settings_window_edits_env_and_presets():
81
99
  assert "(1...3).map" in swift
82
100
  assert "EnvPresetCard" in swift
83
101
  assert "mergeEnvText(raw:" in swift
102
+ assert "EnvSettingsTab" in swift
103
+ assert "case .raw:" in swift
104
+ assert "saveRaw(restart:" in swift
105
+ assert ".onChange(of: envStore.rawEnvText)" not in swift
106
+ assert "syncRawEditsIntoForm" not in swift
107
+ assert 'ChoiceOption("antigravity", label: "antigravity (agy)")' in swift
108
+ assert 'ChoiceOption("agy")' not in swift
109
+ assert 'ChoiceOption("gemini", label: "gemini (legacy)")' in swift
110
+ assert "antigravityModelChoices" in swift
111
+ assert "geminiLegacyModelChoices" in swift
112
+ assert '"Gemini 3.1 Pro (High)"' in swift
113
+ assert '"Gemini 3.5 Flash (Medium)"' in swift
114
+ assert '"gemini-3.1-pro-preview"' in swift
115
+ assert '"gemini-3.1-pro"' not in swift
116
+ assert "THREADKEEPER_SPAWN__MODEL__CODEX" in swift
117
+ assert "THREADKEEPER_SPAWN__MODEL__GEMINI" in swift
84
118
  assert "THREADKEEPER_DISABLE_BG_DAEMONS" in swift
85
119
  assert "THREADKEEPER_EVOLVE_APPLY_INTERVAL_S" in swift
86
120
  assert "THREADKEEPER_SPAWN__MODEL__EVOLVE_APPLIER" in swift