threadkeeper 0.7.0__tar.gz → 0.8.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.7.0/threadkeeper.egg-info → threadkeeper-0.8.0}/PKG-INFO +1 -1
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/pyproject.toml +1 -1
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_curator.py +82 -0
- threadkeeper-0.8.0/tests/test_evolve_daemon.py +187 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_nudges.py +13 -0
- threadkeeper-0.8.0/tests/test_panel.py +188 -0
- threadkeeper-0.8.0/tests/test_probe_daemon.py +211 -0
- threadkeeper-0.8.0/tests/test_search_fts_punctuation.py +67 -0
- threadkeeper-0.8.0/tests/test_skill_passive_tier.py +117 -0
- threadkeeper-0.8.0/tests/test_spawn_reap.py +80 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/brief.py +21 -7
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/config.py +60 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/curator.py +61 -1
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/db.py +9 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/embeddings.py +5 -1
- threadkeeper-0.8.0/threadkeeper/evolve_daemon.py +233 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/helpers.py +21 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/identity.py +10 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/ingest.py +68 -35
- threadkeeper-0.8.0/threadkeeper/probe_daemon.py +276 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/server.py +1 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/shadow_review.py +2 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/dialectic.py +20 -2
- threadkeeper-0.8.0/threadkeeper/tools/panel.py +195 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/spawn.py +59 -5
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/threads.py +48 -3
- {threadkeeper-0.7.0 → threadkeeper-0.8.0/threadkeeper.egg-info}/PKG-INFO +1 -1
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper.egg-info/SOURCES.txt +9 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/LICENSE +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/README.md +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/setup.cfg +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_adapters.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_identity.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_lessons.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_onnx_embeddings.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_process_health.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_skills.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_spawn_slim.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_threads.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/adapters/gemini.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/migrate_embeddings.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.0}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.7.0 → threadkeeper-0.8.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.8.0
|
|
4
4
|
Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, 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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "threadkeeper"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Multi-agent shared brain across Claude Code/Desktop, Codex, 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" }]
|
|
@@ -321,3 +321,85 @@ def test_advisory_mode_default_excludes_destructive_tools(
|
|
|
321
321
|
assert "lesson_append" not in allowed
|
|
322
322
|
assert "ADVISORY MODE" in kw["prompt"]
|
|
323
323
|
assert "DESTRUCTIVE MODE ENABLED" not in kw["prompt"]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
327
|
+
# Concepts review (F1) — curator also audits the concepts store
|
|
328
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
def _add_concept(conn, cid, desc, confidence="medium",
|
|
331
|
+
registered_at=None, last_evidence_at=None):
|
|
332
|
+
now = int(time.time())
|
|
333
|
+
conn.execute(
|
|
334
|
+
"INSERT INTO concepts (id, description, confidence, registered_at, "
|
|
335
|
+
"last_evidence_at) VALUES (?,?,?,?,?)",
|
|
336
|
+
(cid, desc, confidence, registered_at or now, last_evidence_at),
|
|
337
|
+
)
|
|
338
|
+
conn.commit()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_collect_concepts_empty(tmp_path, monkeypatch):
|
|
342
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
343
|
+
conn = pkg["db"].get_db()
|
|
344
|
+
text, n = pkg["curator"]._collect_concepts(conn)
|
|
345
|
+
assert n == 0
|
|
346
|
+
assert text == ""
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_collect_concepts_lists_with_age(tmp_path, monkeypatch):
|
|
350
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
351
|
+
conn = pkg["db"].get_db()
|
|
352
|
+
now = int(time.time())
|
|
353
|
+
_add_concept(conn, "Cfresh", "fresh high-conf idea",
|
|
354
|
+
confidence="high", last_evidence_at=now - 86400) # 1d
|
|
355
|
+
_add_concept(conn, "Cstale", "stale low-conf idea",
|
|
356
|
+
confidence="low",
|
|
357
|
+
registered_at=now - 40 * 86400,
|
|
358
|
+
last_evidence_at=None) # never corroborated, 40d old
|
|
359
|
+
text, n = pkg["curator"]._collect_concepts(conn)
|
|
360
|
+
assert n == 2
|
|
361
|
+
assert "Cfresh" in text and "Cstale" in text
|
|
362
|
+
assert "CONCEPTS (n=2)" in text
|
|
363
|
+
# stale concept (no last_evidence, registered 40d ago) shows ~40d age
|
|
364
|
+
assert "40d_ago" in text
|
|
365
|
+
# oldest-first ordering: stale concept appears before fresh one
|
|
366
|
+
assert text.index("Cstale") < text.index("Cfresh")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_run_curator_pass_includes_concepts_in_inventory(
|
|
370
|
+
tmp_path, monkeypatch,
|
|
371
|
+
):
|
|
372
|
+
pkg = _bootstrap(tmp_path, monkeypatch, min_lessons="2")
|
|
373
|
+
pkg["lessons"].append_lesson(title="a", body="b1", source="shadow")
|
|
374
|
+
pkg["lessons"].append_lesson(title="b", body="b2", source="shadow")
|
|
375
|
+
conn = pkg["db"].get_db()
|
|
376
|
+
_add_concept(conn, "Cabc", "asymmetric in-band reactivity",
|
|
377
|
+
confidence="high")
|
|
378
|
+
|
|
379
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
380
|
+
captured: list[dict] = []
|
|
381
|
+
monkeypatch.setattr(
|
|
382
|
+
spawn_mod, "spawn",
|
|
383
|
+
lambda **kw: captured.append(kw) or "spawn task_id=fake pid=0",
|
|
384
|
+
)
|
|
385
|
+
pkg["curator"].run_curator_pass(force=True)
|
|
386
|
+
prompt = captured[0]["prompt"]
|
|
387
|
+
assert "CONCEPTS (n=1)" in prompt
|
|
388
|
+
assert "Cabc" in prompt
|
|
389
|
+
assert "asymmetric in-band reactivity" in prompt
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_concepts_alone_do_not_trigger_pass(tmp_path, monkeypatch):
|
|
393
|
+
"""Concepts enrich the review but don't lower the lesson threshold —
|
|
394
|
+
a pass still requires CURATOR_MIN_LESSONS lessons."""
|
|
395
|
+
pkg = _bootstrap(tmp_path, monkeypatch, min_lessons="3")
|
|
396
|
+
conn = pkg["db"].get_db()
|
|
397
|
+
_add_concept(conn, "Conly", "a lone concept", confidence="high")
|
|
398
|
+
|
|
399
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
400
|
+
called = []
|
|
401
|
+
monkeypatch.setattr(spawn_mod, "spawn",
|
|
402
|
+
lambda **kw: called.append(kw) or "x")
|
|
403
|
+
out = pkg["curator"].run_curator_pass(force=True)
|
|
404
|
+
assert out.startswith("below_threshold")
|
|
405
|
+
assert called == []
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Evolve reviewer daemon — autonomous triage of the format-evolution queue.
|
|
2
|
+
|
|
3
|
+
The daemon never APPLIES a suggestion (that edits format/code). It spawns a
|
|
4
|
+
child that calls evolve_decide(promote|dismiss) to keep the queue honest.
|
|
5
|
+
Tests exercise the pure logic + dispatch with spawn monkeypatched; no real
|
|
6
|
+
child is launched.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_FAKE_CID = "dddd4444-5555-6666-7777-888899990000"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _bootstrap(tmp_path, monkeypatch, interval="0", review_min="2"):
|
|
19
|
+
env = {
|
|
20
|
+
"THREADKEEPER_DB": str(tmp_path / "db.sqlite"),
|
|
21
|
+
"CLAUDE_PROJECTS_DIR": str(tmp_path / "fake_claude_projects"),
|
|
22
|
+
"THREADKEEPER_INGEST_INTERVAL_S": "0",
|
|
23
|
+
"THREADKEEPER_INGEST_CAP": "0",
|
|
24
|
+
"THREADKEEPER_SKILL_WATCH_INTERVAL_S": "0",
|
|
25
|
+
"THREADKEEPER_SPAWN_BUDGET_POLL_S": "0",
|
|
26
|
+
"THREADKEEPER_SEARCH_PROXY_POLL_S": "0",
|
|
27
|
+
"THREADKEEPER_MEMORY_GUARD_POLL_S": "0",
|
|
28
|
+
"THREADKEEPER_SHADOW_REVIEW_INTERVAL_S": "0",
|
|
29
|
+
"THREADKEEPER_CURATOR_INTERVAL_S": "0",
|
|
30
|
+
"THREADKEEPER_EXTRACT_INTERVAL_S": "0",
|
|
31
|
+
"THREADKEEPER_CANDIDATE_REVIEW_INTERVAL_S": "0",
|
|
32
|
+
"THREADKEEPER_PROBE_INTERVAL_S": "0",
|
|
33
|
+
"THREADKEEPER_EVOLVE_REVIEW_INTERVAL_S": interval,
|
|
34
|
+
"THREADKEEPER_EVOLVE_REVIEW_MIN": review_min,
|
|
35
|
+
"THREADKEEPER_TASK_LOG_DIR": str(tmp_path / "tasks"),
|
|
36
|
+
"THREADKEEPER_CLIENT": "pytest",
|
|
37
|
+
"THREADKEEPER_FORCE_CID": _FAKE_CID,
|
|
38
|
+
"THREADKEEPER_NO_EMBEDDINGS": "1",
|
|
39
|
+
}
|
|
40
|
+
for k, v in env.items():
|
|
41
|
+
monkeypatch.setenv(k, v)
|
|
42
|
+
Path(env["CLAUDE_PROJECTS_DIR"]).mkdir(parents=True, exist_ok=True)
|
|
43
|
+
for name in [m for m in list(sys.modules) if m.startswith("threadkeeper")]:
|
|
44
|
+
del sys.modules[name]
|
|
45
|
+
import threadkeeper.server # noqa: F401
|
|
46
|
+
from threadkeeper import _mcp, db, evolve_daemon, identity
|
|
47
|
+
return {"mcp": _mcp.mcp, "db": db, "ed": evolve_daemon, "identity": identity}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _tool(pkg, name):
|
|
51
|
+
return pkg["mcp"]._tool_manager._tools[name].fn
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _add_evolve(conn, suggestion, rationale=None, applied=0, status="pending"):
|
|
55
|
+
conn.execute(
|
|
56
|
+
"INSERT INTO evolve (suggestion, rationale, applied, status, created_at) "
|
|
57
|
+
"VALUES (?,?,?,?,?)",
|
|
58
|
+
(suggestion, rationale, applied, status, int(time.time())),
|
|
59
|
+
)
|
|
60
|
+
conn.commit()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── pending selection ──────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def test_pending_excludes_applied_and_decided(tmp_path, monkeypatch):
|
|
66
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
67
|
+
conn = pkg["db"].get_db()
|
|
68
|
+
_add_evolve(conn, "pending one")
|
|
69
|
+
_add_evolve(conn, "already applied", applied=1)
|
|
70
|
+
_add_evolve(conn, "already dismissed", status="dismissed")
|
|
71
|
+
_add_evolve(conn, "already promoted", status="promoted")
|
|
72
|
+
pend = pkg["ed"]._pending(conn)
|
|
73
|
+
sugg = [r["suggestion"] for r in pend]
|
|
74
|
+
assert sugg == ["pending one"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── evolve_decide tool ─────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def test_evolve_decide_promote(tmp_path, monkeypatch):
|
|
80
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
81
|
+
conn = pkg["db"].get_db()
|
|
82
|
+
_add_evolve(conn, "make briefs shorter")
|
|
83
|
+
eid = conn.execute("SELECT id FROM evolve").fetchone()["id"]
|
|
84
|
+
out = _tool(pkg, "evolve_decide")(evolve_id=eid, decision="promote",
|
|
85
|
+
reason="clear win")
|
|
86
|
+
assert "status=promoted" in out
|
|
87
|
+
row = conn.execute("SELECT status, review_reason, reviewed_at FROM evolve "
|
|
88
|
+
"WHERE id=?", (eid,)).fetchone()
|
|
89
|
+
assert row["status"] == "promoted"
|
|
90
|
+
assert row["review_reason"] == "clear win"
|
|
91
|
+
assert row["reviewed_at"] is not None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_evolve_decide_dismiss_and_bad_inputs(tmp_path, monkeypatch):
|
|
95
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
96
|
+
conn = pkg["db"].get_db()
|
|
97
|
+
_add_evolve(conn, "dup suggestion")
|
|
98
|
+
eid = conn.execute("SELECT id FROM evolve").fetchone()["id"]
|
|
99
|
+
assert "status=dismissed" in _tool(pkg, "evolve_decide")(
|
|
100
|
+
evolve_id=eid, decision="dismiss", reason="duplicate of #1")
|
|
101
|
+
assert _tool(pkg, "evolve_decide")(
|
|
102
|
+
evolve_id=eid, decision="banana").startswith("ERR bad_decision")
|
|
103
|
+
assert _tool(pkg, "evolve_decide")(
|
|
104
|
+
evolve_id=9999, decision="promote").startswith("ERR evolve_not_found")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── run_evolve_pass dispatch ────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def test_run_evolve_pass_disabled(tmp_path, monkeypatch):
|
|
110
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
111
|
+
assert pkg["ed"].run_evolve_pass() == "disabled"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_run_evolve_pass_no_pending(tmp_path, monkeypatch):
|
|
115
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
116
|
+
assert pkg["ed"].run_evolve_pass(force=True) == "no_pending"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_run_evolve_pass_below_min(tmp_path, monkeypatch):
|
|
120
|
+
pkg = _bootstrap(tmp_path, monkeypatch, review_min="2")
|
|
121
|
+
conn = pkg["db"].get_db()
|
|
122
|
+
_add_evolve(conn, "only one")
|
|
123
|
+
assert pkg["ed"].run_evolve_pass(force=True) == "below_min n=1"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_run_evolve_pass_spawns_reviewer(tmp_path, monkeypatch):
|
|
127
|
+
pkg = _bootstrap(tmp_path, monkeypatch, review_min="2")
|
|
128
|
+
conn = pkg["db"].get_db()
|
|
129
|
+
_add_evolve(conn, "suggestion alpha", rationale="friction A")
|
|
130
|
+
_add_evolve(conn, "suggestion beta")
|
|
131
|
+
calls = {}
|
|
132
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
133
|
+
monkeypatch.setattr(spawn_mod, "spawn",
|
|
134
|
+
lambda **kw: calls.update(kw) or "ok task=tk_ev pid=1")
|
|
135
|
+
out = pkg["ed"].run_evolve_pass(force=True)
|
|
136
|
+
assert out.startswith("spawned n=2")
|
|
137
|
+
# both suggestions reached the child prompt
|
|
138
|
+
assert "suggestion alpha" in calls["prompt"]
|
|
139
|
+
assert "suggestion beta" in calls["prompt"]
|
|
140
|
+
assert "friction A" in calls["prompt"]
|
|
141
|
+
assert calls["write_origin"] == "evolve"
|
|
142
|
+
assert calls["role"] == "evolve_reviewer"
|
|
143
|
+
# narrow tool surface: triage only, never applies
|
|
144
|
+
assert "evolve_decide" in calls["extra_allowed_tools"]
|
|
145
|
+
assert "skill_manage" not in calls["extra_allowed_tools"]
|
|
146
|
+
assert pkg["ed"]._last_evolve_ts(conn) > 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_run_evolve_pass_single_flight(tmp_path, monkeypatch):
|
|
150
|
+
pkg = _bootstrap(tmp_path, monkeypatch, review_min="1")
|
|
151
|
+
conn = pkg["db"].get_db()
|
|
152
|
+
_add_evolve(conn, "s1")
|
|
153
|
+
import os
|
|
154
|
+
conn.execute(
|
|
155
|
+
"INSERT INTO tasks (id, pid, cwd, prompt, started_at) "
|
|
156
|
+
"VALUES (?,?,?,?,?)",
|
|
157
|
+
("tk_evr", os.getpid(), "/tmp",
|
|
158
|
+
"You are an EVOLVE REVIEWER triaging the queue.", int(time.time())),
|
|
159
|
+
)
|
|
160
|
+
conn.commit()
|
|
161
|
+
|
|
162
|
+
def _boom(**kw):
|
|
163
|
+
raise AssertionError("must not spawn while a reviewer runs")
|
|
164
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
165
|
+
monkeypatch.setattr(spawn_mod, "spawn", _boom)
|
|
166
|
+
assert "reviewer_running" in pkg["ed"].run_evolve_pass(force=True)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── brief surfaces promoted ★ first, drops dismissed ───────────────────
|
|
170
|
+
|
|
171
|
+
def test_brief_evolve_promoted_marked_dismissed_hidden(tmp_path, monkeypatch):
|
|
172
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
173
|
+
conn = pkg["db"].get_db()
|
|
174
|
+
_add_evolve(conn, "promoted one", status="promoted")
|
|
175
|
+
_add_evolve(conn, "pending one", status="pending")
|
|
176
|
+
_add_evolve(conn, "dismissed one", status="dismissed")
|
|
177
|
+
from threadkeeper.brief import render_brief
|
|
178
|
+
text = render_brief(conn)
|
|
179
|
+
# suggestion text is wrapped by q(); assert on the ★ marker + substring
|
|
180
|
+
assert "★" in text
|
|
181
|
+
assert "promoted one" in text
|
|
182
|
+
assert "pending one" in text
|
|
183
|
+
assert "dismissed one" not in text
|
|
184
|
+
# the ★ marker attaches to the promoted suggestion, not the pending one
|
|
185
|
+
assert text.index("★") < text.index("promoted one")
|
|
186
|
+
# promoted sorts before pending
|
|
187
|
+
assert text.index("promoted one") < text.index("pending one")
|
|
@@ -37,6 +37,19 @@ def _bootstrap_with_env(tmp_path, monkeypatch,
|
|
|
37
37
|
"CLAUDE_PROJECTS_DIR": str(tmp_path / "fake_claude_projects"),
|
|
38
38
|
"THREADKEEPER_INGEST_INTERVAL_S": "0",
|
|
39
39
|
"THREADKEEPER_INGEST_CAP": "0",
|
|
40
|
+
# Zero every background-daemon interval so no daemon thread fires a
|
|
41
|
+
# pass mid-test and emits a counted `spawn` event that races the
|
|
42
|
+
# nudge-counter assertions. Inherited from the real shell env
|
|
43
|
+
# otherwise (a dev box with probe/evolve daemons enabled in
|
|
44
|
+
# settings.json leaks the interval into pytest).
|
|
45
|
+
"THREADKEEPER_PROBE_INTERVAL_S": "0",
|
|
46
|
+
"THREADKEEPER_EVOLVE_REVIEW_INTERVAL_S": "0",
|
|
47
|
+
"THREADKEEPER_SHADOW_REVIEW_INTERVAL_S": "0",
|
|
48
|
+
"THREADKEEPER_CURATOR_INTERVAL_S": "0",
|
|
49
|
+
"THREADKEEPER_EXTRACT_INTERVAL_S": "0",
|
|
50
|
+
"THREADKEEPER_CANDIDATE_REVIEW_INTERVAL_S": "0",
|
|
51
|
+
"THREADKEEPER_SPAWN_BUDGET_POLL_S": "0",
|
|
52
|
+
"THREADKEEPER_MEMORY_GUARD_POLL_S": "0",
|
|
40
53
|
"THREADKEEPER_TASK_LOG_DIR": str(tmp_path / "tasks"),
|
|
41
54
|
"THREADKEEPER_CLIENT": "pytest",
|
|
42
55
|
"THREADKEEPER_MEMORY_NUDGE_INTERVAL": str(memory_interval),
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Judge panel — spawned independent voters fill the distill/dialectic
|
|
2
|
+
promotion quorum, with an adversarial guard so a rubber-stamp panel can't
|
|
3
|
+
self-promote (the panel_vote origin is granted by the spawner only when a
|
|
4
|
+
skeptic is present).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_FAKE_CID = "cccc3333-4444-5555-6666-777788889999"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _bootstrap(tmp_path, monkeypatch, **env_extra):
|
|
17
|
+
env = {
|
|
18
|
+
"THREADKEEPER_DB": str(tmp_path / "db.sqlite"),
|
|
19
|
+
"CLAUDE_PROJECTS_DIR": str(tmp_path / "fake_claude_projects"),
|
|
20
|
+
"THREADKEEPER_INGEST_INTERVAL_S": "0",
|
|
21
|
+
"THREADKEEPER_INGEST_CAP": "0",
|
|
22
|
+
"THREADKEEPER_SKILL_WATCH_INTERVAL_S": "0",
|
|
23
|
+
"THREADKEEPER_SPAWN_BUDGET_POLL_S": "0",
|
|
24
|
+
"THREADKEEPER_SEARCH_PROXY_POLL_S": "0",
|
|
25
|
+
"THREADKEEPER_MEMORY_GUARD_POLL_S": "0",
|
|
26
|
+
"THREADKEEPER_SHADOW_REVIEW_INTERVAL_S": "0",
|
|
27
|
+
"THREADKEEPER_CURATOR_INTERVAL_S": "0",
|
|
28
|
+
"THREADKEEPER_EXTRACT_INTERVAL_S": "0",
|
|
29
|
+
"THREADKEEPER_CANDIDATE_REVIEW_INTERVAL_S": "0",
|
|
30
|
+
"THREADKEEPER_PROBE_INTERVAL_S": "0",
|
|
31
|
+
"THREADKEEPER_TASK_LOG_DIR": str(tmp_path / "tasks"),
|
|
32
|
+
"THREADKEEPER_CLIENT": "pytest",
|
|
33
|
+
"THREADKEEPER_FORCE_CID": _FAKE_CID,
|
|
34
|
+
"THREADKEEPER_NO_EMBEDDINGS": "1",
|
|
35
|
+
}
|
|
36
|
+
env.update(env_extra)
|
|
37
|
+
for k, v in env.items():
|
|
38
|
+
monkeypatch.setenv(k, v)
|
|
39
|
+
Path(env["CLAUDE_PROJECTS_DIR"]).mkdir(parents=True, exist_ok=True)
|
|
40
|
+
for name in [m for m in list(sys.modules) if m.startswith("threadkeeper")]:
|
|
41
|
+
del sys.modules[name]
|
|
42
|
+
import threadkeeper.server # noqa: F401
|
|
43
|
+
from threadkeeper import _mcp, db
|
|
44
|
+
return {"mcp": _mcp.mcp, "db": db}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _tool(pkg, name):
|
|
48
|
+
return pkg["mcp"]._tool_manager._tools[name].fn
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── role / adversarial composition logic ───────────────────────────────
|
|
52
|
+
|
|
53
|
+
def test_roles_default_from_config(tmp_path, monkeypatch):
|
|
54
|
+
_bootstrap(tmp_path, monkeypatch)
|
|
55
|
+
from threadkeeper.tools import panel
|
|
56
|
+
assert panel._roles() == ["skeptic", "critic", "generator"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_roles_override_and_size(tmp_path, monkeypatch):
|
|
60
|
+
_bootstrap(tmp_path, monkeypatch)
|
|
61
|
+
from threadkeeper.tools import panel
|
|
62
|
+
assert panel._roles(size=2, override="a,b,c") == ["a", "b"]
|
|
63
|
+
# size larger than role set cycles
|
|
64
|
+
assert panel._roles(size=4, override="x,y") == ["x", "y", "x", "y"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_panel_with_skeptic_is_adversarial_full_weight(tmp_path, monkeypatch):
|
|
68
|
+
_bootstrap(tmp_path, monkeypatch)
|
|
69
|
+
from threadkeeper.tools import panel
|
|
70
|
+
assert panel._panel_origin(["skeptic", "critic"]) == "panel_vote"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_panel_without_skeptic_is_discounted(tmp_path, monkeypatch):
|
|
74
|
+
_bootstrap(tmp_path, monkeypatch) # PANEL_REQUIRE_SKEPTIC defaults on
|
|
75
|
+
from threadkeeper.tools import panel
|
|
76
|
+
assert panel._panel_origin(["critic", "generator"]) == "background_review"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_require_skeptic_off_accepts_diverse_panel(tmp_path, monkeypatch):
|
|
80
|
+
_bootstrap(tmp_path, monkeypatch,
|
|
81
|
+
THREADKEEPER_PANEL_REQUIRE_SKEPTIC="0")
|
|
82
|
+
from threadkeeper.tools import panel
|
|
83
|
+
assert panel._panel_origin(["critic", "generator"]) == "panel_vote"
|
|
84
|
+
assert panel._panel_origin(["critic", "critic"]) == "background_review"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── panel_vote origin lifts dialectic evidence to full weight ──────────
|
|
88
|
+
|
|
89
|
+
def test_panel_vote_origin_full_weight(tmp_path, monkeypatch):
|
|
90
|
+
_bootstrap(tmp_path, monkeypatch)
|
|
91
|
+
from threadkeeper.tools.dialectic import _evidence_weight
|
|
92
|
+
assert _evidence_weight("panel_vote", 1.0) == 1.0
|
|
93
|
+
assert _evidence_weight("background_review", 1.0) == 0.5
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_panel_vote_weight_configurable(tmp_path, monkeypatch):
|
|
97
|
+
_bootstrap(tmp_path, monkeypatch, THREADKEEPER_PANEL_VOTE_WEIGHT="0.75")
|
|
98
|
+
from threadkeeper.tools.dialectic import _evidence_weight
|
|
99
|
+
assert _evidence_weight("panel_vote", 1.0) == 0.75
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── convene_panel dispatch (spawn monkeypatched) ───────────────────────
|
|
103
|
+
|
|
104
|
+
def _seed_distill(pkg, did="Dtest", content="reusable insight X"):
|
|
105
|
+
conn = pkg["db"].get_db()
|
|
106
|
+
conn.execute(
|
|
107
|
+
"INSERT INTO threads (id, question, state, opened_at, last_touched_at) "
|
|
108
|
+
"VALUES ('Tsrc','q','active',1,1)"
|
|
109
|
+
)
|
|
110
|
+
conn.execute(
|
|
111
|
+
"INSERT INTO distill (id, content, kind, confidence, source_thread, "
|
|
112
|
+
"created_at) VALUES (?,?,?,?,?,?)",
|
|
113
|
+
(did, content, "pattern", "high", "Tsrc", int(time.time())),
|
|
114
|
+
)
|
|
115
|
+
conn.commit()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_convene_panel_distill_spawns_voters(tmp_path, monkeypatch):
|
|
119
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
120
|
+
_seed_distill(pkg, "Dabc", "always reset network before WDA start")
|
|
121
|
+
calls = []
|
|
122
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
123
|
+
|
|
124
|
+
def _fake_spawn(**kw):
|
|
125
|
+
calls.append(kw)
|
|
126
|
+
return f"ok task=tk_{len(calls)} pid={len(calls)} child_cid=c{len(calls)}"
|
|
127
|
+
monkeypatch.setattr(spawn_mod, "spawn", _fake_spawn)
|
|
128
|
+
|
|
129
|
+
out = _tool(pkg, "convene_panel")(target_kind="distill", target_id="Dabc")
|
|
130
|
+
assert "adversarial" in out and "origin=panel_vote" in out
|
|
131
|
+
assert len(calls) == 3
|
|
132
|
+
for c in calls:
|
|
133
|
+
assert c["write_origin"] == "panel_vote"
|
|
134
|
+
assert "mcp__thread-keeper__vote_distill" in c["extra_allowed_tools"]
|
|
135
|
+
# the distillate content reached the child
|
|
136
|
+
assert "always reset network" in c["prompt"]
|
|
137
|
+
# explicit permission to dissent
|
|
138
|
+
assert "may vote against" in c["prompt"].lower()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_convene_panel_claim_uses_dialectic_tool(tmp_path, monkeypatch):
|
|
142
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
143
|
+
conn = pkg["db"].get_db()
|
|
144
|
+
conn.execute(
|
|
145
|
+
"INSERT INTO user_dialectic (id, claim, domain, confidence, state, "
|
|
146
|
+
"tier, created_at) VALUES ('UCx','user prefers spawn','workflow',"
|
|
147
|
+
"'medium','active','hypothesis',?)",
|
|
148
|
+
(int(time.time()),),
|
|
149
|
+
)
|
|
150
|
+
conn.commit()
|
|
151
|
+
calls = []
|
|
152
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
153
|
+
monkeypatch.setattr(spawn_mod, "spawn",
|
|
154
|
+
lambda **kw: calls.append(kw) or "ok task=tk1")
|
|
155
|
+
|
|
156
|
+
out = _tool(pkg, "convene_panel")(target_kind="claim", target_id="UCx")
|
|
157
|
+
assert "origin=panel_vote" in out
|
|
158
|
+
assert all(
|
|
159
|
+
"mcp__thread-keeper__dialectic_evidence" in c["extra_allowed_tools"]
|
|
160
|
+
for c in calls
|
|
161
|
+
)
|
|
162
|
+
assert all("user prefers spawn" in c["prompt"] for c in calls)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_convene_panel_unknown_target(tmp_path, monkeypatch):
|
|
166
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
167
|
+
out = _tool(pkg, "convene_panel")(target_kind="distill", target_id="Dnope")
|
|
168
|
+
assert out.startswith("ERR target_not_found")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_convene_panel_bad_kind(tmp_path, monkeypatch):
|
|
172
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
173
|
+
out = _tool(pkg, "convene_panel")(target_kind="banana", target_id="x")
|
|
174
|
+
assert out.startswith("ERR bad_target_kind")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_convene_panel_no_skeptic_is_discounted(tmp_path, monkeypatch):
|
|
178
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
179
|
+
_seed_distill(pkg, "Dq", "some insight")
|
|
180
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
181
|
+
seen = []
|
|
182
|
+
monkeypatch.setattr(spawn_mod, "spawn",
|
|
183
|
+
lambda **kw: seen.append(kw) or "ok task=t")
|
|
184
|
+
out = _tool(pkg, "convene_panel")(
|
|
185
|
+
target_kind="distill", target_id="Dq", roles="critic,generator"
|
|
186
|
+
)
|
|
187
|
+
assert "discounted" in out and "origin=background_review" in out
|
|
188
|
+
assert all(c["write_origin"] == "background_review" for c in seen)
|