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.
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/PKG-INFO +44 -9
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/README.md +43 -8
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/pyproject.toml +1 -1
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_evolve_applier.py +289 -10
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_menubar_app.py +34 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_config.py +4 -4
- threadkeeper-0.13.0/tests/test_verify_ingest.py +162 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/README.md +9 -3
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +241 -57
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/config.py +5 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/evolve_applier.py +256 -13
- threadkeeper-0.13.0/threadkeeper/verify_ingest.py +313 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/PKG-INFO +44 -9
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/SOURCES.txt +2 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/LICENSE +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/setup.cfg +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_adapters.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_agent_status.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_auto_update.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_brief_footprint.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_config_settings.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_curator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dashboard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_feed_tools.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_miner.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_observation_resolve.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_recompute.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_dialectic_validator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_evolve_apply_2.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_evolve_apply_3.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_evolve_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_extract_dedup.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_identity.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_ingest_status.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_lessons.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_nudges.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_onnx_embeddings.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_panel.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_probe_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_process_health.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_search_fts_punctuation.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_passive_tier.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_skills.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_codex_stdin.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_reap.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_slim.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_spawn_wrap.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_thread_janitor.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_threads.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/_spawn_wrap.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/antigravity.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/gemini.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/agent_status.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/auto_update.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/curator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/db.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/dialectic_miner.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/dialectic_validator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/evolve_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/identity.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/menubar_app.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/migrate_embeddings.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/probe_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/server.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/shadow_review.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/thread_janitor.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/agent_status.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/dashboard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/dialectic_feed.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/evolve_applier.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/panel.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/spawn.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.13.0}/threadkeeper.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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.
|
|
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.
|
|
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`).
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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`).
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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.
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|