trinity-lite 0.7.0__tar.gz → 0.7.2__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 (45) hide show
  1. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/PKG-INFO +6 -4
  2. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/README.md +5 -3
  3. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/pyproject.toml +1 -1
  4. trinity_lite-0.7.2/tests/test_init.py +238 -0
  5. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_mcp_server.py +13 -0
  6. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/__init__.py +1 -1
  7. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/cli.py +35 -4
  8. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/doctor.py +71 -1
  9. trinity_lite-0.7.2/trinity_lite/init.py +116 -0
  10. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/mcp_server.py +15 -4
  11. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite.egg-info/PKG-INFO +6 -4
  12. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite.egg-info/SOURCES.txt +2 -0
  13. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/LICENSE +0 -0
  14. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/setup.cfg +0 -0
  15. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_bus.py +0 -0
  16. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_cli.py +0 -0
  17. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_doctor.py +0 -0
  18. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_guard.py +0 -0
  19. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_orchestrator.py +0 -0
  20. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_pipeline.py +0 -0
  21. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_proof.py +0 -0
  22. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_router.py +0 -0
  23. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_worker.py +0 -0
  24. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/tests/test_worktree.py +0 -0
  25. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/__main__.py +0 -0
  26. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/adapters.py +0 -0
  27. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/bus.py +0 -0
  28. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/config.py +0 -0
  29. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/guard.py +0 -0
  30. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/model_autodetect.py +0 -0
  31. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/model_pool_wizard.py +0 -0
  32. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/model_selector.py +0 -0
  33. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/orchestrator.py +0 -0
  34. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/paths.py +0 -0
  35. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/pipeline.py +0 -0
  36. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/proof.py +0 -0
  37. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/py.typed +0 -0
  38. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/router.py +0 -0
  39. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/validation.py +0 -0
  40. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/worker.py +0 -0
  41. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite/worktree.py +0 -0
  42. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite.egg-info/dependency_links.txt +0 -0
  43. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite.egg-info/entry_points.txt +0 -0
  44. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite.egg-info/requires.txt +0 -0
  45. {trinity_lite-0.7.0 → trinity_lite-0.7.2}/trinity_lite.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trinity-lite
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Local AgentOps for cross-vendor CLI agents: route work, recover state, and accept only with evidence.
5
5
  Author: Yomiracle
6
6
  License-Expression: MIT
@@ -99,7 +99,7 @@ LangGraph and CrewAI give you primitives for building agents from scratch — gr
99
99
  - **Execute safely, no shell injection.** Agent commands are JSON arrays run with `shell=False`. No string interpolation into a shell. No surprises.
100
100
  - **Test with mock agents.** Mock agents simulate the full cycle without real CLIs. Prototype routing, persistence, and review handoffs first. Wire up real agents later.
101
101
  - **Guard against runaway delegation.** Self-delegation is blocked. Delegation depth is capped. Working directories are allowlisted. Safe by default.
102
- - **Check health in one pass.** `trinity-lite doctor` verifies Python, SQLite, route config, agent config, and publish readiness.
102
+ - **Check health in one pass.** `trinity-lite doctor` verifies Python, SQLite, route config, agent config, and publish readiness. `trinity-lite doctor --onboarding` grades every check as blocker or optional, probes for agent CLIs, and fails only on blockers.
103
103
  - **Zero core dependencies.** The default runtime is Python standard library only. YAML pipelines are available through an optional extra.
104
104
  - **150+ tests guarding the surface area.** Mock workflows, safety checks, routing, persistence, MCP, and acceptance gates — all covered.
105
105
  - **Optional model selection hints.** Select from your declared model pool with transparent task, tier, and capability rules; no claim of a universal best or cheapest model.
@@ -130,13 +130,15 @@ trinity-lite orchestrate "implement a rate limiter for the API"
130
130
 
131
131
  The primary task row records `route_json`, `review_task_id`, `verification_json`, `acceptance_status`, `acceptance_reason`, and `accepted_at`.
132
132
 
133
- Ready for real CLIs when you are:
133
+ Ready for real CLIs when you are — auto-detect what is installed and generate a safe starter config:
134
134
 
135
135
  ```bash
136
- cp examples/agents.command.example.json agents.local.json
136
+ trinity-lite init
137
137
  trinity-lite orchestrate "implement a rate limiter for the API" --agents agents.local.json
138
138
  ```
139
139
 
140
+ `trinity-lite init` probes PATH for Codex, Claude Code, and Hermes, writes `agents.local.json` with JSON-array commands for the CLIs it finds, keeps the rest in mock mode, and never writes credentials or overwrites an existing config without `--force`. Prefer hand-editing? `cp examples/agents.command.example.json agents.local.json` still works.
141
+
140
142
  Prefer manual control? Use the lower-level bus commands:
141
143
 
142
144
  ```bash
@@ -69,7 +69,7 @@ LangGraph and CrewAI give you primitives for building agents from scratch — gr
69
69
  - **Execute safely, no shell injection.** Agent commands are JSON arrays run with `shell=False`. No string interpolation into a shell. No surprises.
70
70
  - **Test with mock agents.** Mock agents simulate the full cycle without real CLIs. Prototype routing, persistence, and review handoffs first. Wire up real agents later.
71
71
  - **Guard against runaway delegation.** Self-delegation is blocked. Delegation depth is capped. Working directories are allowlisted. Safe by default.
72
- - **Check health in one pass.** `trinity-lite doctor` verifies Python, SQLite, route config, agent config, and publish readiness.
72
+ - **Check health in one pass.** `trinity-lite doctor` verifies Python, SQLite, route config, agent config, and publish readiness. `trinity-lite doctor --onboarding` grades every check as blocker or optional, probes for agent CLIs, and fails only on blockers.
73
73
  - **Zero core dependencies.** The default runtime is Python standard library only. YAML pipelines are available through an optional extra.
74
74
  - **150+ tests guarding the surface area.** Mock workflows, safety checks, routing, persistence, MCP, and acceptance gates — all covered.
75
75
  - **Optional model selection hints.** Select from your declared model pool with transparent task, tier, and capability rules; no claim of a universal best or cheapest model.
@@ -100,13 +100,15 @@ trinity-lite orchestrate "implement a rate limiter for the API"
100
100
 
101
101
  The primary task row records `route_json`, `review_task_id`, `verification_json`, `acceptance_status`, `acceptance_reason`, and `accepted_at`.
102
102
 
103
- Ready for real CLIs when you are:
103
+ Ready for real CLIs when you are — auto-detect what is installed and generate a safe starter config:
104
104
 
105
105
  ```bash
106
- cp examples/agents.command.example.json agents.local.json
106
+ trinity-lite init
107
107
  trinity-lite orchestrate "implement a rate limiter for the API" --agents agents.local.json
108
108
  ```
109
109
 
110
+ `trinity-lite init` probes PATH for Codex, Claude Code, and Hermes, writes `agents.local.json` with JSON-array commands for the CLIs it finds, keeps the rest in mock mode, and never writes credentials or overwrites an existing config without `--force`. Prefer hand-editing? `cp examples/agents.command.example.json agents.local.json` still works.
111
+
110
112
  Prefer manual control? Use the lower-level bus commands:
111
113
 
112
114
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trinity-lite"
7
- version = "0.7.0"
7
+ version = "0.7.2"
8
8
  description = "Local AgentOps for cross-vendor CLI agents: route work, recover state, and accept only with evidence."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,238 @@
1
+ import io
2
+ import json
3
+ import os
4
+ import stat
5
+ import tempfile
6
+ import unittest
7
+ from contextlib import redirect_stdout
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ from trinity_lite.cli import main
12
+ from trinity_lite.doctor import run_doctor
13
+ from trinity_lite.init import (
14
+ AGENT_CLI_PRESETS,
15
+ build_starter_config,
16
+ detect_agent_clis,
17
+ run_init,
18
+ )
19
+
20
+
21
+ def _fake_path(dir_path: Path, names: list[str]) -> str:
22
+ """Create executable stubs for the given CLI names in dir_path."""
23
+ for name in names:
24
+ stub = dir_path / name
25
+ stub.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
26
+ stub.chmod(stub.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
27
+ return str(dir_path)
28
+
29
+
30
+ class DetectAgentClisTest(unittest.TestCase):
31
+ def test_detects_present_clis(self):
32
+ with tempfile.TemporaryDirectory() as tmp:
33
+ path = _fake_path(Path(tmp), ["codex", "claude"])
34
+ with mock.patch.dict(os.environ, {"PATH": path}):
35
+ detected = detect_agent_clis()
36
+ self.assertTrue(detected["codex"].endswith("codex"))
37
+ self.assertTrue(detected["claude_code"].endswith("claude"))
38
+ self.assertIsNone(detected["hermes"])
39
+
40
+ def test_detects_nothing_on_empty_path(self):
41
+ with tempfile.TemporaryDirectory() as tmp:
42
+ with mock.patch.dict(os.environ, {"PATH": str(Path(tmp))}):
43
+ detected = detect_agent_clis()
44
+ self.assertTrue(all(exe is None for exe in detected.values()))
45
+ self.assertEqual(set(detected), set(AGENT_CLI_PRESETS))
46
+
47
+
48
+ class BuildStarterConfigTest(unittest.TestCase):
49
+ def test_detected_agents_use_command_mode(self):
50
+ config = build_starter_config({"codex": "/usr/bin/codex", "claude_code": None, "hermes": None})
51
+ agents = config["agents"]
52
+ self.assertEqual(agents["codex"]["mode"], "command")
53
+ self.assertEqual(agents["codex"]["command"], ["codex", "exec", "-C", "{cwd}", "{prompt}"])
54
+ self.assertEqual(agents["claude_code"]["mode"], "mock")
55
+ self.assertEqual(agents["hermes"]["mode"], "mock")
56
+
57
+ def test_commands_are_json_arrays_without_credentials(self):
58
+ config = build_starter_config({agent: f"/usr/bin/{agent}" for agent in AGENT_CLI_PRESETS})
59
+ text = json.dumps(config).lower()
60
+ for spec in config["agents"].values():
61
+ self.assertIsInstance(spec["command"], list)
62
+ self.assertTrue(all(isinstance(part, str) for part in spec["command"]))
63
+ for forbidden in ("api_key", "apikey", "token", "secret", "password"):
64
+ self.assertNotIn(forbidden, text)
65
+
66
+
67
+ class RunInitTest(unittest.TestCase):
68
+ def test_writes_starter_config_with_detected_clis(self):
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ root = Path(tmp)
71
+ (root / "bin").mkdir()
72
+ path = _fake_path(root / "bin", ["codex"])
73
+ out = root / "agents.local.json"
74
+ with mock.patch.dict(os.environ, {"PATH": path}):
75
+ result = run_init(out_path=str(out))
76
+ self.assertTrue(result["written"])
77
+ self.assertFalse(result["skipped"])
78
+ self.assertFalse(result["mock_only"])
79
+ self.assertEqual([d["agent"] for d in result["detected"]], ["codex"])
80
+ self.assertEqual(result["missing"], ["claude_code", "hermes"])
81
+ config = json.loads(out.read_text(encoding="utf-8"))
82
+ self.assertEqual(config["agents"]["codex"]["mode"], "command")
83
+ self.assertEqual(config["agents"]["claude_code"]["mode"], "mock")
84
+
85
+ def test_mock_only_config_when_no_cli_found(self):
86
+ with tempfile.TemporaryDirectory() as tmp:
87
+ root = Path(tmp)
88
+ out = root / "agents.local.json"
89
+ with mock.patch.dict(os.environ, {"PATH": str(root)}):
90
+ result = run_init(out_path=str(out))
91
+ self.assertTrue(result["written"])
92
+ self.assertTrue(result["mock_only"])
93
+ self.assertEqual(result["detected"], [])
94
+ config = json.loads(out.read_text(encoding="utf-8"))
95
+ self.assertTrue(all(s["mode"] == "mock" for s in config["agents"].values()))
96
+
97
+ def test_never_overwrites_existing_config_without_force(self):
98
+ with tempfile.TemporaryDirectory() as tmp:
99
+ out = Path(tmp) / "agents.local.json"
100
+ out.write_text('{"agents": {"custom": {"mode": "mock"}}}\n', encoding="utf-8")
101
+ with mock.patch.dict(os.environ, {"PATH": str(Path(tmp))}):
102
+ result = run_init(out_path=str(out))
103
+ self.assertFalse(result["written"])
104
+ self.assertTrue(result["skipped"])
105
+ self.assertIn("exists", result["reason"])
106
+ self.assertIn("custom", out.read_text(encoding="utf-8"))
107
+
108
+ def test_force_overwrites_existing_config(self):
109
+ with tempfile.TemporaryDirectory() as tmp:
110
+ out = Path(tmp) / "agents.local.json"
111
+ out.write_text('{"agents": {"custom": {"mode": "mock"}}}\n', encoding="utf-8")
112
+ with mock.patch.dict(os.environ, {"PATH": str(Path(tmp))}):
113
+ result = run_init(out_path=str(out), force=True)
114
+ self.assertTrue(result["written"])
115
+ self.assertTrue(result["forced"])
116
+ config = json.loads(out.read_text(encoding="utf-8"))
117
+ self.assertNotIn("custom", config["agents"])
118
+
119
+
120
+ class InitCliTest(unittest.TestCase):
121
+ def test_cli_init_writes_config_and_prints_json(self):
122
+ with tempfile.TemporaryDirectory() as tmp:
123
+ out = Path(tmp) / "agents.local.json"
124
+ output = io.StringIO()
125
+ with mock.patch.dict(os.environ, {"PATH": str(Path(tmp))}):
126
+ with redirect_stdout(output):
127
+ code = main(["init", "--out", str(out)])
128
+ self.assertEqual(code, 0)
129
+ result = json.loads(output.getvalue())
130
+ self.assertTrue(result["written"])
131
+ self.assertTrue(out.exists())
132
+
133
+ def test_cli_init_skips_existing_without_force(self):
134
+ with tempfile.TemporaryDirectory() as tmp:
135
+ out = Path(tmp) / "agents.local.json"
136
+ out.write_text("{}", encoding="utf-8")
137
+ output = io.StringIO()
138
+ with redirect_stdout(output):
139
+ code = main(["init", "--out", str(out)])
140
+ self.assertEqual(code, 0)
141
+ result = json.loads(output.getvalue())
142
+ self.assertTrue(result["skipped"])
143
+ self.assertEqual(out.read_text(encoding="utf-8"), "{}")
144
+
145
+
146
+ class OnboardingDoctorTest(unittest.TestCase):
147
+ def test_run_doctor_marks_levels_on_all_checks(self):
148
+ with tempfile.TemporaryDirectory() as tmp:
149
+ report = run_doctor(db_path=str(Path(tmp) / "bus.db"))
150
+ self.assertTrue(report["checks"])
151
+ for check in report["checks"]:
152
+ self.assertIn(check["level"], ("blocker", "optional"))
153
+ self.assertEqual(report["blockers_failed"], [])
154
+ self.assertEqual(report["optional_failed"], [])
155
+
156
+ def test_onboarding_adds_agent_cli_check_as_optional(self):
157
+ with tempfile.TemporaryDirectory() as tmp:
158
+ with mock.patch.dict(os.environ, {"PATH": str(Path(tmp))}):
159
+ report = run_doctor(db_path=str(Path(tmp) / "bus.db"), onboarding=True)
160
+ cli_check = next(c for c in report["checks"] if c["name"] == "agent_clis")
161
+ self.assertEqual(cli_check["level"], "optional")
162
+ self.assertFalse(cli_check["ok"])
163
+ self.assertIn("hint", cli_check)
164
+ # Optional failure alone does not make onboarding unhealthy.
165
+ self.assertEqual(report["status"], "healthy")
166
+ self.assertEqual(report["blockers_failed"], [])
167
+ self.assertEqual(report["optional_failed"], ["agent_clis"])
168
+
169
+ def test_onboarding_unhealthy_only_on_blocker_failure(self):
170
+ with tempfile.TemporaryDirectory() as tmp:
171
+ root = Path(tmp)
172
+ agents_path = root / "agents.json"
173
+ agents_path.write_text(json.dumps({"agents": {"bad": {"mode": "nope"}}}), encoding="utf-8")
174
+ with mock.patch.dict(os.environ, {"PATH": str(root)}):
175
+ report = run_doctor(
176
+ db_path=str(root / "bus.db"),
177
+ agents_path=str(agents_path),
178
+ onboarding=True,
179
+ )
180
+ self.assertEqual(report["status"], "unhealthy")
181
+ self.assertIn("agents", report["blockers_failed"])
182
+ self.assertIn("agent_clis", report["optional_failed"])
183
+
184
+ def test_default_doctor_status_unchanged_by_optional_failures(self):
185
+ # Default (non-onboarding) mode keeps the old semantics: any failed
186
+ # check makes the report unhealthy.
187
+ with tempfile.TemporaryDirectory() as tmp:
188
+ root = Path(tmp)
189
+ runtime = root / "runtime"
190
+ runtime.mkdir()
191
+ report = run_doctor(db_path=str(root / "bus.db"), runtime_root=str(runtime))
192
+ self.assertEqual(report["status"], "unhealthy")
193
+ self.assertIn("runtime_metrics", report["optional_failed"])
194
+
195
+ def test_cli_doctor_onboarding_exit_codes(self):
196
+ with tempfile.TemporaryDirectory() as tmp:
197
+ root = Path(tmp)
198
+ # Healthy blockers + missing CLIs (optional) -> exit 0.
199
+ output = io.StringIO()
200
+ with mock.patch.dict(os.environ, {"PATH": str(root)}):
201
+ with redirect_stdout(output):
202
+ code = main(["doctor", "--onboarding", "--db", str(root / "bus.db")])
203
+ self.assertEqual(code, 0)
204
+ report = json.loads(output.getvalue())
205
+ self.assertEqual(report["status"], "healthy")
206
+
207
+ # Blocker failure -> exit 1.
208
+ bad_agents = root / "agents.json"
209
+ bad_agents.write_text(json.dumps({"agents": {"bad": {"mode": "nope"}}}), encoding="utf-8")
210
+ output = io.StringIO()
211
+ with mock.patch.dict(os.environ, {"PATH": str(root)}):
212
+ with redirect_stdout(output):
213
+ code = main([
214
+ "doctor", "--onboarding",
215
+ "--db", str(root / "bus.db"),
216
+ "--agents", str(bad_agents),
217
+ ])
218
+ self.assertEqual(code, 1)
219
+ report = json.loads(output.getvalue())
220
+ self.assertIn("agents", report["blockers_failed"])
221
+
222
+ def test_cli_doctor_default_still_exits_zero_on_failure(self):
223
+ with tempfile.TemporaryDirectory() as tmp:
224
+ root = Path(tmp)
225
+ bad_agents = root / "agents.json"
226
+ bad_agents.write_text(json.dumps({"agents": {"bad": {"mode": "nope"}}}), encoding="utf-8")
227
+ output = io.StringIO()
228
+ with redirect_stdout(output):
229
+ code = main([
230
+ "doctor",
231
+ "--db", str(root / "bus.db"),
232
+ "--agents", str(bad_agents),
233
+ ])
234
+ self.assertEqual(code, 0)
235
+
236
+
237
+ if __name__ == "__main__":
238
+ unittest.main()
@@ -75,6 +75,19 @@ class McpServerTest(unittest.TestCase):
75
75
  resp = self._call(self._msg("initialized"))
76
76
  self.assertIsNone(resp)
77
77
 
78
+ def test_notifications_initialized_returns_none(self):
79
+ # Real MCP clients send `notifications/initialized` (no id) after
80
+ # initialize. Must return None and never emit an id:null error.
81
+ resp = self._call({"jsonrpc": "2.0", "method": "notifications/initialized"})
82
+ self.assertIsNone(resp)
83
+
84
+ def test_unknown_notification_returns_none(self):
85
+ # Any notification (no id) must not receive a response, even if the
86
+ # method is unknown. Regression for id:null error responses that
87
+ # crashed the official mcp SDK >= 2.x stdio transport.
88
+ resp = self._call({"jsonrpc": "2.0", "method": "notifications/whatever"})
89
+ self.assertIsNone(resp)
90
+
78
91
  def test_shutdown_returns_empty_result(self):
79
92
  resp = self._call(self._msg("shutdown", 1, {}))
80
93
  self.assertEqual(resp["id"], 1)
@@ -1,3 +1,3 @@
1
1
  """Trinity Lite: local AgentOps for cross-vendor CLI agents."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.7.2"
@@ -53,7 +53,7 @@ Trinity Lite — multi-agent task bus
53
53
  Quick demo: trinity-lite demo
54
54
  Full help: trinity-lite --help
55
55
 
56
- Commands: demo, dispatch, dispatch-auto, worker, orchestrate, worktree,
56
+ Commands: demo, init, dispatch, dispatch-auto, worker, orchestrate, worktree,
57
57
  status, latest, tasks, route, proof, doctor, send, inbox, mcp,
58
58
  setup-models, detect-models"""
59
59
 
@@ -173,6 +173,28 @@ def build_parser() -> argparse.ArgumentParser:
173
173
  default=[],
174
174
  help="TCP port that should not be listening, repeatable",
175
175
  )
176
+ doctor.add_argument(
177
+ "--onboarding",
178
+ action="store_true",
179
+ help="onboarding mode: also probe agent CLIs, grade checks as blocker/optional, "
180
+ "and exit non-zero only when a blocker fails",
181
+ )
182
+
183
+ init_cmd = sub.add_parser(
184
+ "init",
185
+ parents=[common],
186
+ help="detect local agent CLIs and write a safe starter agents config",
187
+ )
188
+ init_cmd.add_argument(
189
+ "--out",
190
+ default=None,
191
+ help="output path (default: ./agents.local.json)",
192
+ )
193
+ init_cmd.add_argument(
194
+ "--force",
195
+ action="store_true",
196
+ help="overwrite an existing config file",
197
+ )
176
198
 
177
199
  version_cmd = sub.add_parser("version", parents=[common], help="show version and exit")
178
200
 
@@ -228,7 +250,12 @@ def run_command(args: argparse.Namespace) -> int:
228
250
  if args.command == "worktree":
229
251
  return _worktree(args)
230
252
 
231
- # Model pool commands do not use the task bus.
253
+ # Init and model pool commands do not use the task bus.
254
+ if args.command == "init":
255
+ from .init import run_init
256
+
257
+ print_json(run_init(out_path=args.out, force=args.force))
258
+ return 0
232
259
  if args.command == "setup-models":
233
260
  from .model_pool_wizard import main as wizard_main
234
261
  wizard_main()
@@ -342,14 +369,18 @@ def run_command(args: argparse.Namespace) -> int:
342
369
  print_json(bus.inbox(args.agent, unread_only=not args.all, mark_read=args.mark_read, limit=args.limit))
343
370
  return 0
344
371
  if args.command == "doctor":
345
- print_json(run_doctor(
372
+ report = run_doctor(
346
373
  args.db,
347
374
  args.routes,
348
375
  args.agents,
349
376
  args.scan_root,
350
377
  args.runtime_root,
351
378
  args.retired_port,
352
- ))
379
+ onboarding=getattr(args, "onboarding", False),
380
+ )
381
+ print_json(report)
382
+ if getattr(args, "onboarding", False):
383
+ return 1 if report["blockers_failed"] else 0
353
384
  return 0
354
385
  if args.command == "demo":
355
386
  return _demo(args, bus)
@@ -24,6 +24,33 @@ RETIRED_RUNTIME_ARTIFACTS = {
24
24
  "trinity_learn.db-shm",
25
25
  }
26
26
 
27
+ # Checks that never block a first run. Everything else is a blocker:
28
+ # without it the bus, routes, or agent config cannot work at all.
29
+ OPTIONAL_CHECKS = {
30
+ "public_tree_scan",
31
+ "runtime_root",
32
+ "runtime_metrics",
33
+ "retired_runtime_artifacts",
34
+ "retired_port",
35
+ "agent_clis",
36
+ }
37
+
38
+ # Hints attached to failed optional checks so onboarding users know the
39
+ # failure does not block the mock-agent path.
40
+ OPTIONAL_HINTS = {
41
+ "agent_clis": "no supported agent CLI on PATH; mock agents still work, or run `trinity-lite init` after installing one",
42
+ "public_tree_scan": "resolve before publishing this tree",
43
+ "runtime_root": "runtime state directory is not ready yet",
44
+ "runtime_metrics": "start the runtime once to generate metrics",
45
+ "retired_runtime_artifacts": "remove retired artifacts from the runtime directory",
46
+ "retired_port": "stop the listener on the retired port",
47
+ }
48
+
49
+
50
+ def _check_level(name: str) -> str:
51
+ base = name.split(":", 1)[0]
52
+ return "optional" if base in OPTIONAL_CHECKS else "blocker"
53
+
27
54
 
28
55
  def run_doctor(
29
56
  db_path: str | None = None,
@@ -32,6 +59,7 @@ def run_doctor(
32
59
  scan_root: str | None = None,
33
60
  runtime_root: str | None = None,
34
61
  retired_ports: list[int] | None = None,
62
+ onboarding: bool = False,
35
63
  ) -> dict[str, Any]:
36
64
  checks: list[dict[str, Any]] = []
37
65
 
@@ -99,12 +127,54 @@ def run_doctor(
99
127
  for port in retired_ports or []:
100
128
  checks.append(_retired_port_check(port))
101
129
 
130
+ if onboarding:
131
+ checks.append(_agent_cli_check())
132
+
133
+ for check in checks:
134
+ check["level"] = _check_level(check["name"])
135
+ if not check["ok"] and check["level"] == "optional":
136
+ hint = OPTIONAL_HINTS.get(check["name"].split(":", 1)[0])
137
+ if hint:
138
+ check["hint"] = hint
139
+
140
+ blockers_failed = [
141
+ c["name"] for c in checks if not c["ok"] and c["level"] == "blocker"
142
+ ]
143
+ optional_failed = [
144
+ c["name"] for c in checks if not c["ok"] and c["level"] == "optional"
145
+ ]
146
+
147
+ if onboarding:
148
+ status = "healthy" if not blockers_failed else "unhealthy"
149
+ else:
150
+ status = "healthy" if all(c["ok"] for c in checks) else "unhealthy"
151
+
102
152
  return {
103
- "status": "healthy" if all(c["ok"] for c in checks) else "unhealthy",
153
+ "status": status,
154
+ "onboarding": bool(onboarding),
155
+ "blockers_failed": blockers_failed,
156
+ "optional_failed": optional_failed,
104
157
  "checks": checks,
105
158
  }
106
159
 
107
160
 
161
+ def _agent_cli_check() -> dict[str, Any]:
162
+ """Probe PATH for supported agent CLIs (always optional)."""
163
+ from .init import detect_agent_clis
164
+
165
+ detected = detect_agent_clis()
166
+ found = sorted(agent for agent, exe in detected.items() if exe)
167
+ missing = sorted(agent for agent, exe in detected.items() if not exe)
168
+ return {
169
+ "name": "agent_clis",
170
+ "ok": bool(found),
171
+ "detail": {
172
+ "found": {agent: detected[agent] for agent in found},
173
+ "missing": missing,
174
+ },
175
+ }
176
+
177
+
108
178
  def _bus_acceptance_checks(bus: TrinityBus) -> list[dict[str, Any]]:
109
179
  with bus.connect() as conn:
110
180
  columns = {
@@ -0,0 +1,116 @@
1
+ """Detect local agent CLIs and generate a safe starter agents config.
2
+
3
+ This module supports the five-minute onboarding path: probe PATH for
4
+ supported agent CLIs, then write an ``agents.local.json`` that runs
5
+ detected CLIs in command mode and keeps everything else on mock agents.
6
+
7
+ Safety rules:
8
+
9
+ - Commands are JSON arrays only; Trinity Lite executes them with
10
+ ``shell=False``.
11
+ - No credentials, API keys, tokens, or private paths are ever written.
12
+ - An existing config is never overwritten unless ``force=True``.
13
+ - When no supported CLI is found, the generated config is mock-only and
14
+ still fully usable.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import shutil
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ # Supported agent CLI presets. Each entry maps the Trinity Lite agent id to
25
+ # the executable probed on PATH and the safe command template aligned with
26
+ # docs/REAL_AGENTS.md and examples/agents.command.example.json.
27
+ AGENT_CLI_PRESETS: dict[str, dict[str, Any]] = {
28
+ "codex": {
29
+ "executable": "codex",
30
+ "command": ["codex", "exec", "-C", "{cwd}", "{prompt}"],
31
+ },
32
+ "claude_code": {
33
+ "executable": "claude",
34
+ "command": ["claude", "-p", "{prompt}"],
35
+ },
36
+ "hermes": {
37
+ "executable": "hermes",
38
+ "command": ["hermes", "-z", "{prompt}"],
39
+ },
40
+ }
41
+
42
+ DEFAULT_TIMEOUT = 1800
43
+ DEFAULT_CONFIG_NAME = "agents.local.json"
44
+
45
+
46
+ def detect_agent_clis() -> dict[str, str | None]:
47
+ """Probe PATH for supported agent CLI executables.
48
+
49
+ Returns a mapping of agent id to the resolved executable path, or None
50
+ when the executable is not on PATH.
51
+ """
52
+ found: dict[str, str | None] = {}
53
+ for agent_id, preset in AGENT_CLI_PRESETS.items():
54
+ found[agent_id] = shutil.which(preset["executable"])
55
+ return found
56
+
57
+
58
+ def build_starter_config(detected: dict[str, str | None]) -> dict[str, Any]:
59
+ """Build an agents config from detection results.
60
+
61
+ Detected CLIs become command-mode agents with safe JSON-array commands;
62
+ undetected presets stay in mock mode so the bus works out of the box.
63
+ """
64
+ agents: dict[str, Any] = {}
65
+ for agent_id, preset in AGENT_CLI_PRESETS.items():
66
+ if detected.get(agent_id):
67
+ agents[agent_id] = {
68
+ "mode": "command",
69
+ "command": list(preset["command"]),
70
+ "timeout": DEFAULT_TIMEOUT,
71
+ }
72
+ else:
73
+ agents[agent_id] = {
74
+ "mode": "mock",
75
+ "timeout": DEFAULT_TIMEOUT,
76
+ }
77
+ return {"agents": agents}
78
+
79
+
80
+ def run_init(out_path: str | None = None, force: bool = False) -> dict[str, Any]:
81
+ """Detect agent CLIs and write a starter config.
82
+
83
+ Returns a stable JSON-serializable result describing what was detected
84
+ and what was written. Never raises for a missing CLI; a mock-only
85
+ config is a successful outcome.
86
+ """
87
+ target = Path(out_path) if out_path else Path.cwd() / DEFAULT_CONFIG_NAME
88
+ detected = detect_agent_clis()
89
+ found = sorted(agent for agent, exe in detected.items() if exe)
90
+ missing = sorted(agent for agent, exe in detected.items() if not exe)
91
+
92
+ result: dict[str, Any] = {
93
+ "config_path": str(target),
94
+ "detected": [
95
+ {"agent": agent, "executable": detected[agent]} for agent in found
96
+ ],
97
+ "missing": missing,
98
+ "mock_only": not found,
99
+ "forced": bool(force),
100
+ "written": False,
101
+ "skipped": False,
102
+ }
103
+
104
+ if target.exists() and not force:
105
+ result["skipped"] = True
106
+ result["reason"] = f"exists: {target} (use --force to overwrite)"
107
+ return result
108
+
109
+ config = build_starter_config(detected)
110
+ target.parent.mkdir(parents=True, exist_ok=True)
111
+ target.write_text(
112
+ json.dumps(config, ensure_ascii=False, indent=2) + "\n",
113
+ encoding="utf-8",
114
+ )
115
+ result["written"] = True
116
+ return result
@@ -374,6 +374,7 @@ RESOURCE_DEFINITIONS = [
374
374
  ALLOWED_METHODS = {
375
375
  "initialize",
376
376
  "initialized",
377
+ "notifications/initialized",
377
378
  "tools/list",
378
379
  "tools/call",
379
380
  "resources/list",
@@ -1073,6 +1074,13 @@ def handle_request(msg, bus, agents_path, routes_path):
1073
1074
  id_ = msg.get("id")
1074
1075
  params = msg.get("params", {})
1075
1076
 
1077
+ # JSON-RPC 2.0: notifications carry no `id` and must NOT receive a
1078
+ # response. Silently accept any notification (known or unknown) so we
1079
+ # never emit an `id: null` error response, which the official `mcp`
1080
+ # SDK >= 2.x stdio transport fails to validate and closes the stream.
1081
+ if id_ is None:
1082
+ return None
1083
+
1076
1084
  if method not in ALLOWED_METHODS:
1077
1085
  return jsonrpc_error(id_, -32601, "method not found: {}".format(method))
1078
1086
 
@@ -1083,7 +1091,7 @@ def handle_request(msg, bus, agents_path, routes_path):
1083
1091
  "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
1084
1092
  })
1085
1093
 
1086
- if method == "initialized":
1094
+ if method in ("initialized", "notifications/initialized"):
1087
1095
  return None
1088
1096
 
1089
1097
  if method == "tools/list":
@@ -1143,9 +1151,12 @@ def serve(bus, agents_path=None, routes_path=None):
1143
1151
  except Exception:
1144
1152
  tb = traceback.format_exc()
1145
1153
  print("[mcp] unhandled exception:\n{}".format(tb), file=sys.stderr, flush=True)
1146
- error_resp = jsonrpc_error(msg.get("id"), -32603, "internal server error")
1147
- sys.stdout.write(json.dumps(error_resp) + "\n")
1148
- sys.stdout.flush()
1154
+ # Don't emit an `id: null` error for notifications (no id).
1155
+ _err_id = msg.get("id")
1156
+ if _err_id is not None:
1157
+ error_resp = jsonrpc_error(_err_id, -32603, "internal server error")
1158
+ sys.stdout.write(json.dumps(error_resp) + "\n")
1159
+ sys.stdout.flush()
1149
1160
  continue
1150
1161
 
1151
1162
  # Write response
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trinity-lite
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Local AgentOps for cross-vendor CLI agents: route work, recover state, and accept only with evidence.
5
5
  Author: Yomiracle
6
6
  License-Expression: MIT
@@ -99,7 +99,7 @@ LangGraph and CrewAI give you primitives for building agents from scratch — gr
99
99
  - **Execute safely, no shell injection.** Agent commands are JSON arrays run with `shell=False`. No string interpolation into a shell. No surprises.
100
100
  - **Test with mock agents.** Mock agents simulate the full cycle without real CLIs. Prototype routing, persistence, and review handoffs first. Wire up real agents later.
101
101
  - **Guard against runaway delegation.** Self-delegation is blocked. Delegation depth is capped. Working directories are allowlisted. Safe by default.
102
- - **Check health in one pass.** `trinity-lite doctor` verifies Python, SQLite, route config, agent config, and publish readiness.
102
+ - **Check health in one pass.** `trinity-lite doctor` verifies Python, SQLite, route config, agent config, and publish readiness. `trinity-lite doctor --onboarding` grades every check as blocker or optional, probes for agent CLIs, and fails only on blockers.
103
103
  - **Zero core dependencies.** The default runtime is Python standard library only. YAML pipelines are available through an optional extra.
104
104
  - **150+ tests guarding the surface area.** Mock workflows, safety checks, routing, persistence, MCP, and acceptance gates — all covered.
105
105
  - **Optional model selection hints.** Select from your declared model pool with transparent task, tier, and capability rules; no claim of a universal best or cheapest model.
@@ -130,13 +130,15 @@ trinity-lite orchestrate "implement a rate limiter for the API"
130
130
 
131
131
  The primary task row records `route_json`, `review_task_id`, `verification_json`, `acceptance_status`, `acceptance_reason`, and `accepted_at`.
132
132
 
133
- Ready for real CLIs when you are:
133
+ Ready for real CLIs when you are — auto-detect what is installed and generate a safe starter config:
134
134
 
135
135
  ```bash
136
- cp examples/agents.command.example.json agents.local.json
136
+ trinity-lite init
137
137
  trinity-lite orchestrate "implement a rate limiter for the API" --agents agents.local.json
138
138
  ```
139
139
 
140
+ `trinity-lite init` probes PATH for Codex, Claude Code, and Hermes, writes `agents.local.json` with JSON-array commands for the CLIs it finds, keeps the rest in mock mode, and never writes credentials or overwrites an existing config without `--force`. Prefer hand-editing? `cp examples/agents.command.example.json agents.local.json` still works.
141
+
140
142
  Prefer manual control? Use the lower-level bus commands:
141
143
 
142
144
  ```bash
@@ -5,6 +5,7 @@ tests/test_bus.py
5
5
  tests/test_cli.py
6
6
  tests/test_doctor.py
7
7
  tests/test_guard.py
8
+ tests/test_init.py
8
9
  tests/test_mcp_server.py
9
10
  tests/test_orchestrator.py
10
11
  tests/test_pipeline.py
@@ -20,6 +21,7 @@ trinity_lite/cli.py
20
21
  trinity_lite/config.py
21
22
  trinity_lite/doctor.py
22
23
  trinity_lite/guard.py
24
+ trinity_lite/init.py
23
25
  trinity_lite/mcp_server.py
24
26
  trinity_lite/model_autodetect.py
25
27
  trinity_lite/model_pool_wizard.py
File without changes
File without changes